"""DT 청산 하드닝 라운드 1 회귀 테스트 (2026-10-09, daytrading/exit_service.py). R1-1 고아 근거를 DT 원장 순체결(BUY−SELL)>0 으로 한정 — 수동·HTS 합산분 매도 금지 R1-2 고아 매도 결과 불명이어도 후보에서 빠지지 않는다(불명 재판정 경로 재도달) R1-3/7 KB금융(105560) 재현 — 'synced' active 행 공존·DT 원장 순보유 0 → 팔지 않고 수동 경보 R1-4 전일 포지션은 일반 청산 경로(손절 선판정·분봉·EOD·일일한도·공통 종단)에서 제외 R1-5 매도 미체결 복원 시 ActiveTrade(day_trade) 도 되살린다 R1-6 근거 없음 → error + 텔레그램(하루 1회) R1-9 2단계 판정가 — 마지막 1분봉 이후 관측된 REST 현재가 재사용 목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`_execute_exit_impl`·`safe_place_order`)에 걸고, 호출되면 안 되는 경로는 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.day_trading_strategy import MinuteCandle 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: 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": [{}], }) KB = "105560" def _pf(code=KB, qty=3): return SimpleNamespace(stock_code=code, stock_name="KB금융", quantity=qty, average_price=167_881.25, strategy_type="daytrading") def _ev(code=KB, qty=3, days_ago=3): return SimpleNamespace( stock_code=code, stock_name="KB금융", quantity=qty, entry_price=166_866.0, highest_since_entry=167_000.0, status="closed", strategy_name="day_trade", created_at=datetime.now().replace(microsecond=0) - timedelta(days=days_ago), ) def _th_row(code, side, qty, 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) @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.last_total_assets) 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, 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.last_total_assets = saved[4] for coll in (es._unresolved_exit_unknown, es._manual_alerted, es._rest_price_seen, es._recent_dt_sell_finalized): coll.clear() async def _run(session_factory, balance, exec_mock): with 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": "163400"}})), \ 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 # ═══ R1-1 ════════════════════════════════════════════════════════════════════ def test_net_qty_from_history_is_conservative(): rows = [ _th_row("A", "BUY", 3), _th_row("A", "SELL", 3), # 정상 왕복 → 0 _th_row("B", "BUY", 3), _th_row("B", "SELL", 3, "CANCELLED", executed=0), # 0체결 → 3 _th_row("C", "BUY", 3), _th_row("C", "SELL", 3, "PENDING", executed=None), # 불명 = 팔림 _th_row("D", "BUY", 3, "PENDING"), # 미확인 매수 미산정 _th_row("E", "BUY", 3), _th_row("E", "SELL", 3, "FAILED"), # 실패 매도 미산정 ] net = es._dt_net_qty_from_history(rows) assert net.get("A", 0) == 0 assert net["B"] == 3 assert net.get("C", 0) == 0 assert net.get("D", 0) == 0 assert net["E"] == 3 @pytest.mark.asyncio async def test_manual_buy_after_normal_dt_exit_is_not_sold(_clean): """DT 3주 정상 청산 뒤 수동 10주 — 'daytrading' 태그·closed DT 행이 남아도 팔지 않는다.""" th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3)] out = await _run(_sess([], [_pf(qty=10)], [_ev()], th), _balance([(KB, 10, 10)]), _boom("_execute_exit_impl")) assert out == [] assert _clean.await_count == 1 # 수동 확인 경보 @pytest.mark.asyncio async def test_zero_fill_cancelled_sell_is_orphan_capped_by_net(_clean): """근거 행 5주·Portfolio 10주라도 DT 원장 순보유(2주)까지만 판다.""" th = [_th_row(KB, "BUY", 5), _th_row(KB, "SELL", 3), _th_row(KB, "SELL", 2, "CANCELLED", executed=0)] exec_mock = AsyncMock(return_value={"stock_code": KB}) await _run(_sess([], [_pf(qty=10)], [_ev(qty=5)], th, []), _balance([(KB, 10, 10)]), exec_mock) assert exec_mock.await_count == 1 assert exec_mock.await_args.args[1]["quantity"] == 2 assert exec_mock.await_args.kwargs.get("is_stale") is True # ═══ R1-2 ════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_orphan_with_unresolved_unknown_stays_candidate(): """결과 불명이 남아 있어도 후보에 남아 stale 경로(불명 재판정)에 도달한다.""" es._unresolved_exit_unknown[(es._today_key(), KB)] = { "qty": 3, "since": es._kst_now(), "total_qty": 3, } th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)] exec_mock = AsyncMock(return_value={"stock_code": KB}) await _run(_sess([], [_pf()], [_ev()], th, []), _balance([(KB, 3, 3)]), exec_mock) assert exec_mock.await_count == 1 def test_restore_path_still_blocks_unresolved_unknown(): es._unresolved_exit_unknown[(es._today_key(), KB)] = {"qty": 1, "since": es._kst_now()} assert es._orphan_blocked_reason(KB, es._today_key()) == "결과 불명 매도 미해결" assert es._orphan_blocked_reason_v2(KB, es._today_key(), block_unresolved_unknown=False) is None # ═══ R1-3 / R1-7 (KB금융 운영 상태 재현) ═════════════════════════════════════ @pytest.mark.asyncio async def test_kb_synced_row_and_net_zero_history_alerts_instead_of_selling(_clean): """운영 DB(10/09): Portfolio 'daytrading' 3, DT 행 전부 closed, 'synced' active 3, DT 원장 9/30·10/01·10/06 모두 3/3 왕복 → DT 몫 없음. 팔지 않고 수동 경보.""" th = [_th_row(KB, s, 3) for s in ("BUY", "SELL", "BUY", "SELL", "BUY", "SELL")] out = await _run(_sess([], [_pf()], [_ev()], th), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl")) assert out == [] assert _clean.await_count == 1 assert "105560" in _clean.await_args.args[1] @pytest.mark.asyncio async def test_orphan_with_synced_row_and_no_dt_available_alerts_error(_clean): """DT 원장상 3주 남았지만 실보유 3주 전부 'synced' 행 몫 → 팔지 않고 error+텔레그램.""" synced = SimpleNamespace(stock_code=KB, quantity=3) th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)] await _run(_sess([], [_pf()], [_ev()], th, [synced]), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl")) assert _clean.await_count == 1 @pytest.mark.asyncio async def test_orphan_with_synced_row_sells_only_dt_share(): synced = SimpleNamespace(stock_code=KB, quantity=3) th = [_th_row(KB, "BUY", 3), _th_row(KB, "SELL", 3, "CANCELLED", executed=0)] exec_mock = AsyncMock(return_value={"stock_code": KB}) await _run(_sess([], [_pf(qty=6)], [_ev()], th, [synced]), _balance([(KB, 6, 6)]), exec_mock) assert exec_mock.await_args.args[1]["quantity"] == 3 # ═══ R1-6 ════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_no_evidence_alerts_once_per_day(_clean): for _ in range(2): await _run(_sess([], [_pf()], [], []), _balance([(KB, 3, 3)]), _boom("_execute_exit_impl")) assert _clean.await_count == 1 # ═══ R1-4 전일 포지션은 일반 경로 제외 ═══════════════════════════════════════ def _stale_pos(qty=10, entry=10_000.0): return {"stock_name": "전일", "entry_price": entry, "quantity": qty, "stop_loss": entry * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT), "target_price": entry * 1.05, "highest_price": entry, "entry_time": datetime.now() - timedelta(days=1), "order_id": "1"} @pytest.mark.asyncio async def test_fast_stop_and_minute_check_skip_stale_position(): day_trading_state.positions["111111"] = _stale_pos() with patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("chart")), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): assert await es._fast_stop_check_impl("111111") == (True, None) assert await es._check_single_position("111111") is None @pytest.mark.asyncio async def test_common_terminus_refuses_stale_position_in_general_path(): pos = _stale_pos() day_trading_state.positions["111111"] = pos import app.services.trade_executor as te with patch.object(te, "safe_place_order", _boom("safe_place_order")): assert await es._execute_exit("111111", pos, 9_000.0, "손절") is None assert "111111" not in day_trading_state.exits_in_progress assert day_trading_state.daily_pnl == 0.0 @pytest.mark.asyncio async def test_force_close_all_routes_stale_to_close_stale_positions(): day_trading_state.positions["111111"] = _stale_pos() stale_mock = AsyncMock(return_value=[{"stock_code": "111111"}]) async def _exec(code, pos, price, reason, sell_pct, is_stale): if not is_stale: raise MustNotBeCalled("전일 포지션이 일반 경로로 매도됨") return None with patch.object(es, "_execute_exit_impl", _exec), \ patch.object(es, "close_stale_positions", stale_mock), \ patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")): out = await es.force_close_all(reason="EOD 강제 청산") assert stale_mock.await_count == 1 assert out == [{"stock_code": "111111"}] @pytest.mark.asyncio async def test_stale_overnight_gap_not_counted_in_daily_loss_limit(): """전일 포지션 -20% 갭 미실현이 있어도 당일 -한도 강제청산이 발동하지 않는다.""" pos = _stale_pos(qty=100, entry=10_000.0) pos["current_price"] = 8_000.0 day_trading_state.positions["111111"] = pos day_trading_state.last_total_assets = 1_000_000.0 with patch.object(es, "_ensure_total_assets_for_exit_impl", AsyncMock(return_value=1_000_000.0)), \ patch.object(es, "force_close_all", _boom("force_close_all")), \ patch.object(es, "is_eod_flat_time", return_value=False), \ patch.object(es, "close_stale_positions", AsyncMock(return_value=[])), \ patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day", _boom("chart")), \ patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")): assert await es.check_and_exit_positions() == [] # ═══ R1-5 미체결 복원 → ActiveTrade 되살림 ═══════════════════════════════════ @pytest.fixture def exit_env(): import app.services.trade_executor as te import app.services.portfolio_cache_bridge as pcb cap = {"upserts": []} async def _ups(payload): cap["upserts"].append(dict(payload)) with patch.object(te, "safe_place_order", AsyncMock(return_value={"rt_cd": "0", "output": {"ODNO": "0000123"}})), \ patch.object(es, "enqueue_trade_history", AsyncMock()), \ patch.object(es, "enqueue_close_active_trade", AsyncMock()), \ patch("app.services.db_worker.enqueue_active_trade_upsert", _ups), \ patch("app.services.db_worker.enqueue_update_trade_history_by_order_id", AsyncMock()), \ 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 _today_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_050.0, "order_id": "0000999", "entry_time": datetime.now(), "reason": "ORB"} @pytest.mark.asyncio async def test_full_exit_zero_fill_reopens_active_trade(exit_env): day_trading_state.positions["666666"] = _today_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)): await es._execute_exit("666666", day_trading_state.positions["666666"], 9_900.0, "EOD 강제 청산") await asyncio.gather(*list(fr._pending_tasks)) assert day_trading_state.positions["666666"]["quantity"] == 10 last = exit_env["upserts"][-1] assert last["strategy_name"] == "day_trade" and last["status"] == "active" assert last["quantity"] == 10 and last["entry_price"] == 10_000.0 assert last["stop_loss"] == 9_700.0 and last["take_profit"] == 10_450.0 @pytest.mark.asyncio async def test_partial_exit_zero_fill_restores_active_trade_quantity(exit_env): day_trading_state.positions["666666"] = _today_pos(10) rows = [{"odno": "123", "pdno": "666666", "ord_qty": "5", "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)): await es._execute_exit("666666", day_trading_state.positions["666666"], 10_500.0, "목표가 도달", sell_pct=0.5) await asyncio.gather(*list(fr._pending_tasks)) assert day_trading_state.positions["666666"]["quantity"] == 10 # finalize 가 잔량 5로 줄인 뒤, 미체결 복원이 10 으로 되돌린다 assert [u["quantity"] for u in exit_env["upserts"]] == [5, 10] last = exit_env["upserts"][-1] # [R4-1 2026-10-09] 체결 0 = 분할이 없었던 것 → 목표가·partial_sold 를 분할 전으로 되돌리고 # remaining_pct=1.0 으로 영속화한다(예전 기대값 inf/<1.0 은 분할 익절 영구 소실 결함이었다). restored = day_trading_state.positions["666666"] assert restored["target_price"] == pytest.approx(10_450.0) assert restored["partial_sold"] is False assert last["take_profit"] == pytest.approx(10_450.0) assert last["remaining_pct"] == 1.0 # ═══ R1-9 REST 현재가 재사용 ═════════════════════════════════════════════════ def _candle(dt: datetime, close: float) -> MinuteCandle: return MinuteCandle(date=dt.strftime("%Y%m%d"), time=dt.strftime("%H%M%S"), open=close, high=close, low=close, close=close, volume=100, cum_volume=100, cum_amount=close * 100) def test_fresher_rest_price_rules(): now = es._kst_now() candles = [_candle(now - timedelta(seconds=150), 100.0)] es._rest_price_seen["A"] = (101.0, now) # 마지막 봉 +60초 이후 assert es._fresher_rest_price_impl("A", candles) == 101.0 fresh_candles = [_candle(now - timedelta(seconds=10), 100.0)] # 봉이 더 신선 assert es._fresher_rest_price_impl("A", fresh_candles) is None _old = float(settings.DAY_TRADE_EXIT_FAST_STOP_TIMEOUT_SEC) + float(settings.DAY_TRADE_EXIT_CHECK_TIMEOUT_SEC) + 1 es._rest_price_seen["A"] = (101.0, now - timedelta(seconds=_old)) # 같은 사이클 아님 assert es._fresher_rest_price_impl("A", candles) is None assert es._fresher_rest_price_impl("B", candles) is None @pytest.mark.asyncio async def test_minute_check_uses_fresher_rest_price_not_stale_candle_close(): now = es._kst_now() pos = _today_pos(10) pos["entry_time"] = now - timedelta(minutes=5) day_trading_state.positions["555555"] = pos es._rest_price_seen["555555"] = (10_020.0, now) candles = [_candle(now - timedelta(minutes=10) + timedelta(minutes=i), 10_000.0) for i in range(8)] c5 = [SimpleNamespace(close=9_990.0, high=9_990.0, low=9_990.0)] * 6 seen = {} def _exit(**kw): seen.update(kw) return None with patch.object(es.korea_investment_api, "get_minute_chart_full_day", AsyncMock(return_value={})), \ patch.object(es.korea_investment_api, "get_minute_chart_full_day_v2", AsyncMock(return_value=[])), \ patch.object(es, "parse_minute_candles", return_value=candles), \ patch.object(es, "aggregate_to_5min", return_value=c5), \ patch.object(es, "check_day_trade_exit", _exit): assert await es._check_single_position("555555") is None assert seen["current_price"] == 10_020.0 assert day_trading_state.positions["555555"]["current_price"] == 10_020.0