"""DT 청산 하드닝 루프 라운드 2 회귀 테스트 (2026-10-09, daytrading/exit_service.py). HL2-1 매도 0체결 복원 ActiveTrade payload 에 원 진입 시각(created_at)·매수 ODNO(order_id)를 싣는다. HL2-2 0체결 복원 포지션 재청산 시 같은 왕복을 loss_tracker·당일 연패에 두 번 세지 않고, 부분체결 복원이면 체결 레그 순손익을 realized_net(왕복 누적)에 반영한다. 목킹 원칙(CLAUDE.md §4.1): 주문 공통 종단(`safe_place_order`)은 BaseException 발생형으로 막고, 호출되면 안 되는 loss_tracker 기록도 예외 발생형으로 둔다. 기대값은 settings.* / 모듈 헬퍼에서 계산. """ from datetime import datetime, timedelta from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytz 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 CODE = "333333" BUY_ODNO = "0000999" _COLLS = ( "_stale_sell_attempted", "_stale_sell_failures", "_last_exit_order_outcome", "_unresolved_exit_unknown", "_stale_cleaned", "_exit_tokens", "_recent_dt_sell_finalized", "_unknown_lookup_failures", ) @pytest.fixture(autouse=True) def _clean(): 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.loss_streak_today) 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 day_trading_state.loss_streak_today = 0 for name in _COLLS: getattr(es, name).clear() day_trading_state.exits_in_progress.clear() with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ patch("app.services.trade_executor.safe_place_order", _boom("safe_place_order")), \ patch.object(es, "_alert_once_v2", MagicMock()), \ patch.object(es, "_in_closing_auction_impl", return_value=False), \ patch.object(es, "enqueue_trade_history", AsyncMock()), \ patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \ patch("app.services.portfolio_cache_bridge.reflect_sell", AsyncMock()): yield 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] day_trading_state.loss_streak_today = saved[4] for name in _COLLS: getattr(es, name).clear() day_trading_state.exits_in_progress.clear() ENTRY = 10_000.0 STOP = ENTRY * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT) def _pos(qty=4, entry_time=None): risk = ENTRY - STOP return {"stock_name": "테스트", "strategy": "VWAP", "entry_price": ENTRY, "quantity": qty, "stop_loss": STOP, "target_price": ENTRY + risk * settings.DAY_TRADE_TARGET_MULTIPLIER, "highest_price": ENTRY, "entry_time": entry_time or (es._kst_now() - timedelta(minutes=12)).replace(microsecond=0), "reason": "VWAP 반등", "order_id": BUY_ODNO} async def _full_exit(pos, price, reason, *, rec, upserts, sell_odno="0000777"): """전량 청산 접수 반영 → fill_reconciler 콜백을 캡처해 돌려준다.""" captured = {} def _sched(code, order_id, side, qty, cb): captured["cb"] = cb return object() with patch.object(es.fill_reconciler, "schedule_v2", _sched), \ patch("app.services.db_worker.enqueue_active_trade_upsert", upserts), \ patch.object(es.loss_tracker, "record_trade_result_safe", rec), \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()): qty = int(pos["quantity"]) res = await es._finalize_exit_impl( CODE, pos, price, reason, 1.0, {"output": {"ODNO": sell_odno}}, qty, qty, True, ) assert res is not None return captured["cb"] async def _deliver(cb, info, upserts): with patch("app.services.db_worker.enqueue_active_trade_upsert", upserts): await cb(info) # ═══ HL2-1 복원 ActiveTrade 에 원 진입 시각·ODNO ═══════════════════════════════ @pytest.mark.asyncio async def test_zero_fill_restore_payload_carries_entry_time_and_order_id(): pos = _pos(qty=4) orig_entry_time = pos["entry_time"] day_trading_state.positions[CODE] = pos upserts = AsyncMock() rec = AsyncMock() cb = await _full_exit(dict(pos), STOP - 10, "손절 이탈", rec=rec, upserts=upserts) await _deliver(cb, {"terminal": True, "filled_qty": 0, "avg_price": 0}, upserts) payload = upserts.await_args.args[0] assert payload["status"] == "active" and payload["quantity"] == 4 assert payload["created_at"] == orig_entry_time assert payload["order_id"] == BUY_ODNO assert day_trading_state.positions[CODE]["entry_time"] == orig_entry_time @pytest.mark.asyncio async def test_restore_payload_without_entry_time_omits_created_at(): pos = _pos(qty=2) pos["entry_time"] = None pos["order_id"] = "" upserts = AsyncMock() with patch("app.services.db_worker.enqueue_active_trade_upsert", upserts): await es._enqueue_restored_active_trade_impl(CODE, pos) payload = upserts.await_args.args[0] assert "created_at" not in payload # 모델 기본값(now_kst) — 지어내지 않는다 assert "order_id" not in payload def test_restored_created_at_normalizes_aware_and_rejects_future(): kst = pytz.timezone("Asia/Seoul") naive = (es._kst_now() - timedelta(minutes=5)).replace(microsecond=0) aware_utc = kst.localize(naive).astimezone(pytz.utc) assert es._restored_created_at_impl(aware_utc) == naive assert es._restored_created_at_impl(naive) == naive assert es._restored_created_at_impl(es._kst_now() + timedelta(hours=1)) is None assert es._restored_created_at_impl("2026-10-09T09:10:00") is None # ═══ HL2-2 재청산 이중 집계 방지 ═══════════════════════════════════════════════ @pytest.mark.asyncio async def test_restored_position_reexit_does_not_double_count_loss(): pos = _pos(qty=4) day_trading_state.positions[CODE] = pos upserts = AsyncMock() rec1 = AsyncMock() cb = await _full_exit(dict(pos), STOP - 10, "손절 이탈", rec=rec1, upserts=upserts) assert rec1.await_count == 1 and rec1.await_args.args == (CODE, True) assert day_trading_state.loss_streak_today == 1 await _deliver(cb, {"terminal": True, "filled_qty": 0, "avg_price": 0}, upserts) cur = day_trading_state.positions[CODE] assert cur[es._RESTORED_STREAK_MARK_KEY] is True assert cur[es._RESTORED_LOSS_TRACKER_MARK_KEY] is True assert es._FULL_EXIT_RECORD_KEY not in cur pnl_after_restore = day_trading_state.daily_pnl assert pnl_after_restore == pytest.approx(0.0) # 0체결 → 접수 시점 손익 되돌림 # 재청산(손실) — loss_tracker 기록은 호출되면 안 된다, 연패도 그대로 await _full_exit(dict(cur), STOP - 20, "손절 이탈", rec=_boom("loss_tracker 재기록"), upserts=upserts, sell_odno="0000778") assert day_trading_state.loss_streak_today == 1 leg = es.dt_net_pnl(ENTRY, STOP - 20, 4, cur)["net_profit_loss"] assert day_trading_state.daily_pnl == pytest.approx(pnl_after_restore + leg) assert CODE not in day_trading_state.positions @pytest.mark.asyncio async def test_restored_after_win_record_then_loss_is_recorded(): """접수 시 이익(비손실)으로 기록 → 0체결 복원 → 재청산 손실이면 손실은 기록한다(보수적).""" pos = _pos(qty=4) day_trading_state.positions[CODE] = pos upserts = AsyncMock() rec1 = AsyncMock() cb = await _full_exit(dict(pos), ENTRY * 1.03, "트레일링 스탑", rec=rec1, upserts=upserts) assert rec1.await_args.args == (CODE, False) await _deliver(cb, {"terminal": True, "filled_qty": 0, "avg_price": 0}, upserts) cur = day_trading_state.positions[CODE] assert cur[es._RESTORED_LOSS_TRACKER_MARK_KEY] is False rec2 = AsyncMock() await _full_exit(dict(cur), STOP - 10, "손절 이탈", rec=rec2, upserts=upserts, sell_odno="0000778") assert rec2.await_count == 1 and rec2.await_args.args == (CODE, True) assert day_trading_state.loss_streak_today == 1 @pytest.mark.asyncio async def test_partial_fill_restore_adds_filled_leg_to_realized_net(): pos = _pos(qty=4) day_trading_state.positions[CODE] = pos upserts = AsyncMock() rec1 = AsyncMock() fill_px = ENTRY * 1.02 cb = await _full_exit(dict(pos), fill_px, "트레일링 스탑", rec=rec1, upserts=upserts) await _deliver(cb, {"terminal": True, "filled_qty": 1, "avg_price": fill_px}, upserts) cur = day_trading_state.positions[CODE] filled_leg = es.dt_net_pnl(ENTRY, fill_px, 1, pos)["net_profit_loss"] assert cur["quantity"] == 3 assert cur["realized_net"] == pytest.approx(filled_leg) assert upserts.await_args.args[0]["quantity"] == 3 # 잔량 3주를 소폭 손실로 청산 — 왕복(체결 레그 +잔량)이 이익이면 이전 기록(이익)과 같아 재기록 없음 small_loss_px = ENTRY * 0.999 last_leg = es.dt_net_pnl(ENTRY, small_loss_px, 3, cur)["net_profit_loss"] assert filled_leg + last_leg > 0, "테스트 전제: 왕복은 이익" await _full_exit(dict(cur), small_loss_px, "EOD 청산", rec=_boom("loss_tracker 재기록"), upserts=upserts, sell_odno="0000778") assert day_trading_state.loss_streak_today == 0 @pytest.mark.asyncio async def test_plain_full_exit_without_marks_records_once(): """표식 없는 일반 전량 청산은 기존대로 1회 기록(무변경 확인).""" pos = _pos(qty=2) day_trading_state.positions[CODE] = pos rec = AsyncMock() await _full_exit(dict(pos), STOP - 10, "손절 이탈", rec=rec, upserts=AsyncMock()) assert rec.await_count == 1 and rec.await_args.args == (CODE, True) assert day_trading_state.loss_streak_today == 1 def test_should_record_outcome_rules(): f = es._should_record_outcome_impl assert f(None, True) is True and f(None, False) is True assert f(True, True) is False and f(False, False) is False assert f(False, True) is True # 이익 기록 후 손실 → 손실 기록 assert f(True, False) is False # 손실 기록 유지(보수적)