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
This commit is contained in:
207
backend/tests/test_dt_fill_late_recheck_r1_20261009.py
Normal file
207
backend/tests/test_dt_fill_late_recheck_r1_20261009.py
Normal file
@@ -0,0 +1,207 @@
|
||||
"""[하드닝 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)
|
||||
Reference in New Issue
Block a user