"""[2026-10-09 하드닝 R3] DT 기동 복원 검증 + 원장 재구성 연패의 왕복 기준 회귀 테스트. 결함 1 (MEDIUM): restore_from_db 가 closed_today·KIS 실보유를 보지 않고 당일 active day_trade 행을 인메모리로 되살려, 청산 큐 유실 뒤 재기동하면 이미 판 물량을 일반 청산 경로가 다시 팔 수 있었다. → verify_restored_today_positions_v2 (장중 복원 경로 _restore_lost_today_positions_impl 과 같은 규칙). 결함 2 (LOW): Redis 미스 시 원장 재구성의 연패가 마지막 레그 기준이라 실시간 왕복 기준(R2-4)과 달랐다. """ import inspect 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 xs from app.services.daytrading import position_manager as pm from app.services.daytrading.position_manager import DayTradingState class _MustNotCall(BaseException): """호출되면 안 되는 경로 — 프로덕션 except Exception 에 삼켜지지 않게 BaseException.""" class _KISDown(Exception): pass class _Result: def __init__(self, rows): self._rows = rows def scalars(self): return self def all(self): return list(self._rows) class _Session: def __init__(self, rows): self._rows = rows async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, *_a, **_k): return _Result(self._rows) def _factory(rows): return lambda: _Session(rows) def _no_db(): raise _MustNotCall("DB 조회가 일어나면 안 된다") async def _no_kis(): raise _MustNotCall("KIS 잔고 조회가 일어나면 안 된다") def _holdings(mapping): async def _f(): return {c: {"hldg": q, "sellable": q} for c, q in mapping.items()} return _f async def _kis_down(): raise _KISDown("balance down") def _today_at(h=9, m=10): return datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=h, minute=m) def _pos(qty, entry_time=None, restored=True): return { "stock_name": "T", "strategy": "day_trade", "entry_price": 10_000.0, "stop_loss": 9_800.0, "target_price": 10_300.0, "quantity": qty, "highest_price": 10_000.0, "entry_time": entry_time or _today_at(), "reason": "", "order_id": "", **({"restored_from_db": True} if restored else {}), } @pytest.fixture(autouse=True) def _notify(): m = AsyncMock(return_value=None) with patch.object(pm, "_notify_daily_state_impl", m): yield m def _state(**positions): st = DayTradingState() st.begin_restore() st.positions.update(positions) return st # ── 결함 1: 기동 복원 검증 ──────────────────────────────────────────────────── @pytest.mark.asyncio async def test_closed_today_restored_position_is_held_back_and_alerted(_notify): """전량 청산 후 close 큐 유실 → active 행이 남아 복원돼도 DT 전량 청산 기록이면 되살리지 않는다. [R2 하드닝] 근거는 closed_today(재진입 차단)가 아니라 remove_position 이 남기는 dt_exited_today. """ st = _state(A00001=_pos(7)) st.closed_today.add("A00001") st.dt_exited_today.add("A00001") r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis) assert "A00001" not in st.positions assert r["held"] == ["A00001"] assert "A00001" in st.closed_today # 재진입 차단 유지 _notify.assert_awaited_once() @pytest.mark.asyncio async def test_kis_holding_minus_swing_below_row_qty_is_held_back(_notify): """실보유 7주 중 스윙 4주 → DT 가용 3주 < 행 7주: 복원하면 스윙 물량을 판다(§3.5).""" st = _state(A00002=_pos(7)) swing = [SimpleNamespace(stock_code="A00002", quantity=4)] r = await st.verify_restored_today_positions_v2( session_factory=_factory(swing), holdings_fetcher=_holdings({"A00002": 7}), ) assert "A00002" not in st.positions assert r["held"] == ["A00002"] and r["unverified"] is False _notify.assert_awaited_once() assert "A00002" not in st.closed_today # 잔고가 맞으면 장중 복원이 다시 잡을 수 있게 @pytest.mark.asyncio async def test_partial_sell_upsert_lost_row_qty_exceeds_holding_is_held_back(): """분할 매도 후 잔량 upsert 유실 — 행 10주, 실보유 5주 → 보류.""" st = _state(A00003=_pos(10)) r = await st.verify_restored_today_positions_v2( session_factory=_factory([]), holdings_fetcher=_holdings({"A00003": 5}), ) assert r["held"] == ["A00003"] and "A00003" not in st.positions @pytest.mark.asyncio async def test_consistent_position_is_kept(_notify): st = _state(A00004=_pos(7)) p = st.positions["A00004"] swing = [SimpleNamespace(stock_code="A00004", quantity=3)] r = await st.verify_restored_today_positions_v2( session_factory=_factory(swing), holdings_fetcher=_holdings({"A00004": 10}), ) assert st.positions["A00004"] is p assert r["held"] == [] and r["checked"] == 1 _notify.assert_not_awaited() @pytest.mark.asyncio async def test_prior_day_and_non_restored_positions_are_not_touched(): """전일 진입분은 close_stale_positions 담당, 복원 표식 없는 포지션은 대상 아님.""" yday = _today_at() - timedelta(days=1) st = _state(S00001=_pos(5, entry_time=yday), N00001=_pos(5, restored=False)) st.closed_today |= {"S00001", "N00001"} r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis) assert set(st.positions) == {"S00001", "N00001"} assert r["checked"] == 0 @pytest.mark.asyncio async def test_unverified_holds_back_when_orphan_detect_enabled(_notify): """판정 불가(KIS 장애) → 장중 close_stale_positions 가 같은 규칙으로 재복원하므로 보류(경보는 warning).""" st = _state(A00005=_pos(3)) with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True): r = await st.verify_restored_today_positions_v2( session_factory=_factory([]), holdings_fetcher=_kis_down, ) assert r["unverified"] is True and r["held"] == ["A00005"] assert "A00005" not in st.positions _notify.assert_not_awaited() @pytest.mark.asyncio async def test_unverified_keeps_when_orphan_detect_disabled(): """재복원 경로가 없으면 보류는 곧 손절 공백 — 유지하고 error.""" st = _state(A00006=_pos(3)) with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", False): r = await st.verify_restored_today_positions_v2( session_factory=_factory([]), holdings_fetcher=_kis_down, ) assert r["unverified"] is True and r["held"] == [] assert "A00006" in st.positions @pytest.mark.asyncio async def test_db_failure_is_unverified_and_skips_kis(): def _db_down(): raise _KISDown("db down") st = _state(A00007=_pos(3)) with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True): r = await st.verify_restored_today_positions_v2(session_factory=_db_down, holdings_fetcher=_no_kis) assert r["unverified"] is True and "A00007" not in st.positions @pytest.mark.asyncio async def test_default_fetcher_is_exit_service_holdings_common_terminal(): """기본 잔고 조회는 exit_service._fetch_kis_holdings_v2_impl (장중 복원 경로와 같은 소스).""" st = _state(A00008=_pos(3)) m = AsyncMock(side_effect=_KISDown("blocked")) with patch.object(xs, "_fetch_kis_holdings_v2_impl", m), \ patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True): r = await st.verify_restored_today_positions_v2(session_factory=_factory([])) m.assert_awaited_once() assert r["unverified"] is True def test_restore_from_db_signature_unchanged_and_scheduler_wires_verify(): assert list(inspect.signature(DayTradingState.restore_from_db).parameters) == ["self", "db"] from app.services.daytrading import scheduler as sch src = inspect.getsource(sch) i_restore = src.index("restore_from_db(db)") i_verify = src.index("verify_restored_today_positions_v2()") i_stale = src.index('close_stale_positions(), "기동 시') assert i_restore < i_verify < i_stale # ── 결함 2: 원장 재구성 연패 = 왕복 순손익 ───────────────────────────────────── def _row(i, code, order_type, net=None): return SimpleNamespace( id=i, stock_code=code, order_type=order_type, status="COMPLETED", exit_reason="", net_profit_loss=net, strategy_type="daytrading", created_at=_today_at(9, 10 + i), ) def test_partial_profit_then_small_loss_is_round_trip_win(): """+1.5R 분할 +3,000 뒤 잔량 -300 → 왕복 +2,700 = 이익 → 연패 리셋 (실시간 R2-4 와 동일).""" rows = [ _row(0, "L00001", "BUY"), _row(1, "L00001", "SELL", -500.0), _row(2, "L00002", "BUY"), _row(3, "L00002", "SELL", -500.0), _row(4, "P00001", "BUY"), _row(5, "P00001", "SELL", 3000.0), _row(6, "P00001", "SELL", -300.0), ] r = pm._rebuild_daily_state_from_rows(rows) assert r["loss_streak_today"] == 0 assert r["daily_pnl"] == pytest.approx(-500.0 - 500.0 + 3000.0 - 300.0) def test_partial_small_profit_then_big_loss_is_round_trip_loss(): rows = [_row(0, "P00002", "BUY"), _row(1, "P00002", "SELL", 100.0), _row(2, "P00002", "SELL", -300.0)] assert pm._rebuild_daily_state_from_rows(rows)["loss_streak_today"] == 1 def test_rebuild_streak_matches_realtime_round_trip_rule(): """재구성 결과 = 실시간 _apply_round_trip_streak_v2 를 같은 왕복 순서로 적용한 결과.""" trips = [("A", [-10.0]), ("B", [500.0, -20.0]), ("C", [-5.0]), ("D", [50.0, -80.0]), ("E", [0.0])] rows, i = [], 0 for code, legs in trips: rows.append(_row(i, code, "BUY")); i += 1 for leg in legs: rows.append(_row(i, code, "SELL", leg)); i += 1 rebuilt = pm._rebuild_daily_state_from_rows(rows)["loss_streak_today"] st = xs.day_trading_state saved = st.loss_streak_today try: st.loss_streak_today = 0 for _code, legs in trips: xs._apply_round_trip_streak_v2(sum(legs)) realtime = st.loss_streak_today finally: st.loss_streak_today = saved assert rebuilt == realtime == 2