"""DT 하드닝 라운드 6 — 당일 매수분 고아 청산 손익의 당일 집계 (2026-10-10, daytrading/exit_service.py). R6-1 당일 DT 매수 후 인메모리·active 행이 모두 유실돼 고아로 감지된 포지션은 전일·고아(stale) 경로(실보유·타전략 차감·재시도)를 그대로 타되, 실현손익은 당일 daily_pnl·loss_streak_today 에 넣는다(§7 의 제외 예외는 '전일' 포지션뿐). 청산 사유 접두사도 분리해 재기동 원장 재구성에서 당일 손익으로 포함된다. 목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`)에 걸고, 호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings·모듈 상수에서 계산한다. """ import asyncio 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 import position_manager as pm 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 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)) async def commit(self): raise MustNotBeCalled("commit") def _sess(*results): return lambda: _SeqSession(results) def _balance(rows): return AsyncMock(return_value={ "rt_cd": "0", "output1": [{"pdno": c, "hldg_qty": str(h), "ord_psbl_qty": str(s)} for c, h, s in rows], "output2": [{}], }) CODE = "005930" def _today_at(h, m): return datetime.combine(es._kst_now().date(), datetime.min.time()).replace(hour=h, minute=m) def _pf(qty=3): return SimpleNamespace(stock_code=CODE, stock_name="삼성전자", quantity=qty, average_price=70_000.0, strategy_type="daytrading") def _ev(qty=3, created_at=None): return SimpleNamespace( stock_code=CODE, stock_name="삼성전자", quantity=qty, entry_price=70_000.0, highest_since_entry=70_500.0, status="closed", strategy_name="day_trade", created_at=created_at or (datetime.now().replace(microsecond=0) - timedelta(days=3)), ) def _th(side, qty, created_at, status="COMPLETED", executed=None): return SimpleNamespace(stock_code=CODE, order_type=side, status=status, executed_quantity=qty if executed is None else executed, order_quantity=qty, created_at=created_at) @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.loss_streak_today, es._stale_realized_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 day_trading_state.loss_streak_today = 0 for coll in (es._stale_sell_attempted, es._stale_sell_failures, es._last_exit_order_outcome, es._unresolved_exit_unknown, es._stale_cleaned, es._exit_tokens, es._recent_dt_sell_finalized, es._manual_alerted, es._rest_price_seen): coll.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] day_trading_state.loss_streak_today = saved[4] es._stale_realized_pnl = saved[5] for coll in (es._unresolved_exit_unknown, es._manual_alerted, es._rest_price_seen, es._recent_dt_sell_finalized, es._stale_sell_attempted, es._stale_cleaned): coll.clear() async def _run(session_factory, balance, exec_mock): with patch.object(es, "is_market_open", return_value=True), \ patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True), \ patch("app.core.database.AsyncSessionLocal", session_factory), \ patch.object(es.korea_investment_api, "get_balance", balance), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "69000"}})), \ patch.object(es, "_execute_exit_impl", exec_mock): out = await es.close_stale_positions() await asyncio.sleep(0) await asyncio.sleep(0) return out # 쿼리 순서: ①DT active ③Portfolio DT ④DT 근거 행 ④b DT 원장 ⑤추가 비-DT # ═══ 감지: 당일 순매수 → orphan_today ═══════════════════════════════════════════ def test_sanity_reason_prefix_not_treated_as_stale_in_rebuild(): """새 사유 접두사는 전일·고아 제외 접두사와 겹치지 않는다(겹치면 원장 재구성이 다시 뺀다).""" assert not es._ORPHAN_TODAY_EXIT_REASON_PREFIX.startswith(pm._STALE_EXIT_REASON_PREFIXES) @pytest.mark.asyncio async def test_today_buy_orphan_routes_to_stale_terminus_with_today_flag_and_reason(): """근거 ActiveTrade 는 3일 전 행이지만 오늘 DT 매수 3주(순체결 +3) → 당일 고아. stale 공통 종단을 타되(실보유·타전략 차감 유지) 사유는 당일 접두사, 진입일은 실제 매수일.""" buy_at = _today_at(9, 5) th = [_th("BUY", 3, buy_at)] exec_mock = AsyncMock(return_value={"stock_code": CODE}) await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(CODE, 3, 3)]), exec_mock) assert exec_mock.await_count == 1 args, kwargs = exec_mock.await_args.args, exec_mock.await_args.kwargs pos, reason = args[1], args[3] assert kwargs.get("is_stale") is True assert pos[es._ORPHAN_TODAY_KEY] is True assert pos["entry_time"] == buy_at assert reason.startswith(es._ORPHAN_TODAY_EXIT_REASON_PREFIX) assert f"진입일 {buy_at.date()}" in reason assert not reason.startswith(pm._STALE_EXIT_REASON_PREFIXES) @pytest.mark.asyncio async def test_past_buy_orphan_keeps_stale_prefix_and_no_today_flag(): """매수가 전일 이전뿐이면 기존 '전일·고아' 경로 그대로(당일 손익 제외).""" buy_at = _today_at(9, 5) - timedelta(days=2) th = [_th("BUY", 3, buy_at), _th("SELL", 3, buy_at + timedelta(hours=6), "CANCELLED", executed=0)] exec_mock = AsyncMock(return_value={"stock_code": CODE}) await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(CODE, 3, 3)]), exec_mock) pos, reason = exec_mock.await_args.args[1], exec_mock.await_args.args[3] assert pos[es._ORPHAN_TODAY_KEY] is False assert pos["entry_time"] == buy_at # 근거 행(3일 전)이 아니라 실제 마지막 매수 assert reason.startswith("DT 고아 잔고 청산") @pytest.mark.asyncio async def test_today_orphan_still_capped_by_other_strategy_share(): """당일 고아여도 §3.5 차감은 그대로 — 실보유 6 − 스윙 3 = DT 3 만 판다.""" synced = SimpleNamespace(stock_code=CODE, quantity=3) th = [_th("BUY", 3, _today_at(9, 5))] exec_mock = AsyncMock(return_value={"stock_code": CODE}) await _run(_sess([], [_pf(qty=6)], [_ev()], th, [synced]), _balance([(CODE, 6, 6)]), exec_mock) assert exec_mock.await_args.args[1]["quantity"] == 3 assert exec_mock.await_args.args[1][es._ORPHAN_TODAY_KEY] is True def test_last_buy_helper_splits_today_and_past(): t0 = _today_at(9, 1) rows = [_th("BUY", 1, t0 - timedelta(days=1)), _th("BUY", 1, t0), _th("BUY", 1, t0 + timedelta(minutes=5), status="PENDING"), _th("SELL", 1, t0 + timedelta(minutes=9))] out = es._last_completed_dt_buy_at_v2(rows, t0.date()) assert out[(CODE, True)] == t0 assert out[(CODE, False)] == t0 - timedelta(days=1) # ═══ 반영: _finalize_exit_impl (stale 플래그로 들어와도 당일 집계) ══════════════ def _orphan_pos(today_flag: bool, qty=10, entry=10_000.0): return {"stock_name": "삼성전자", "strategy": "day_trade", "entry_price": entry, "quantity": qty, "highest_price": entry, "entry_time": _today_at(9, 5), "reason": "", "order_id": "", "orphan": True, es._ORPHAN_TODAY_KEY: today_flag} async def _finalize(pos, price): import app.services.portfolio_cache_bridge as pcb sched = AsyncMock() with patch.object(es, "enqueue_trade_history", AsyncMock()), \ patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ patch.object(es, "_schedule_sell_fill_reconcile_v2", lambda **k: None), \ patch.object(es, "_in_closing_auction_impl", return_value=False), \ patch.object(pcb, "reflect_sell", sched), \ patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()) as rec, \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()) as save: out = await es._finalize_exit_impl( CODE, pos, price, f"{es._ORPHAN_TODAY_EXIT_REASON_PREFIX} (테스트)", 1.0, {"output": {"ODNO": "0000777"}}, int(pos["quantity"]), int(pos["quantity"]), True, count_daily_pnl=False, ) return out, rec, save @pytest.mark.asyncio async def test_today_orphan_loss_counts_in_daily_pnl_and_streak(): stale_before = es._stale_realized_pnl out, rec, save = await _finalize(_orphan_pos(True), 9_700.0) assert out["profit_loss"] < 0 assert day_trading_state.daily_pnl == pytest.approx(out["profit_loss"]) assert day_trading_state.loss_streak_today == 1 assert es._stale_realized_pnl == pytest.approx(stale_before) # loss_tracker 는 기존대로 기록 + save_to_db (§5.1) assert rec.await_count == 1 and save.await_count == 1 assert CODE in day_trading_state.closed_today # 재진입 차단 유지 @pytest.mark.asyncio async def test_past_orphan_loss_still_excluded_from_daily_pnl(): stale_before = es._stale_realized_pnl out, _, _ = await _finalize(_orphan_pos(False), 9_700.0) assert day_trading_state.daily_pnl == 0.0 assert day_trading_state.loss_streak_today == 0 assert es._stale_realized_pnl == pytest.approx(stale_before + out["profit_loss"]) @pytest.mark.asyncio async def test_today_orphan_loss_triggers_daily_limit(): """당일 고아 손실로 일일 손실한도(settings)를 넘으면 is_daily_loss_exceeded 가 발동한다. (예전에는 stale 경로라 daily_pnl 이 0 으로 남아 한도가 발동하지 않았다.)""" from app.services.day_trading_strategy import is_daily_loss_exceeded total = 1_000_000.0 loss_ratio = abs(float(settings.DAY_TRADE_DAILY_LOSS_LIMIT)) qty, entry = 100, 10_000.0 price = entry - (total * loss_ratio * 2) / qty # 한도의 약 2배 손실 await _finalize(_orphan_pos(True, qty=qty, entry=entry), price) assert is_daily_loss_exceeded(total, day_trading_state.daily_pnl) day_trading_state.daily_pnl = 0.0 await _finalize(_orphan_pos(False, qty=qty, entry=entry), price) assert not is_daily_loss_exceeded(total, day_trading_state.daily_pnl) # ═══ 체결 확인: 0체결 종결 시 당일 손익 되돌림, 인메모리 복원 없음 ═══════════════ @pytest.mark.asyncio async def test_today_orphan_unfilled_reverts_daily_pnl_without_memory_restore(): captured = {} def _sched(code, order_id, side, qty, cb): captured["cb"] = cb return object() day_trading_state.daily_pnl = -3_000.0 pos = _orphan_pos(True, qty=3) upsert = AsyncMock() with patch.object(es.fill_reconciler, "schedule_v2", _sched), \ patch("app.services.db_worker.enqueue_active_trade_upsert", upsert), \ patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()): es._schedule_sell_fill_reconcile_v2( stock_code=CODE, pos=pos, order_id="0000778", signal_price=9_000.0, quantity=3, entry_price=10_000.0, recorded_net=-3_000.0, count_daily_pnl=False, ) await captured["cb"]({"terminal": True, "filled_qty": 0, "avg_price": 0}) assert day_trading_state.daily_pnl == pytest.approx(0.0) # 인메모리로 직접 되살리지는 않는다(손절가 없는 사본) assert CODE not in day_trading_state.positions # [R8-1] 대신 ActiveTrade(day_trade, active)를 당일 진입 시각으로 되살려 당일 행 복원 경로에 맡긴다 assert upsert.await_count == 1 payload = upsert.await_args.args[0] assert payload["strategy_name"] == "day_trade" and payload["status"] == "active" assert payload["quantity"] == 3 assert payload["created_at"] == pos["entry_time"] assert CODE in day_trading_state.dt_reheld_today # 당일 행 복원 보류(restore_block) 해제 day_trading_state.dt_reheld_today.discard(CODE) # stale 재청산 표식이 지워져 close_stale_positions 가 다시 잡는다 assert (es._kst_now().date().isoformat(), CODE) not in es._stale_sell_attempted # ═══ 재기동 원장 재구성: 당일 고아 사유는 당일 손익에 포함 ═══════════════════════ def _row(i, order_type, net=None, reason=""): return SimpleNamespace( id=i, stock_code=CODE, order_type=order_type, status="COMPLETED", exit_reason=reason, net_profit_loss=net, strategy_type="daytrading", created_at=_today_at(9, 10 + i), executed_quantity=3, order_quantity=3, ) def test_rebuild_counts_today_orphan_reason_but_excludes_stale_orphan(): today_rows = [_row(0, "BUY"), _row(1, "SELL", -2_000.0, f"{es._ORPHAN_TODAY_EXIT_REASON_PREFIX} (진입일 x)")] r = pm._rebuild_daily_state_from_rows(today_rows) assert r["daily_pnl"] == pytest.approx(-2_000.0) assert r["loss_streak_today"] == 1 assert r["stale_excluded"] == 0 stale_rows = [_row(0, "BUY"), _row(1, "SELL", -2_000.0, "DT 고아 잔고 청산 (진입일 x)")] r2 = pm._rebuild_daily_state_from_rows(stale_rows) assert r2["daily_pnl"] == 0.0 assert r2["stale_excluded"] == 1