Files
auto-stock/backend/tests/test_dt_fill_late_recheck_r1_20261009.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
2026-10-10 12:17:49 +09:00

208 lines
8.3 KiB
Python

"""[하드닝 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)