"""DT 운영 관측 3건 회귀 테스트 (2026-10-08). 1. 고아 DT 잔고 — Portfolio.strategy_type='daytrading' 수량은 있는데 인메모리·active DT 행이 없는 종목(10/06 KB금융: EOD 매도 접수 → 0체결 → ActiveTrade 만 closed)을 close_stale_positions 가 감지해 같은 stale 경로로 전량 청산한다. + 접수 후 0체결 종결이 확인되면 인메모리 포지션을 되살린다(재시도). 2. 시간손절 임계 -0.3% → -0.7% (비용·슬리피지 노이즈 위), reason 은 .2f. 3. 엔진 카드 '청산' 은 DT 청산만 센다(스윙 손절의 전략 간 쿨다운 제외). 목킹 원칙: 주문은 공통 종단 `_execute_exit_impl`(stale 경로)·`safe_place_order` 에 건다. 호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다. """ import asyncio import inspect from datetime import datetime, time, timedelta from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from app.core.config import settings from app.services import day_trading_strategy as dts from app.services.daytrading import exit_service as es from app.services.daytrading import fill_reconciler as fr 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: """execute() 호출 순서대로 결과를 돌려주는 가짜 세션(SELECT 전용).""" def __init__(self, results): self._results = list(results) self.calls = 0 async def __aenter__(self): return self async def __aexit__(self, *a): return False async def execute(self, *a, **k): self.calls += 1 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": [{}], }) KB = "105560" def _pf(code=KB, qty=3, st="daytrading"): return SimpleNamespace(stock_code=code, stock_name="KB금융", quantity=qty, average_price=80_000.0, strategy_type=st) def _th(code=KB, buy=3, sell=3, sell_status="CANCELLED", sell_exec=0, days_ago=2): """[R1-1] DT 원장 — 기본값은 '매도 접수 후 0체결 종결'(순보유 = buy).""" t = datetime.now().replace(microsecond=0) - timedelta(days=days_ago) return [ SimpleNamespace(stock_code=code, order_type="BUY", status="COMPLETED", executed_quantity=buy, order_quantity=buy, created_at=t), SimpleNamespace(stock_code=code, order_type="SELL", status=sell_status, executed_quantity=sell_exec, order_quantity=sell, created_at=t), ] def _ev(code=KB, qty=3, days_ago=2, status="closed"): return SimpleNamespace( stock_code=code, stock_name="KB금융", quantity=qty, entry_price=80_000.0, highest_since_entry=80_500.0, status=status, strategy_name="day_trade", created_at=datetime.now().replace(microsecond=0) - timedelta(days=days_ago), ) @pytest.fixture(autouse=True) def _clean(): saved_pos = dict(day_trading_state.positions) saved_closed = set(day_trading_state.closed_today) saved_dt_exit = set(day_trading_state.dt_exited_today) saved_pnl = 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 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): coll.clear() day_trading_state.exits_in_progress.clear() from app.services.telegram_notifier import telegram_notifier with patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ patch.object(telegram_notifier, "notify_system_warning", AsyncMock()): yield day_trading_state.positions.clear() day_trading_state.positions.update(saved_pos) day_trading_state.closed_today.clear() day_trading_state.closed_today.update(saved_closed) day_trading_state.dt_exited_today.clear() day_trading_state.dt_exited_today.update(saved_dt_exit) day_trading_state.daily_pnl = saved_pnl es._recent_dt_sell_finalized.clear() def _common(session_factory, balance, exec_mock): return ( patch.object(es, "is_market_open", return_value=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": "77600"}})), patch.object(es, "_execute_exit_impl", exec_mock), ) async def _run(session_factory, balance, exec_mock): p = _common(session_factory, balance, exec_mock) with p[0], p[1], p[2], p[3], p[4]: return await es.close_stale_positions() # ═════════════════════════════════════════════════════════════════════════════ # 1. 고아 DT 잔고 # ═════════════════════════════════════════════════════════════════════════════ # 쿼리 순서: ①DT active ②(stale 있을 때만) 비-DT ③Portfolio DT ④DT 근거 행 ④b DT 원장(R1-1) # ⑤추가 비-DT @pytest.mark.asyncio async def test_orphan_portfolio_dt_holding_is_sold_via_stale_path(): """10/06 KB금융 재현: active 행은 closed, 인메모리 비어 있음, Portfolio 'daytrading' 3주.""" exec_mock = AsyncMock(return_value={"stock_code": KB, "quantity": 3}) out = await _run(_sess([], [_pf()], [_ev()], _th(), []), _balance([(KB, 3, 3)]), exec_mock) assert out == [{"stock_code": KB, "quantity": 3}] assert exec_mock.await_count == 1 code, pos, price, reason, sell_pct = exec_mock.await_args.args assert exec_mock.await_args.kwargs.get("is_stale") is True assert code == KB and pos["quantity"] == 3 and sell_pct == 1.0 assert "고아" in reason # 당일 재진입 차단(stale 경로와 동일) assert KB in day_trading_state.closed_today @pytest.mark.asyncio async def test_orphan_sell_qty_capped_by_swing_holding_and_evidence_qty(): """§3.5 — 실보유 10, 비-DT active 7 → DT 가용 3. 근거 행 수량(3)·Portfolio(10) 중 최소.""" swing = SimpleNamespace(stock_code=KB, quantity=7) exec_mock = AsyncMock(return_value={"stock_code": KB}) await _run(_sess([], [_pf(qty=10)], [_ev(qty=3)], _th(), [swing]), _balance([(KB, 10, 10)]), exec_mock) assert exec_mock.await_args.args[1]["quantity"] == 3 @pytest.mark.asyncio async def test_orphan_all_swing_owned_is_not_sold(): swing = SimpleNamespace(stock_code=KB, quantity=3) await _run(_sess([], [_pf()], [_ev()], _th(), [swing]), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl")) @pytest.mark.asyncio async def test_orphan_without_dt_evidence_is_not_sold(): """'daytrading' 태그만 있고 최근 DT 행이 없으면 팔지 않는다(수동 매수 섞인 행 보호).""" await _run(_sess([], [_pf()], []), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl")) @pytest.mark.asyncio async def test_orphan_with_zero_kis_holding_does_nothing(): """실보유 0 — 매도도, ActiveTrade close 도 하지 않는다(닫을 active 행이 없다).""" with patch.object(es, "enqueue_close_active_trade", _boom("enqueue_close_active_trade")): out = await _run(_sess([], [_pf()], [_ev()], _th(), []), _balance([]), _boom("_execute_exit_impl")) assert out == [] @pytest.mark.asyncio async def test_orphan_kis_balance_failure_holds_sell(): bal = AsyncMock(side_effect=RuntimeError("KIS down")) out = await _run(_sess([], [_pf()], [_ev()], _th(), []), bal, _boom("_execute_exit_impl")) assert out == [] @pytest.mark.asyncio async def test_orphan_grace_after_recent_dt_sell(): """방금 DT 매도를 반영한 종목은 Portfolio/KIS 가 옛 수량일 수 있다 → 유예.""" import time as _t es._recent_dt_sell_finalized[KB] = _t.monotonic() out = await _run(_sess([], [_pf()], [_ev()], []), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl")) assert out == [] @pytest.mark.asyncio async def test_orphan_skipped_when_in_memory(): day_trading_state.positions[KB] = {"entry_price": 80_000.0, "quantity": 3, "entry_time": datetime.now()} with patch.object(es.korea_investment_api, "get_balance", _boom("get_balance")): p = _common(_sess([], [_pf()], [_ev()], []), _balance([]), _boom("_execute_exit_impl")) with p[0], p[1], p[3], p[4], \ patch.object(es.korea_investment_api, "get_balance", _boom("get_balance")): assert await es.close_stale_positions() == [] @pytest.mark.asyncio async def test_orphan_detection_can_be_disabled(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", False) out = await _run(_sess([], [_pf()], [_ev()], []), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl")) assert out == [] # ── 당일 active 행 인메모리 유실 → 복원 ──────────────────────────────────── def _today_row(code="222222", qty=5): return SimpleNamespace( stock_code=code, stock_name="T", strategy_name="day_trade", status="active", entry_price=10_000.0, stop_loss=9_700.0, take_profit=10_450.0, quantity=qty, highest_since_entry=10_100.0, created_at=datetime.now().replace(microsecond=0), entry_reason="", order_id="1", remaining_pct=None, ) @pytest.mark.asyncio async def test_today_active_row_missing_in_memory_is_restored_not_sold(): # ①DT active=[당일 행] ③Portfolio=[] ⑤추가 비-DT=[] out = await _run(_sess([_today_row()], [], []), _balance([("222222", 5, 5)]), _boom("_execute_exit_impl")) assert out == [] pos = day_trading_state.positions["222222"] assert pos["quantity"] == 5 and pos["stop_loss"] == 9_700.0 @pytest.mark.asyncio async def test_today_active_row_not_restored_when_closed_today(): # [R2 하드닝 2026-10-09] 복원 차단 근거는 DT 전량 청산 기록(remove_position 의 dt_exited_today). # closed_today 만 있는 경우(불명 매수 접수·0체결 복원)는 test_dt_restore_closed_today_r2h_20261009 참조. day_trading_state.closed_today.add("222222") day_trading_state.dt_exited_today.add("222222") await _run(_sess([_today_row()], [], []), _balance([("222222", 5, 5)]), _boom("_execute_exit_impl")) assert "222222" not in day_trading_state.positions @pytest.mark.asyncio async def test_today_active_row_not_restored_when_holding_short(): await _run(_sess([_today_row(qty=5)], [], []), _balance([("222222", 2, 2)]), _boom("_execute_exit_impl")) assert "222222" not in day_trading_state.positions # ── 접수 후 0체결 종결 → 인메모리 복원 ────────────────────────────────────── @pytest.fixture def exit_env(): import app.services.trade_executor as te import app.services.portfolio_cache_bridge as pcb cap = {"updates": [], "history": []} async def _upd(order_id, fields): cap["updates"].append((order_id, fields)) async def _hist(payload): cap["history"].append(payload) with patch.object(te, "safe_place_order", AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \ patch.object(es, "enqueue_trade_history", _hist), \ patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ patch("app.services.db_worker.enqueue_active_trade_upsert", AsyncMock()), \ patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", _upd), \ patch.object(pcb, "reflect_sell", AsyncMock()), \ patch.object(es.loss_tracker, "record_trade_result_safe", AsyncMock()), \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()), \ patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True), \ patch.object(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0), \ patch.object(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0): yield cap def _pos(qty=10): return {"stock_name": "테스트", "entry_price": 10_000.0, "quantity": qty, "stop_loss": 9_700.0, "target_price": 10_450.0, "highest_price": 10_000.0, "order_id": "0000999", "entry_time": datetime.now()} @pytest.mark.asyncio async def test_eod_sell_accepted_then_zero_fill_restores_position(exit_env): day_trading_state.positions["666666"] = _pos(10) rows = [{"odno": "123", "pdno": "666666", "ord_qty": "10", "tot_ccld_qty": "0", "avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}] with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): res = await es._execute_exit("666666", day_trading_state.positions["666666"], 9_900.0, "EOD 강제 청산 (15:20)") assert res is not None # 접수 시점엔 청산으로 반영됐다(기존 동작) await asyncio.gather(*list(fr._pending_tasks)) # 인메모리 복원 → 다음 EOD 사이클 force_close_all 이 재시도 assert day_trading_state.positions["666666"]["quantity"] == 10 assert "666666" not in day_trading_state.dt_exited_today # 당일 손익은 체결 0 이므로 원복 assert day_trading_state.daily_pnl == pytest.approx(0.0) oid, upd = exit_env["updates"][-1] assert oid == "0000123" and upd["status"] == "CANCELLED" and upd["executed_quantity"] == 0 # 그레이스 해제(고아 감지 즉시 가능) assert "666666" not in es._recent_dt_sell_finalized @pytest.mark.asyncio async def test_stale_sell_zero_fill_clears_reorder_block(exit_env): today = datetime.now().date().isoformat() rows = [{"odno": "123", "pdno": "777777", "ord_qty": "10", "tot_ccld_qty": "0", "avg_prvs": "0", "rmn_qty": "0", "rjct_qty": "10"}] es._stale_sell_attempted.add((today, "777777")) with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): await es._execute_exit_stale("777777", _pos(10), 9_500.0, "전일 미청산 포지션 강제 청산") await asyncio.gather(*list(fr._pending_tasks)) assert (today, "777777") not in es._stale_sell_attempted assert "777777" not in day_trading_state.positions # 전일분은 close_stale 가 다시 잡는다 assert day_trading_state.daily_pnl == 0.0 @pytest.mark.asyncio async def test_full_fill_keeps_existing_reconcile_behavior(exit_env): day_trading_state.positions["888888"] = _pos(10) rows = [{"odno": "123", "tot_ccld_qty": "10", "avg_prvs": "10080"}] with patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): await es._execute_exit("888888", day_trading_state.positions["888888"], 10_100.0, "트레일링") await asyncio.gather(*list(fr._pending_tasks)) assert "888888" not in day_trading_state.positions _, upd = exit_env["updates"][-1] assert float(upd["executed_price"]) == pytest.approx(10_080.0) assert "status" not in upd # ── 스케줄러 배선 ────────────────────────────────────────────────────────── def test_scheduler_runs_stale_orphan_check_at_startup_without_restore(): """복원 0건이어도 기동 시 전일·고아 점검을 한다 — 'if restored:' 블록 밖(복원 try 이후).""" from app.services.daytrading import scheduler as sched src = inspect.getsource(sched.daytrading_scheduler) head = src[:src.index("while True:")] restore_fail = head.index("시작 시 포지션 복원 실패") assert head.index("_dt_exit.close_stale_positions()") > restore_fail def test_scheduler_offhours_branch_checks_without_positions_guard(): from app.services.daytrading import scheduler as sched src = inspect.getsource(sched.daytrading_scheduler) tail = src[src.index("# 그 외 시간"):] tail = tail[:tail.index("except Exception as e:")] assert "check_and_exit_positions()" in tail assert "if day_trading_state.positions:" not in tail eod = src[src.index("if is_eod_flat_time():"):] eod = eod[:eod.index("continue")] assert "_dt_exit.close_stale_positions()" in eod # ═════════════════════════════════════════════════════════════════════════════ # 2. 시간손절 임계 # ═════════════════════════════════════════════════════════════════════════════ def test_time_stop_threshold_is_negative(): """임계는 손실 쪽이어야 한다(양수면 수익 중 포지션까지 시간손절).""" assert settings.DAY_TRADE_TIME_STOP_MIN_GAIN < 0 def _exit_at(gain): from app.core.timeutil import now_kst entry = 10_000.0 return dts.check_day_trade_exit( entry_price=entry, current_price=entry * (1 + gain), stop_loss=entry * 0.5, target_price=entry * 2, highest_since_entry=entry, candles_5min=[], current_time=time(10, 0), entry_time=now_kst() - timedelta(minutes=settings.DAY_TRADE_TIME_STOP_MINUTES + 1), ) def test_time_stop_not_triggered_by_cost_sized_loss(): # 임계의 절반 손실(비용·슬리피지 수준) — 시간손절 아님 sig = _exit_at(settings.DAY_TRADE_TIME_STOP_MIN_GAIN / 2) assert sig is None or not dts.is_time_stop_exit_reason(sig.reason) def test_time_stop_triggered_below_threshold_with_2dp_reason(): g = settings.DAY_TRADE_TIME_STOP_MIN_GAIN - 0.0001 sig = _exit_at(g) assert sig is not None and dts.is_time_stop_exit_reason(sig.reason) assert f"< {settings.DAY_TRADE_TIME_STOP_MIN_GAIN * 100:.2f}%" in sig.reason # .1f 반올림이었다면 "-0.7% < -0.7%" 처럼 보였다 — 수익률도 .2f assert f"수익={g * 100:.2f}%" in sig.reason # ═════════════════════════════════════════════════════════════════════════════ # 3. 엔진 카드 '청산' = DT 청산만 # ═════════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_status_closed_count_excludes_swing_cooldown_codes(): from app.api.endpoints import day_trading as dt_api day_trading_state.closed_today.update({"015760", "006260"}) # 스윙 손절 쿨다운(한국전력·LS) async with day_trading_state.trade_lock: for c in ("111111", "222222"): day_trading_state.positions[c] = {"quantity": 1} day_trading_state.remove_position(c) out = await dt_api.get_status() assert out["closed_today"] == 2 assert out["reentry_blocked_today"] == 4 @pytest.mark.asyncio async def test_dt_exited_today_persisted_and_restored(): from app.services.daytrading.position_manager import DayTradingState saved = {} async def _setex(key, ttl, payload): saved["p"] = payload return True async def _get(key): return saved.get("p") a = DayTradingState() a.reset_if_new_day() a.dt_exited_today.add("111111") a.closed_today.update({"111111", "015760"}) with patch("app.core.redis.setex_json", _setex), patch("app.core.redis.get_json", _get): assert await a.save_daily_state() b = DayTradingState() b.reset_if_new_day() assert await b.load_daily_state() assert b.dt_exited_today == {"111111"} assert b.closed_today == {"111111", "015760"}