Files
auto-stock/backend/tests/test_dt_exit_hardening_r3_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

382 lines
18 KiB
Python

"""DT 청산 하드닝 라운드 3 회귀 테스트 (2026-10-09, daytrading/exit_service.py).
R3-1 KIS 잔고 첫 페이지가 가득 찼는데 종목이 없으면 '0주'가 아니라 '판정 불가'(보류)
R3-2 장 마감 후 잔존 점검은 종가 매도 재확인이 끝난 뒤에, 장 마감 후 0체결 복원은 텔레그램
R3-3 청산 판정 실패(상한 초과·예외·판정 불가)가 N 사이클 연속이면 텔레그램 + 헬스 노출
R3-4 폴백 손절 대체가는 나이 상한 안의 KIS 관측가만 — 낡은 값/시각 없는 값은 '판정 불가'
목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`)에, 호출되면 안 되는 경로는
BaseException 발생형. 기대값은 settings.* / 모듈 헬퍼에서 계산한다.
"""
import asyncio
import time
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from app.core.config import settings
from app.services.daytrading import exit_service as es
from app.services.daytrading.position_manager import day_trading_state
class MustNotBeCalled(BaseException):
"""호출되면 안 되는 경로 (except Exception 에 삼켜지지 않게 BaseException)."""
def _boom(name):
async def _f(*a, **k):
raise MustNotBeCalled(f"호출 금지 경로 도달: {name}")
return _f
def _fail(name):
"""프로덕션이 처리해야 하는 일반 실패(KIS 장애 재현)."""
async def _f(*a, **k):
raise RuntimeError(f"KIS 장애 재현: {name}")
return _f
_COLLS = (
"_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome",
"_unresolved_exit_unknown", "_stale_cleaned", "_exit_tokens", "_recent_dt_sell_finalized",
"_manual_alerted", "_once_alerted", "_post_close_check_scheduled", "_price_seen_mono",
"_exit_check_failures", "_exit_stall_alerted", "_rest_price_seen",
)
@pytest.fixture(autouse=True)
def _clean():
from app.services.telegram_notifier import telegram_notifier
saved = (dict(day_trading_state.positions), set(day_trading_state.closed_today),
set(day_trading_state.dt_exited_today), day_trading_state.daily_pnl)
day_trading_state.reset_if_new_day()
day_trading_state.positions.clear()
day_trading_state.closed_today.clear()
day_trading_state.dt_exited_today.clear()
day_trading_state.daily_pnl = 0.0
for name in _COLLS:
getattr(es, name).clear()
day_trading_state.exits_in_progress.clear()
tg = AsyncMock()
with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \
patch.object(telegram_notifier, "notify_system_warning", tg):
yield tg
day_trading_state.positions.clear()
day_trading_state.positions.update(saved[0])
day_trading_state.closed_today.clear()
day_trading_state.closed_today.update(saved[1])
day_trading_state.dt_exited_today.clear()
day_trading_state.dt_exited_today.update(saved[2])
day_trading_state.daily_pnl = saved[3]
for name in _COLLS:
getattr(es, name).clear()
day_trading_state.exits_in_progress.clear()
async def _drain_alerts():
await asyncio.gather(*list(es._alert_tasks), return_exceptions=True)
def _tags(tg):
return [c.args[0] for c in tg.await_args_list]
def _pos(qty=4, entry=10_000.0, stop=9_700.0, entry_time=None):
return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": entry, "quantity": qty,
"stop_loss": stop, "target_price": entry * 1.045, "highest_price": entry,
"entry_time": entry_time or es._kst_now(), "reason": "", "order_id": "0000999"}
def _full_page(extra=None, n_other=None):
"""첫 페이지가 가득 찬 잔고 응답 — 다른 종목 n 행(+ extra 행)."""
full = int(settings.SWING_BALANCE_FULL_PAGE_ROWS)
rows = [{"pdno": f"9{i:05d}", "hldg_qty": "1", "ord_psbl_qty": "1"}
for i in range(full if n_other is None else n_other)]
if extra:
rows = rows[: max(0, len(rows) - len(extra))] + extra
return AsyncMock(return_value={"rt_cd": "0", "output1": rows, "output2": [{}]})
class _SeqSession:
def __init__(self, results):
self._results = list(results)
async def __aenter__(self):
return self
async def __aexit__(self, *a):
return False
async def execute(self, *a, **k):
rows = self._results.pop(0) if self._results else []
return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: rows))
def _sess(*results):
return lambda: _SeqSession(results)
# ═══ R3-1 잔고 첫 페이지 가득 참 ═══════════════════════════════════════════════
@pytest.mark.asyncio
async def test_holdings_v3_flags_incomplete_and_v2_returns_none_on_full_page():
with patch.object(es.korea_investment_api, "get_balance", _full_page()):
h, complete = await es._fetch_kis_holdings_v3_impl()
assert h is not None and complete is False
# v2(entry_service 결과 불명 매수 판정 경로)는 '미확인'으로 — hldg==0 미접수 오판 방지
assert await es._fetch_kis_holdings_v2_impl() is None
@pytest.mark.asyncio
async def test_holdings_v2_unchanged_below_full_page():
n = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) - 1
with patch.object(es.korea_investment_api, "get_balance", _full_page(n_other=n)):
h, complete = await es._fetch_kis_holdings_v3_impl()
assert complete is True
h2 = await es._fetch_kis_holdings_v2_impl()
assert h2 is not None and len(h2) == n
assert (h2.get("064400") or {}).get("hldg", 0) == 0 # 페이지가 덜 찼으면 '없음 = 0주' 유지
@pytest.mark.asyncio
async def test_stale_missing_on_full_page_is_not_closed(_clean):
"""2페이지에 있을 수 있는 전일 DT 종목 → 매도 없이 ActiveTrade 정리 금지, 수동 경보."""
day_trading_state.positions["064400"] = _p = _pos(entry_time=es._kst_now() - timedelta(days=1))
with patch.object(es, "is_market_open", return_value=True), \
patch("app.core.database.AsyncSessionLocal", _sess([], [])), \
patch.object(es.korea_investment_api, "get_balance", _full_page()), \
patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \
patch.object(es, "enqueue_close_active_trade", _boom("enqueue_close_active_trade")), \
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
assert await es.close_stale_positions() == []
await _drain_alerts()
assert day_trading_state.positions.get("064400") is _p
assert "064400" not in day_trading_state.closed_today
assert not es._stale_cleaned
assert _tags(_clean) == ["DT_MANUAL_CHECK"]
@pytest.mark.asyncio
async def test_stale_found_on_full_page_still_sold():
"""페이지가 가득 차도 응답에 있는 종목은 수량을 믿고 정상 청산(§3.5 차감 유지)."""
day_trading_state.positions["064400"] = _pos(qty=4, entry_time=es._kst_now() - timedelta(days=1))
exec_mock = AsyncMock(return_value={"stock_code": "064400"})
bal = _full_page(extra=[{"pdno": "064400", "hldg_qty": "4", "ord_psbl_qty": "4"}])
with patch.object(es, "is_market_open", return_value=True), \
patch("app.core.database.AsyncSessionLocal", _sess([], [])), \
patch.object(es.korea_investment_api, "get_balance", bal), \
patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "10000"}})), \
patch.object(es, "_execute_exit_impl", exec_mock):
await es.close_stale_positions()
assert exec_mock.await_count == 1
assert exec_mock.await_args.args[1]["quantity"] == 4
@pytest.mark.asyncio
async def test_unknown_exit_missing_on_full_page_is_lookup_failed():
"""결과 불명 매도: 미발견+페이지 가득 → 'accepted'(인메모리 제거) 금지, 판정 보류."""
with patch.object(es.korea_investment_api, "get_balance", _full_page()), \
patch.object(es, "_fetch_non_dt_active_qty_impl", _boom("_fetch_non_dt_active_qty_impl")), \
patch.object(es, "_lookup_todays_orders_impl", _boom("_lookup_todays_orders_impl")):
status, resp, dt_sellable = await es._resolve_unknown_exit_v2_impl(
"064400", 4, es._kst_now(), 4,
)
assert (status, resp, dt_sellable) == ("lookup_failed", None, 0)
@pytest.mark.asyncio
async def test_unknown_exit_missing_on_partial_page_still_accepted():
"""페이지가 덜 찼으면 기존 판정 유지(미발견 = 0주 → 접수)."""
n = int(settings.SWING_BALANCE_FULL_PAGE_ROWS) - 1
with patch.object(es.korea_investment_api, "get_balance", _full_page(n_other=n)), \
patch.object(es, "_fetch_non_dt_active_qty_impl", AsyncMock(return_value=0)), \
patch.object(es, "_lookup_todays_orders_impl", AsyncMock(return_value=[])):
status, _resp, _ = await es._resolve_unknown_exit_v2_impl("064400", 4, es._kst_now(), 4)
assert status == "accepted"
# ═══ R3-2 장 마감 후 잔존 점검 순서 + 0체결 복원 경보 ═══════════════════════════
@pytest.mark.asyncio
async def test_post_close_residual_waits_for_auction_recheck(_clean):
"""재확인이 0체결로 포지션을 되살리기 전에 '잔존 없음'을 확정하지 않는다."""
async def _recheck():
await asyncio.sleep(0.05) # get_todays_orders 왕복 재현
day_trading_state.positions["105560"] = _pos(qty=3)
t = asyncio.get_running_loop().create_task(_recheck())
es._auction_fill_tasks.add(t)
t.add_done_callback(es._auction_fill_tasks.discard)
with patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0):
es._schedule_post_close_residual_check_impl()
await asyncio.gather(*list(es._post_close_tasks))
await _drain_alerts()
assert _tags(_clean) == ["DT_EOD"]
assert "105560" in _clean.await_args_list[0].args[1]
@pytest.mark.asyncio
async def test_post_close_wait_is_bounded():
assert es._auction_recheck_wait_cap_impl() > 0
never = asyncio.get_running_loop().create_task(asyncio.sleep(3600))
es._auction_fill_tasks.add(never)
try:
with patch.object(es, "_auction_recheck_wait_cap_impl", return_value=0.05):
await asyncio.wait_for(es._await_auction_rechecks_impl(), timeout=2.0)
finally:
never.cancel()
es._auction_fill_tasks.discard(never)
@pytest.mark.asyncio
async def test_unfilled_restore_after_close_sends_telegram(_clean):
"""15:30 이후 0체결 종결 → 인메모리 복원 + DT_EOD 텔레그램(오버나이트 확정)."""
after_close = datetime.combine(es._kst_now().date(), es._KRX_MARKET_CLOSE) + timedelta(minutes=1)
info = {"terminal": True, "filled_qty": 0, "avg_price": 0}
pos = _pos(qty=3)
with patch.object(es, "_kst_now", return_value=after_close), \
patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \
patch.object(es.fill_reconciler, "fetch_fill_v2", AsyncMock(return_value=info)), \
patch.object(es, "_enqueue_restored_active_trade_impl", AsyncMock()), \
patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \
patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True):
task = es._schedule_sell_fill_reconcile_v2(
stock_code="105560", pos=pos, order_id="0000411", signal_price=80_000.0, quantity=3,
entry_price=10_000.0, recorded_net=0.0, count_daily_pnl=True, closing_auction=True,
)
assert task is not None
await task
await _drain_alerts()
assert day_trading_state.positions["105560"]["quantity"] == 3
assert _tags(_clean) == ["DT_EOD"]
assert "105560" in _clean.await_args_list[0].args[1]
# ═══ R3-3 청산 판정 실패 연속 → 경보 ═══════════════════════════════════════════
@pytest.mark.asyncio
async def test_bounded_v2_reports_timeout_and_exception():
async def _slow(code):
await asyncio.sleep(1.0)
async def _err(code):
raise RuntimeError("x")
out, failed = await es._run_exit_checks_bounded_v2_impl(["A"], _slow, 0.05, "t")
assert out == {} and failed == {"A"}
out, failed = await es._run_exit_checks_bounded_v2_impl(["B"], _err, 1.0, "t")
assert failed == {"B"}
# 구 시그니처는 dict 만 반환(호환)
assert await es._run_exit_checks_bounded_impl(["C"], _err, 1.0, "t") == {}
def _cycle_patches(price_mock, chart_mock):
return (
patch.object(es, "is_eod_flat_time", return_value=False),
patch.object(es, "close_stale_positions", AsyncMock(return_value=[])),
patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=0.0)),
patch.object(es.korea_investment_api, "get_current_price", price_mock),
patch.object(es.korea_investment_api, "get_minute_chart_full_day", chart_mock),
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")),
)
@pytest.mark.asyncio
async def test_exit_check_stall_alerts_after_n_cycles_then_resets(_clean):
day_trading_state.positions["222222"] = _pos(qty=4)
n = es._exit_check_fail_alert_cycles_impl()
p = _cycle_patches(_fail("get_current_price"), _fail("get_minute_chart_full_day"))
with p[0], p[1], p[2], p[3], p[4], p[5]:
for i in range(n - 1):
assert await es.check_and_exit_positions() == []
await _drain_alerts()
assert _tags(_clean) == [] # N-1 사이클까지는 경보 없음
assert es.get_exit_check_health_v2()["failing"] == {"222222": n - 1}
await es.check_and_exit_positions()
await es.check_and_exit_positions() # 같은 연속 구간 → 1회만
await _drain_alerts()
assert _tags(_clean) == ["DT_EXIT_STALL"]
assert es.get_exit_check_health_v2()["stalled"] == ["222222"]
# 시세 회복(손절 미도달 판정 성공) → 카운터·경보 상태 해제
ok_price = AsyncMock(return_value={"output": {"stck_prpr": "10000"}})
p = _cycle_patches(ok_price, _fail("get_minute_chart_full_day"))
with p[0], p[1], p[2], p[3], p[4], p[5]:
await es.check_and_exit_positions()
assert es.get_exit_check_health_v2()["failing"] == {}
assert not es._exit_stall_alerted
@pytest.mark.asyncio
async def test_orb_window_price_failure_counts_as_check_failure(_clean):
day_trading_state.positions["333333"] = _pos(qty=4)
n = es._exit_check_fail_alert_cycles_impl()
with patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \
patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
for _ in range(n):
await es.check_stops_only_v2()
await _drain_alerts()
assert _tags(_clean) == ["DT_EXIT_STALL"]
# ═══ R3-4 폴백 손절 대체가 나이 ═══════════════════════════════════════════════
@pytest.mark.asyncio
async def test_fallback_ignores_untimed_position_price():
"""pos['current_price'] 만 있고 관측 시각이 없으면 '미도달'이 아니라 '판정 불가'."""
pos = _pos(qty=4)
pos["current_price"] = 10_000.0 # 손절가(9,700)보다 높은 낡은 값
day_trading_state.positions["444444"] = pos
with patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
out, failed = await es._run_exit_checks_bounded_v2_impl(
["444444"], lambda c: es._check_fallback_stop_loss(c, pos, 9_700.0), 5.0, "t",
)
assert out == {"444444": None}
assert failed == {"444444"}
@pytest.mark.asyncio
async def test_fallback_rejects_cached_price_older_than_max_age():
pos = _pos(qty=4)
day_trading_state.positions["444444"] = pos
max_age = es._dt_fallback_price_max_age_impl()
es._price_seen_mono["444444"] = (10_000.0, time.monotonic() - max_age - 5.0)
with patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
_out, failed = await es._run_exit_checks_bounded_v2_impl(
["444444"], lambda c: es._check_fallback_stop_loss(c, pos, 9_700.0), 5.0, "t",
)
assert failed == {"444444"}
@pytest.mark.asyncio
async def test_fallback_uses_fresh_cached_price_and_stops():
"""나이 상한 안의 관측가는 쓴다 — 손절선 이탈이면 전량 청산(공통 종단 도달)."""
pos = _pos(qty=4)
day_trading_state.positions["444444"] = pos
es._remember_price_impl("444444", 9_600.0)
exec_mock = AsyncMock(return_value={"stock_code": "444444", "quantity": 4})
with patch.object(es.korea_investment_api, "get_current_price", _fail("get_current_price")), \
patch.object(es, "_execute_exit_impl", exec_mock):
out, failed = await es._run_exit_checks_bounded_v2_impl(
["444444"], lambda c: es._check_fallback_stop_loss(c, pos, 9_700.0), 5.0, "t",
)
assert failed == set()
assert exec_mock.await_count == 1
assert out["444444"]["quantity"] == 4
@pytest.mark.asyncio
async def test_fast_stop_records_observed_price():
day_trading_state.positions["555555"] = _pos(qty=4)
with patch.object(es.korea_investment_api, "get_current_price",
AsyncMock(return_value={"output": {"stck_prpr": "10100"}})), \
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
assert await es._fast_stop_check_impl("555555") == (False, None)
assert es._price_seen_mono["555555"][0] == 10_100.0