"""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