- 스윙 체결가 확인 전 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
208 lines
8.3 KiB
Python
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)
|