"""DT 청산 하드닝 라운드 2 회귀 테스트 (2026-10-09, daytrading/exit_service.py). R2-1 15:20~15:30 종가 단일가 매도는 장 마감 후 재확인한 실제 체결가로 loss_tracker 승패 기록 R2-3 국내 ETF/ETN 매도는 증권거래세 0 R2-4 분할 익절 후 전량 청산의 승패는 왕복 순손익으로(당일 연패·loss_tracker) R2-5 강제청산 None 반환도 실패로 재시도·텔레그램, 장 마감 후 잔존 DT 포지션 경보 목킹 원칙(CLAUDE.md §4.1): 주문은 공통 종단(`safe_place_order`·`_execute_exit_impl`)에 걸고, 호출되면 안 되는 경로는 BaseException 발생형. 기대값은 settings.* 에서 계산한다. """ import asyncio from datetime import datetime 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 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 @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) 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 colls = (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._once_alerted, es._post_close_check_scheduled) for coll in colls: 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] for coll in colls: coll.clear() day_trading_state.exits_in_progress.clear() @pytest.fixture def exit_env(): """공통 종단 safe_place_order 접수 + 큐·캐시·loss_tracker 목.""" import app.services.trade_executor as te import app.services.portfolio_cache_bridge as pcb rec = AsyncMock() 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", AsyncMock()), \ 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", rec), \ patch.object(es.loss_tracker, "save_to_db", AsyncMock()): yield rec def _pos(qty=10, entry=10_000.0, name="테스트"): return {"stock_name": name, "entry_price": entry, "quantity": qty, "stop_loss": entry * 0.97, "target_price": entry * 1.045, "highest_price": entry, "order_id": "0000999", "entry_time": datetime.now(), "reason": "ORB"} def _row(qty, filled, avg, rmn): return [{"odno": "123", "pdno": "777777", "ord_qty": str(qty), "tot_ccld_qty": str(filled), "avg_prvs": str(avg), "rmn_qty": str(rmn), "cncl_yn": "N"}] # ═══ R2-3 ETF/ETN 거래세 0 ═══════════════════════════════════════════════════ @pytest.mark.parametrize("name", ["PLUS 200", "KIWOOM 200TR", "PLUS K방산", "KODEX 200", "TIGER 미국S&P500", "삼성 인버스 2X WTI원유 선물 ETN"]) def test_etf_etn_sell_tax_zero(name): assert es._dt_sell_tax_rate({"stock_name": name}) == 0.0 @pytest.mark.parametrize("name", ["삼성전자", "KB금융", "SK하이닉스", "", None]) def test_stock_sell_tax_unchanged(name): assert es._dt_sell_tax_rate({"stock_name": name}) == float(settings.KR_SELL_TAX_RATE_KOSPI) assert es._dt_sell_tax_rate({"stock_name": name, "market": "KOSDAQ"}) == \ float(settings.KR_SELL_TAX_RATE_KOSDAQ) def test_etf_net_pnl_has_commission_only(): """10/06 PLUS 200 재현: 비용은 수수료뿐(거래세 812원 없음).""" r = es.dt_net_pnl(113_285.0, 112_840.0, 4, {"stock_name": "PLUS 200"}) fee = float(settings.KR_COMMISSION_RATE) expected_cost = (113_285.0 * 4 + 112_840.0 * 4) * fee assert abs(r["total_cost"] - expected_cost) <= 2.0 stock = es.dt_net_pnl(113_285.0, 112_840.0, 4, {"stock_name": "삼성전자"}) assert stock["total_cost"] - r["total_cost"] >= 112_840.0 * 4 * float(settings.KR_SELL_TAX_RATE_KOSPI) - 2 # ═══ R2-4 왕복 순손익 승패 ═══════════════════════════════════════════════════ @pytest.mark.asyncio async def test_partial_profit_then_small_loss_counts_as_round_trip_win(exit_env): """+1.5R 분할 익절 뒤 잔량 소폭 손실 청산 → 왕복 이익 → 연패 리셋·loss_tracker 이익.""" day_trading_state.positions["777777"] = _pos(10) day_trading_state.loss_streak_today = 2 with patch.object(es, "_in_closing_auction_impl", return_value=False): r1 = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_450.0, "목표가 도달", sell_pct=0.5) assert r1["profit_loss"] > 0 assert day_trading_state.positions["777777"]["realized_net"] == pytest.approx(r1["profit_loss"]) assert exit_env.await_count == 0 # 분할 레그는 기록 안 함 r2 = await es._execute_exit("777777", day_trading_state.positions["777777"], 9_990.0, "트레일링 스탑") assert r2["profit_loss"] < 0 # 마지막 레그만 보면 손실 assert r1["profit_loss"] + r2["profit_loss"] > 0 # 왕복은 이익 exit_env.assert_awaited_once_with("777777", False) assert day_trading_state.loss_streak_today == 0 assert day_trading_state.daily_pnl == pytest.approx(r1["profit_loss"] + r2["profit_loss"]) @pytest.mark.asyncio async def test_round_trip_loss_still_counts_as_loss(exit_env): day_trading_state.positions["777777"] = _pos(10) with patch.object(es, "_in_closing_auction_impl", return_value=False): await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0, "목표가 도달", sell_pct=0.5) await es._execute_exit("777777", day_trading_state.positions["777777"], 9_700.0, "손절") exit_env.assert_awaited_once_with("777777", True) assert day_trading_state.loss_streak_today == 1 @pytest.mark.asyncio async def test_no_partial_keeps_last_leg_rule(exit_env): day_trading_state.positions["777777"] = _pos(10) with patch.object(es, "_in_closing_auction_impl", return_value=False): r = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_010.0, "손절") assert r["profit_loss"] < 0 # 비용 차감 후 손실 exit_env.assert_awaited_once_with("777777", True) assert day_trading_state.loss_streak_today == 1 # ═══ R2-1 종가 단일가 매도 — 체결 확인 후 loss_tracker ═════════════════════════ @pytest.mark.asyncio async def test_closing_auction_sell_records_loss_on_confirmed_fill(exit_env): """15:20 신호가로는 이익이지만 15:30 체결가로 손실 → 체결 확인 후 '손실'로 1회 기록.""" day_trading_state.positions["777777"] = _pos(10) calls = {"n": 0} async def _orders(code): calls["n"] += 1 # 장 마감 전(즉시 확인) 구간에서는 loss_tracker 가 아직 기록되면 안 된다 if calls["n"] <= int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS): assert exit_env.await_count == 0 return _row(10, 0, 0, 10) # 미체결·미종결 return _row(10, 10, 9_950, 0) # 15:30 종가 체결 with 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), \ patch.object(es, "_in_closing_auction_impl", return_value=True), \ patch.object(es, "_auction_recheck_params_impl", return_value=(0.0, 3, 0.0)), \ patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \ patch.object(fr.korea_investment_api, "get_todays_orders", _orders): r = await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0, "EOD 강제 청산") assert r["profit_loss"] > 0 # 신호가 기준은 이익 await asyncio.gather(*list(es._auction_fill_tasks)) exit_env.assert_awaited_once_with("777777", True) expected = es.dt_net_pnl(10_000.0, 9_950.0, 10, _pos())["net_profit_loss"] assert day_trading_state.daily_pnl == pytest.approx(expected) @pytest.mark.asyncio async def test_closing_auction_unconfirmed_falls_back_to_signal_price(exit_env): """재확인까지 주문 행을 못 찾으면 신호가 기준으로 기록(구 동작) — 기록 누락 없음.""" day_trading_state.positions["777777"] = _pos(10) with 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), \ patch.object(es, "_in_closing_auction_impl", return_value=True), \ patch.object(es, "_auction_recheck_params_impl", return_value=(0.0, 2, 0.0)), \ patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \ patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=[])): await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0, "EOD 강제 청산") await asyncio.gather(*list(es._auction_fill_tasks)) exit_env.assert_awaited_once_with("777777", False) @pytest.mark.asyncio async def test_closing_auction_zero_fill_does_not_record(exit_env): """종가 단일가 매도가 0주 체결로 종결 → 포지션 복원, loss_tracker 기록하지 않음.""" day_trading_state.positions["777777"] = _pos(10) rows = [{"odno": "123", "pdno": "777777", "ord_qty": "10", "tot_ccld_qty": "0", "avg_prvs": "0", "rmn_qty": "0", "cncl_yn": "Y"}] with 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), \ patch.object(es, "_in_closing_auction_impl", return_value=True), \ patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \ patch.object(fr.korea_investment_api, "get_todays_orders", AsyncMock(return_value=rows)): await es._execute_exit("777777", day_trading_state.positions["777777"], 10_050.0, "EOD 강제 청산") await asyncio.gather(*list(es._auction_fill_tasks)) assert exit_env.await_count == 0 assert day_trading_state.positions["777777"]["quantity"] == 10 @pytest.mark.asyncio async def test_time_stop_in_auction_still_not_recorded(exit_env): day_trading_state.positions["777777"] = _pos(10) with patch.object(settings, "DAY_TRADE_FILL_CHECK_ENABLED", False), \ patch.object(es, "_in_closing_auction_impl", return_value=True): await es._execute_exit("777777", day_trading_state.positions["777777"], 9_960.0, "시간 손절 (테스트)") assert exit_env.await_count == 0 def test_closing_auction_window_bounds(): d = datetime(2026, 10, 6) assert not es._in_closing_auction_impl(d.replace(hour=15, minute=19, second=59)) assert es._in_closing_auction_impl(d.replace(hour=15, minute=20)) assert es._in_closing_auction_impl(d.replace(hour=15, minute=29, second=59)) assert not es._in_closing_auction_impl(d.replace(hour=15, minute=30)) # ═══ R2-5 강제청산 미청산 경보 ═══════════════════════════════════════════════ @pytest.mark.asyncio async def test_force_close_none_result_retried_and_alerted(_clean): day_trading_state.positions["888888"] = _pos(5) exec_mock = AsyncMock(return_value=None) # KIS 거부 등 — 예외 없이 None with patch.object(es, "_execute_exit_impl", exec_mock), \ patch.object(es, "is_eod_flat_time", return_value=False), \ patch.object(es, "close_stale_positions", _boom("close_stale_positions")), \ patch.object(es.korea_investment_api, "get_current_price", AsyncMock(return_value={"output": {"stck_prpr": "10000"}})): out = await es.force_close_all(reason="EOD 강제 청산") await asyncio.gather(*list(es._alert_tasks)) assert out == [] assert exec_mock.await_count == 2 # 1회 + 재시도 1회 tags = [c.args[0] for c in _clean.await_args_list] assert tags == ["DT_EOD"] assert "888888" in _clean.await_args_list[0].args[1] @pytest.mark.asyncio async def test_post_close_residual_check_alerts(_clean): assert es.check_post_close_residual_v2() == [] day_trading_state.positions["888888"] = _pos(5) assert es.check_post_close_residual_v2() == ["888888"] es.check_post_close_residual_v2() # 같은 날 같은 잔존 → 1회만 await asyncio.gather(*list(es._alert_tasks)) assert [c.args[0] for c in _clean.await_args_list] == ["DT_EOD"] @pytest.mark.asyncio async def test_eod_force_close_schedules_post_close_check_once(_clean): day_trading_state.positions["888888"] = _pos(5) day_trading_state.exits_in_progress.add("888888") # 불명 보류 중 → 이번 회차 skip with patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")), \ patch.object(es, "is_eod_flat_time", return_value=True), \ patch.object(es, "_seconds_until_auction_recheck_impl", return_value=0.0), \ patch.object(es.korea_investment_api, "get_current_price", _boom("get_current_price")): await es.force_close_all(reason="EOD 강제 청산") await es.force_close_all(reason="EOD 강제 청산") assert len(es._post_close_tasks) <= 1 await asyncio.gather(*list(es._post_close_tasks)) await asyncio.gather(*list(es._alert_tasks)) assert [c.args[0] for c in _clean.await_args_list] == ["DT_EOD"]