"""[하드닝 R1 2026-10-09] DT 체결가 확인 누락 — 지연 재확인 + 시간초과 시 본 행 보존. 운영 실측(trade_history daytrading 10/06~10/08): SELL 8/18·BUY 3/19 slippage NULL(체결 미확인). 즉시 확인 창(≈6초)에서 종결 행을 못 보면 신호가 기준 손익이 그대로 남아 일일 -3% 한도·연패 판정이 실제와 어긋난다. fill_reconciler 가 종결을 못 본 경우 간격을 넓혀 더 조회하고, 결과는 1회만 넘긴다. 목킹: KIS 조회(`get_todays_orders`)는 회차별 응답 목록을 소진하면 **예외 발생형**(BaseException)으로 막는다 — 예상보다 많이 부르면 거짓 통과 대신 실패한다. 설정은 settings 를 참조/패치한다. """ import asyncio from unittest.mock import AsyncMock, patch import pytest from app.core.config import settings from app.services.daytrading import fill_reconciler as fr class MustNotBeCalled(BaseException): """예상 밖 호출 (except Exception 에 삼켜지지 않게 BaseException).""" def _scripted(responses): """회차별 응답. 원소가 BaseException/Exception 인스턴스면 raise, 소진되면 MustNotBeCalled.""" calls = {"n": 0} async def _f(*a, **k): i = calls["n"] calls["n"] += 1 if i >= len(responses): raise MustNotBeCalled(f"get_todays_orders 예상 밖 {i + 1}번째 호출") r = responses[i] if isinstance(r, BaseException): raise r return r return _f, calls @pytest.fixture def fill_on(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ENABLED", True) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_DELAY_SEC", 0.0) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 0.0) def _n_initial(): return max(1, int(settings.DAY_TRADE_FILL_CHECK_ATTEMPTS)) def _row(odno, filled, avg, ord_qty, rmn): return {"odno": odno, "tot_ccld_qty": str(filled), "avg_prvs": str(avg), "ord_qty": str(ord_qty), "rmn_qty": str(rmn)} def test_late_params_default_scale_and_override(monkeypatch): monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_INTERVAL_SEC", 2.0) attempts, interval, per_call = fr._late_recheck_params_impl() assert attempts == getattr(settings, "DAY_TRADE_FILL_LATE_RECHECK_ATTEMPTS", fr._LATE_RECHECK_DEFAULT_ATTEMPTS) if getattr(settings, "DAY_TRADE_FILL_LATE_RECHECK_INTERVAL_SEC", None) is None: assert interval == pytest.approx(2.0 * fr._LATE_RECHECK_INTERVAL_FACTOR) assert per_call == pytest.approx(float(settings.DAY_TRADE_FILL_CHECK_TIMEOUT_SEC)) class _Override: """config 키가 추가됐을 때의 경로(pydantic Settings 에는 미정의 필드를 넣을 수 없어 대리 객체).""" DAY_TRADE_FILL_LATE_RECHECK_ATTEMPTS = -3 DAY_TRADE_FILL_LATE_RECHECK_INTERVAL_SEC = 7.5 def __getattr__(self, name): return getattr(settings, name) monkeypatch.setattr(fr, "settings", _Override()) a2, i2, _ = fr._late_recheck_params_impl() assert a2 == 0 and i2 == 7.5 @pytest.mark.asyncio async def test_row_missing_initially_then_confirmed_late(fill_on): """즉시 확인에서 행이 안 보이다가 지연 재확인에서 전량 체결 → on_result 1회(체결가 반영).""" row = _row("0000000123", 10, 10_080, 10, 0) f, calls = _scripted([[]] * _n_initial() + [[], [row]]) got = [] async def _on(info): got.append(info) with patch.object(fr.korea_investment_api, "get_todays_orders", f): t = fr.schedule_v2("005930", "123", "SELL", 10, _on) await t assert len(got) == 1 assert got[0]["terminal"] is True and got[0]["filled_qty"] == 10 assert got[0]["avg_price"] == pytest.approx(10_080.0) assert calls["n"] == _n_initial() + 2 # 종결 확인 즉시 중단 @pytest.mark.asyncio async def test_non_terminal_zero_fill_then_late_fill_delivered_once(fill_on): """즉시 확인은 접수만(체결 0, 미종결) → 지연 재확인에서 체결 종결. 콜백은 최종 1회만.""" pending = _row("77", 0, 0, 5, 5) done = _row("77", 5, 50_100, 5, 0) f, calls = _scripted([[pending]] * _n_initial() + [[pending], [done]]) got = [] async def _on(info): got.append(info) with patch.object(fr.korea_investment_api, "get_todays_orders", f): await fr.schedule_v2("000660", "77", "BUY", 5, _on) assert [g["filled_qty"] for g in got] == [5] assert got[0]["terminal"] is True @pytest.mark.asyncio async def test_terminal_on_first_check_no_late_calls(fill_on): row = _row("9", 3, 1_000, 3, 0) f, calls = _scripted([[row]]) # 두 번째 호출이 있으면 MustNotBeCalled got = [] async def _on(info): got.append(info) with patch.object(fr.korea_investment_api, "get_todays_orders", f): await fr.schedule_v2("111111", "9", "SELL", 3, _on) assert calls["n"] == 1 and len(got) == 1 @pytest.mark.asyncio async def test_never_found_no_callback_and_final_warning(fill_on, caplog): late_n = fr._late_recheck_params_impl()[0] f, calls = _scripted([[]] * (_n_initial() + late_n)) async def _on(info): raise MustNotBeCalled("미확인인데 on_result 호출") with patch.object(fr.korea_investment_api, "get_todays_orders", f): await fr.schedule_v2("222222", "5", "SELL", 4, _on) assert calls["n"] == _n_initial() + late_n assert any("최종 체결 미확인" in r.message for r in caplog.records) @pytest.mark.asyncio async def test_late_call_exception_is_isolated(fill_on, caplog): """지연 재확인 한 회차의 일반 예외는 그 회차만 건너뛴다(다음 회차에서 확정).""" row = _row("31", 2, 20_000, 2, 0) f, calls = _scripted([[]] * _n_initial() + [RuntimeError("EGW00201"), [row]]) got = [] async def _on(info): got.append(info) with patch.object(fr.korea_investment_api, "get_todays_orders", f): await fr.schedule_v2("333333", "31", "BUY", 2, _on) assert len(got) == 1 and got[0]["terminal"] is True assert any("지연 재확인" in r.message and "RuntimeError" in r.message for r in caplog.records) @pytest.mark.asyncio async def test_initial_timeout_keeps_seen_partial_row(monkeypatch, fill_on): """즉시 확인이 시간초과로 끊겨도 그 전에 본 행(부분체결, 미종결)은 버리지 않는다.""" monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_ATTEMPTS", 3) monkeypatch.setattr(settings, "DAY_TRADE_FILL_CHECK_TIMEOUT_SEC", 0.05) partial = _row("44", 2, 9_900, 5, 3) n = {"c": 0} async def _slow_after_first(*a, **k): n["c"] += 1 if n["c"] == 1: return [partial] await asyncio.sleep(5) # rate-limit sleep 모사 return [] with patch.object(fr.korea_investment_api, "get_todays_orders", _slow_after_first): info = await fr.fetch_fill_v2("444444", "44", "SELL", 5) assert info is not None and info["filled_qty"] == 2 and info["terminal"] is False @pytest.mark.asyncio async def test_sell_late_confirmation_writes_slippage(monkeypatch, fill_on): """종단: 매도 체결이 지연 재확인에서 확인되면 TradeHistory 에 slippage 가 기록된다.""" from app.services.daytrading import exit_service as es from app.services.daytrading.position_manager import day_trading_state import app.services.db_worker as dbw updates = [] async def _upd(order_id, fields): updates.append((order_id, fields)) row = _row("555", 4, 10_050, 4, 0) f, _ = _scripted([[]] * _n_initial() + [[row]]) monkeypatch.setattr(dbw, "enqueue_update_trade_history_by_order_id", _upd) with patch.object(fr.korea_investment_api, "get_todays_orders", f), \ patch.object(day_trading_state, "save_daily_state", AsyncMock(return_value=True)), \ patch.object(day_trading_state, "adjust_realized_pnl", lambda d: None): t = es._schedule_sell_fill_reconcile_v2( stock_code="555555", pos={"entry_price": 10_000.0, "quantity": 4}, order_id="555", signal_price=10_100.0, quantity=4, entry_price=10_000.0, recorded_net=0.0, count_daily_pnl=True, ) assert t is not None await t assert len(updates) == 1 oid, fields = updates[0] assert oid == "555" and fields.get("slippage") is not None assert float(fields["executed_price"]) == pytest.approx(10_050.0)