Files
auto-stock/backend/tests/test_dt_api_engine_signals_closeall_20261009.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

321 lines
15 KiB
Python

"""/daytrading API 하드닝 회귀 테스트 (2026-10-09, 하드닝 루프 라운드 1).
1. /close-all — 인메모리 포지션이 없으면 dt_cycle_lock 안에서 close_stale_positions()
로 전일·고아 DT 잔고를 정리한다(예전엔 '활성 포지션 없음'으로 즉시 반환).
2. /signals·/execute(dry_run) — 레거시 모듈 상태(항상 빈 _orb_cache/_positions/
_closed_today) 대신 엔진 상태(day_trading_state, entry_service 분봉 캐시)로 판정.
레거시 매매 사이클은 매매하지 않는 정지 스텁.
목킹 원칙(§4.1): 호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 로 계산.
"""
import asyncio
from datetime import datetime
from unittest.mock import AsyncMock, patch
import pytest
import pytz
from fastapi import HTTPException
from app.core.config import settings
from app.api.endpoints import day_trading as dt
from app.services.day_trading_strategy import (
DayTradeSignal, MinuteCandle, OpeningRange, calculate_day_trade_position_size,
)
from app.services.daytrading import entry_service as ens
from app.services.daytrading import exit_service as xs
from app.services.daytrading.position_manager import day_trading_state, dt_cycle_lock
class MustNotBeCalled(BaseException):
"""호출되면 안 되는 경로 도달 (except Exception 에 삼켜지지 않게 BaseException)."""
def _boom(name):
async def _f(*a, **k):
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
return _f
def _boom_sync(name):
def _f(*a, **k):
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
return _f
@pytest.fixture(autouse=True)
def _isolate_state():
saved_pos = dict(day_trading_state.positions)
saved_closed = set(day_trading_state.closed_today)
saved_orb = dict(day_trading_state.orb_cache)
saved_pnl = day_trading_state.daily_pnl
saved_assets = day_trading_state.last_total_assets
saved_wl = dict(ens._watchlist)
saved_wl_auto = ens._watchlist_auto
day_trading_state.reset_if_new_day()
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
day_trading_state.orb_cache.clear()
day_trading_state.daily_pnl = 0.0
ens._candle_cache.clear()
# 레거시 모듈 상태는 비어 있다는 전제(실운영과 동일)
dt._orb_cache.clear()
dt._positions.clear()
dt._closed_today.clear()
# 주문 공통 종단은 어떤 테스트에서도 호출되면 안 된다
with patch.object(xs, "_execute_exit_impl", _boom("_execute_exit_impl")), \
patch.object(ens, "_place_and_record_entry_impl", _boom("_place_and_record_entry_impl")):
yield
day_trading_state.positions.clear()
day_trading_state.positions.update(saved_pos)
day_trading_state.closed_today.clear()
day_trading_state.closed_today.update(saved_closed)
day_trading_state.orb_cache.clear()
day_trading_state.orb_cache.update(saved_orb)
day_trading_state.daily_pnl = saved_pnl
day_trading_state.last_total_assets = saved_assets
ens._watchlist = saved_wl
ens._watchlist_auto = saved_wl_auto
ens._candle_cache.clear()
# ═════════════════════════════════════════════════════════════════════════════
# 1. /close-all — 인메모리 비어 있으면 전일·고아 잔고 정리
# ═════════════════════════════════════════════════════════════════════════════
@pytest.mark.asyncio
async def test_close_all_empty_memory_closes_stale_under_cycle_lock():
seen = []
async def _stale():
seen.append(dt_cycle_lock.locked())
return [{"stock_code": "111111", "reason": "전일 미청산"}]
with patch.object(dt, "is_market_open", return_value=True), \
patch.object(xs, "close_stale_positions", _stale), \
patch.object(xs, "force_close_all", _boom("force_close_all")):
res = await dt.close_all_positions()
assert seen == [True], "close_stale_positions 는 dt_cycle_lock 안에서 1회 호출돼야 한다"
assert res["stale_checked"] is True
assert res["exits"] == [{"stock_code": "111111", "reason": "전일 미청산"}]
assert res["stale_exits"] == res["exits"]
assert "1건" in res["message"]
assert not dt_cycle_lock.locked()
@pytest.mark.asyncio
async def test_close_all_empty_memory_market_closed_does_not_sell():
with patch.object(dt, "is_market_open", return_value=False), \
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")), \
patch.object(xs, "force_close_all", _boom("force_close_all")):
res = await dt.close_all_positions()
assert res["stale_checked"] is False
assert res["exits"] == []
@pytest.mark.asyncio
async def test_close_all_empty_memory_rejects_while_cycle_holds_lock():
with patch.object(dt, "is_market_open", return_value=True), \
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
async with dt_cycle_lock:
with pytest.raises(HTTPException) as ei:
# 락을 기다리면(=회귀) 교착 대신 TimeoutError 로 실패하게 상한을 둔다
await asyncio.wait_for(dt.close_all_positions(), timeout=1.0)
assert ei.value.status_code == 409
@pytest.mark.asyncio
async def test_close_all_with_positions_still_delegates_to_force_close_all():
day_trading_state.positions["222222"] = {"entry_price": 10_000.0, "quantity": 3}
fca = AsyncMock(return_value=[{"stock_code": "222222"}])
with patch.object(xs, "force_close_all", fca), \
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
res = await dt.close_all_positions()
fca.assert_awaited_once()
assert res["exits"] == [{"stock_code": "222222"}]
# ── [R4] 보유 경로도 dt_cycle_lock 안에서 청산 ────────────────────────────────
@pytest.mark.asyncio
async def test_close_all_with_positions_runs_force_close_under_cycle_lock():
"""보유 경로 force_close_all 이 스케줄러 진입 스캔과 겹치지 않게 dt_cycle_lock 안에서 호출."""
day_trading_state.positions["333333"] = {"entry_price": 10_000.0, "quantity": 2}
seen = []
async def _fca(reason="강제 청산"):
seen.append(dt_cycle_lock.locked())
day_trading_state.positions.pop("333333", None)
return [{"stock_code": "333333"}]
with patch.object(xs, "force_close_all", _fca), \
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
res = await dt.close_all_positions()
assert seen == [True]
assert res["remaining_positions"] == []
assert "잔존" not in res["message"]
assert not dt_cycle_lock.locked()
@pytest.mark.asyncio
async def test_close_all_with_positions_rejects_while_cycle_holds_lock():
"""사이클(진입 스캔 포함)이 락을 쥐고 있으면 기다리지 않고 409 — 청산 경로 미호출."""
day_trading_state.positions["444444"] = {"entry_price": 10_000.0, "quantity": 1}
with patch.object(xs, "force_close_all", _boom("force_close_all")), \
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
async with dt_cycle_lock:
with pytest.raises(HTTPException) as ei:
await asyncio.wait_for(dt.close_all_positions(), timeout=1.0)
assert ei.value.status_code == 409
@pytest.mark.asyncio
async def test_close_all_reports_positions_remaining_after_force_close():
"""청산 후에도 남은 포지션(진행 중·실패)은 '청산 완료' 문구에 숨기지 않고 드러낸다."""
day_trading_state.positions["555555"] = {"entry_price": 10_000.0, "quantity": 1}
day_trading_state.positions["666666"] = {"entry_price": 10_000.0, "quantity": 1}
async def _fca(reason="강제 청산"):
day_trading_state.positions.pop("555555", None)
return [{"stock_code": "555555"}]
with patch.object(xs, "force_close_all", _fca), \
patch.object(xs, "close_stale_positions", _boom("close_stale_positions")):
res = await dt.close_all_positions()
assert res["remaining_positions"] == ["666666"]
assert "1건 잔존" in res["message"]
# ═════════════════════════════════════════════════════════════════════════════
# 2. /signals·/execute — 엔진 상태 기준
# ═════════════════════════════════════════════════════════════════════════════
_KST = pytz.timezone("Asia/Seoul")
def _today_candles(n: int = 30, price: float = 10_000.0) -> list:
today = datetime.now(_KST).strftime("%Y%m%d")
out = []
for i in range(n):
hh, mm = divmod(9 * 60 + 30 + i, 60)
out.append(MinuteCandle(
date=today, time=f"{hh:02d}{mm:02d}00",
open=price, high=price + 10, low=price - 10, close=price,
volume=1000, cum_volume=1000 * (i + 1), cum_amount=price * 1000 * (i + 1),
))
return out
@pytest.mark.asyncio
async def test_signals_use_engine_state_orb_held_closed_and_dt_budget():
total_assets = 10_000_000.0
available_cash = 5_000_000.0
price = 10_000.0
# 엔진 상태: 감시 3종목 중 1개 보유(DT 투입액 차감 대상), 1개 당일 청산
ens._watchlist = {"AAAAAA": "에이", "BBBBBB": "비", "CCCCCC": "씨"}
held_amount = total_assets * settings.DAY_TRADE_MAX_RATIO_DT * 0.9
day_trading_state.positions["BBBBBB"] = {
"entry_price": held_amount / 100, "quantity": 100, "stock_name": "비",
}
day_trading_state.closed_today.add("CCCCCC")
engine_orb = OpeningRange(high=9_990.0, low=9_900.0, total_volume=10_000,
avg_vol_per_min=100.0, open_price=9_950.0)
day_trading_state.orb_cache["AAAAAA"] = engine_orb
fetched = []
async def _candles(code):
fetched.append(code)
return _today_candles(price=price)
orb_seen = []
confidence = 0.8
def _orb_signal(code, name, candles, orb, cur):
orb_seen.append(orb)
return DayTradeSignal(
stock_code=code, stock_name=name, strategy="ORB", entry_price=cur,
stop_loss=cur * 0.98, target_price=cur * 1.03, reason="test", confidence=confidence,
)
with patch.object(dt, "is_day_trade_entry_window", return_value=True), \
patch.object(dt, "_get_portfolio_summary",
AsyncMock(return_value={"total_assets": total_assets,
"available_cash": available_cash})), \
patch.object(ens, "_get_candles_cached", _candles), \
patch.object(dt.loss_tracker, "is_banned_safe", AsyncMock(return_value=False)), \
patch("app.services.market_playbook.is_vi_active", return_value=False), \
patch.object(dt, "check_orb_signal", _orb_signal), \
patch.object(dt, "check_vwap_bounce_signal", _boom_sync("check_vwap_bounce_signal")), \
patch.object(dt, "check_rsi_bb_signal", _boom_sync("check_rsi_bb_signal")):
res = await dt.get_current_signals()
assert res["source"] == "engine"
assert fetched == ["AAAAAA"], "보유·당일청산 종목은 판정하지 않아야 한다"
assert orb_seen == [engine_orb], "ORB 는 엔진 day_trading_state 의 값을 써야 한다"
assert res["skipped_held"] == 1 and res["skipped_closed_today"] == 1
# DT 투입액(보유분) 차감 후 세 한도 재클램프 — 기대값은 settings 로 계산
budget = calculate_day_trade_position_size(total_assets, available_cash, held_amount)
budget = min(
budget * min(1.2, confidence / 0.7),
total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO,
total_assets * settings.DAY_TRADE_MAX_RATIO_DT - held_amount,
available_cash,
)
expected_qty = int(max(0.0, budget) // price)
assert expected_qty > 0
assert res["count"] == 1
assert res["signals"][0]["stock_code"] == "AAAAAA"
assert res["signals"][0]["quantity"] == expected_qty
# 레거시처럼 보유분을 0 으로 보면 종목당 상한까지 커진다 — 그 값과 달라야 의미 있는 검증
assert expected_qty < int(total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO // price)
@pytest.mark.asyncio
async def test_signals_respects_open_slots():
ens._watchlist = {"AAAAAA": "에이"}
for i in range(dt.MAX_POSITIONS):
day_trading_state.positions[f"H{i:05d}"] = {"entry_price": 1.0, "quantity": 1}
with patch.object(dt, "is_day_trade_entry_window", return_value=True), \
patch.object(dt, "_get_portfolio_summary",
AsyncMock(return_value={"total_assets": 10_000_000.0,
"available_cash": 5_000_000.0})), \
patch.object(dt, "_evaluate_engine_signal_v2", AsyncMock(return_value={
"signal": DayTradeSignal("AAAAAA", "에이", "RSI_BB", 10_000.0, 9_800.0,
10_300.0, "t", 0.7),
"quantity": 5, "skip": None})):
res = await dt.get_current_signals()
assert res["gates"]["open_slots"] == 0
assert res["signals"] == []
@pytest.mark.asyncio
async def test_execute_dry_run_uses_engine_positions_and_closed_today():
from app.api.endpoints.day_trading import ExecuteRequest
day_trading_state.positions["AAAAAA"] = {"entry_price": 10_000.0, "quantity": 1}
day_trading_state.closed_today.add("BBBBBB")
with patch.object(dt, "_evaluate_engine_signal_v2", _boom("_evaluate_engine_signal_v2")):
with pytest.raises(HTTPException) as e1:
await dt.execute_day_trade(ExecuteRequest(stock_code="AAAAAA", dry_run=True))
with pytest.raises(HTTPException) as e2:
await dt.execute_day_trade(ExecuteRequest(stock_code="BBBBBB", dry_run=True))
with pytest.raises(HTTPException) as e3:
await dt.execute_day_trade(ExecuteRequest(stock_code="CCCCCC", dry_run=False))
assert e1.value.status_code == 400
assert e2.value.status_code == 400
assert e3.value.status_code == 410
@pytest.mark.asyncio
async def test_legacy_cycle_is_inert_and_legacy_trading_helpers_removed():
with patch.object(dt, "is_market_open", _boom_sync("is_market_open")), \
patch.object(dt.korea_investment_api, "get_balance", _boom("get_balance")):
res = await dt.run_day_trading_cycle()
assert res["phase"] == "deprecated"
assert res["entries"] == [] and res["exits"] == []
for name in ("_check_exits", "_scan_signals", "_execute_entry", "_execute_eod_flat",
"restore_positions_from_db", "_place_buy_order", "_place_sell_order",
"_collect_opening_range"):
assert not hasattr(dt, name), f"레거시 매매 경로 {name} 가 남아 있다"