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:
271
backend/tests/test_dt_restore_verify_r3_20261009.py
Normal file
271
backend/tests/test_dt_restore_verify_r3_20261009.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""[2026-10-09 하드닝 R3] DT 기동 복원 검증 + 원장 재구성 연패의 왕복 기준 회귀 테스트.
|
||||
|
||||
결함 1 (MEDIUM): restore_from_db 가 closed_today·KIS 실보유를 보지 않고 당일 active day_trade 행을
|
||||
인메모리로 되살려, 청산 큐 유실 뒤 재기동하면 이미 판 물량을 일반 청산 경로가 다시 팔 수 있었다.
|
||||
→ verify_restored_today_positions_v2 (장중 복원 경로 _restore_lost_today_positions_impl 과 같은 규칙).
|
||||
결함 2 (LOW): Redis 미스 시 원장 재구성의 연패가 마지막 레그 기준이라 실시간 왕복 기준(R2-4)과 달랐다.
|
||||
"""
|
||||
import inspect
|
||||
from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.daytrading import exit_service as xs
|
||||
from app.services.daytrading import position_manager as pm
|
||||
from app.services.daytrading.position_manager import DayTradingState
|
||||
|
||||
|
||||
class _MustNotCall(BaseException):
|
||||
"""호출되면 안 되는 경로 — 프로덕션 except Exception 에 삼켜지지 않게 BaseException."""
|
||||
|
||||
|
||||
class _KISDown(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class _Result:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def scalars(self):
|
||||
return self
|
||||
|
||||
def all(self):
|
||||
return list(self._rows)
|
||||
|
||||
|
||||
class _Session:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def execute(self, *_a, **_k):
|
||||
return _Result(self._rows)
|
||||
|
||||
|
||||
def _factory(rows):
|
||||
return lambda: _Session(rows)
|
||||
|
||||
|
||||
def _no_db():
|
||||
raise _MustNotCall("DB 조회가 일어나면 안 된다")
|
||||
|
||||
|
||||
async def _no_kis():
|
||||
raise _MustNotCall("KIS 잔고 조회가 일어나면 안 된다")
|
||||
|
||||
|
||||
def _holdings(mapping):
|
||||
async def _f():
|
||||
return {c: {"hldg": q, "sellable": q} for c, q in mapping.items()}
|
||||
return _f
|
||||
|
||||
|
||||
async def _kis_down():
|
||||
raise _KISDown("balance down")
|
||||
|
||||
|
||||
def _today_at(h=9, m=10):
|
||||
return datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=h, minute=m)
|
||||
|
||||
|
||||
def _pos(qty, entry_time=None, restored=True):
|
||||
return {
|
||||
"stock_name": "T", "strategy": "day_trade", "entry_price": 10_000.0,
|
||||
"stop_loss": 9_800.0, "target_price": 10_300.0, "quantity": qty,
|
||||
"highest_price": 10_000.0, "entry_time": entry_time or _today_at(),
|
||||
"reason": "", "order_id": "", **({"restored_from_db": True} if restored else {}),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _notify():
|
||||
m = AsyncMock(return_value=None)
|
||||
with patch.object(pm, "_notify_daily_state_impl", m):
|
||||
yield m
|
||||
|
||||
|
||||
def _state(**positions):
|
||||
st = DayTradingState()
|
||||
st.begin_restore()
|
||||
st.positions.update(positions)
|
||||
return st
|
||||
|
||||
|
||||
# ── 결함 1: 기동 복원 검증 ────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_closed_today_restored_position_is_held_back_and_alerted(_notify):
|
||||
"""전량 청산 후 close 큐 유실 → active 행이 남아 복원돼도 DT 전량 청산 기록이면 되살리지 않는다.
|
||||
|
||||
[R2 하드닝] 근거는 closed_today(재진입 차단)가 아니라 remove_position 이 남기는 dt_exited_today.
|
||||
"""
|
||||
st = _state(A00001=_pos(7))
|
||||
st.closed_today.add("A00001")
|
||||
st.dt_exited_today.add("A00001")
|
||||
r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis)
|
||||
assert "A00001" not in st.positions
|
||||
assert r["held"] == ["A00001"]
|
||||
assert "A00001" in st.closed_today # 재진입 차단 유지
|
||||
_notify.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kis_holding_minus_swing_below_row_qty_is_held_back(_notify):
|
||||
"""실보유 7주 중 스윙 4주 → DT 가용 3주 < 행 7주: 복원하면 스윙 물량을 판다(§3.5)."""
|
||||
st = _state(A00002=_pos(7))
|
||||
swing = [SimpleNamespace(stock_code="A00002", quantity=4)]
|
||||
r = await st.verify_restored_today_positions_v2(
|
||||
session_factory=_factory(swing), holdings_fetcher=_holdings({"A00002": 7}),
|
||||
)
|
||||
assert "A00002" not in st.positions
|
||||
assert r["held"] == ["A00002"] and r["unverified"] is False
|
||||
_notify.assert_awaited_once()
|
||||
assert "A00002" not in st.closed_today # 잔고가 맞으면 장중 복원이 다시 잡을 수 있게
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_sell_upsert_lost_row_qty_exceeds_holding_is_held_back():
|
||||
"""분할 매도 후 잔량 upsert 유실 — 행 10주, 실보유 5주 → 보류."""
|
||||
st = _state(A00003=_pos(10))
|
||||
r = await st.verify_restored_today_positions_v2(
|
||||
session_factory=_factory([]), holdings_fetcher=_holdings({"A00003": 5}),
|
||||
)
|
||||
assert r["held"] == ["A00003"] and "A00003" not in st.positions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consistent_position_is_kept(_notify):
|
||||
st = _state(A00004=_pos(7))
|
||||
p = st.positions["A00004"]
|
||||
swing = [SimpleNamespace(stock_code="A00004", quantity=3)]
|
||||
r = await st.verify_restored_today_positions_v2(
|
||||
session_factory=_factory(swing), holdings_fetcher=_holdings({"A00004": 10}),
|
||||
)
|
||||
assert st.positions["A00004"] is p
|
||||
assert r["held"] == [] and r["checked"] == 1
|
||||
_notify.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prior_day_and_non_restored_positions_are_not_touched():
|
||||
"""전일 진입분은 close_stale_positions 담당, 복원 표식 없는 포지션은 대상 아님."""
|
||||
yday = _today_at() - timedelta(days=1)
|
||||
st = _state(S00001=_pos(5, entry_time=yday), N00001=_pos(5, restored=False))
|
||||
st.closed_today |= {"S00001", "N00001"}
|
||||
r = await st.verify_restored_today_positions_v2(session_factory=_no_db, holdings_fetcher=_no_kis)
|
||||
assert set(st.positions) == {"S00001", "N00001"}
|
||||
assert r["checked"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unverified_holds_back_when_orphan_detect_enabled(_notify):
|
||||
"""판정 불가(KIS 장애) → 장중 close_stale_positions 가 같은 규칙으로 재복원하므로 보류(경보는 warning)."""
|
||||
st = _state(A00005=_pos(3))
|
||||
with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True):
|
||||
r = await st.verify_restored_today_positions_v2(
|
||||
session_factory=_factory([]), holdings_fetcher=_kis_down,
|
||||
)
|
||||
assert r["unverified"] is True and r["held"] == ["A00005"]
|
||||
assert "A00005" not in st.positions
|
||||
_notify.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unverified_keeps_when_orphan_detect_disabled():
|
||||
"""재복원 경로가 없으면 보류는 곧 손절 공백 — 유지하고 error."""
|
||||
st = _state(A00006=_pos(3))
|
||||
with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", False):
|
||||
r = await st.verify_restored_today_positions_v2(
|
||||
session_factory=_factory([]), holdings_fetcher=_kis_down,
|
||||
)
|
||||
assert r["unverified"] is True and r["held"] == []
|
||||
assert "A00006" in st.positions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_db_failure_is_unverified_and_skips_kis():
|
||||
def _db_down():
|
||||
raise _KISDown("db down")
|
||||
st = _state(A00007=_pos(3))
|
||||
with patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True):
|
||||
r = await st.verify_restored_today_positions_v2(session_factory=_db_down, holdings_fetcher=_no_kis)
|
||||
assert r["unverified"] is True and "A00007" not in st.positions
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_fetcher_is_exit_service_holdings_common_terminal():
|
||||
"""기본 잔고 조회는 exit_service._fetch_kis_holdings_v2_impl (장중 복원 경로와 같은 소스)."""
|
||||
st = _state(A00008=_pos(3))
|
||||
m = AsyncMock(side_effect=_KISDown("blocked"))
|
||||
with patch.object(xs, "_fetch_kis_holdings_v2_impl", m), \
|
||||
patch.object(settings, "DAY_TRADE_ORPHAN_DETECT_ENABLED", True):
|
||||
r = await st.verify_restored_today_positions_v2(session_factory=_factory([]))
|
||||
m.assert_awaited_once()
|
||||
assert r["unverified"] is True
|
||||
|
||||
|
||||
def test_restore_from_db_signature_unchanged_and_scheduler_wires_verify():
|
||||
assert list(inspect.signature(DayTradingState.restore_from_db).parameters) == ["self", "db"]
|
||||
from app.services.daytrading import scheduler as sch
|
||||
src = inspect.getsource(sch)
|
||||
i_restore = src.index("restore_from_db(db)")
|
||||
i_verify = src.index("verify_restored_today_positions_v2()")
|
||||
i_stale = src.index('close_stale_positions(), "기동 시')
|
||||
assert i_restore < i_verify < i_stale
|
||||
|
||||
|
||||
# ── 결함 2: 원장 재구성 연패 = 왕복 순손익 ─────────────────────────────────────
|
||||
|
||||
def _row(i, code, order_type, net=None):
|
||||
return SimpleNamespace(
|
||||
id=i, stock_code=code, order_type=order_type, status="COMPLETED", exit_reason="",
|
||||
net_profit_loss=net, strategy_type="daytrading", created_at=_today_at(9, 10 + i),
|
||||
)
|
||||
|
||||
|
||||
def test_partial_profit_then_small_loss_is_round_trip_win():
|
||||
"""+1.5R 분할 +3,000 뒤 잔량 -300 → 왕복 +2,700 = 이익 → 연패 리셋 (실시간 R2-4 와 동일)."""
|
||||
rows = [
|
||||
_row(0, "L00001", "BUY"), _row(1, "L00001", "SELL", -500.0),
|
||||
_row(2, "L00002", "BUY"), _row(3, "L00002", "SELL", -500.0),
|
||||
_row(4, "P00001", "BUY"), _row(5, "P00001", "SELL", 3000.0), _row(6, "P00001", "SELL", -300.0),
|
||||
]
|
||||
r = pm._rebuild_daily_state_from_rows(rows)
|
||||
assert r["loss_streak_today"] == 0
|
||||
assert r["daily_pnl"] == pytest.approx(-500.0 - 500.0 + 3000.0 - 300.0)
|
||||
|
||||
|
||||
def test_partial_small_profit_then_big_loss_is_round_trip_loss():
|
||||
rows = [_row(0, "P00002", "BUY"), _row(1, "P00002", "SELL", 100.0), _row(2, "P00002", "SELL", -300.0)]
|
||||
assert pm._rebuild_daily_state_from_rows(rows)["loss_streak_today"] == 1
|
||||
|
||||
|
||||
def test_rebuild_streak_matches_realtime_round_trip_rule():
|
||||
"""재구성 결과 = 실시간 _apply_round_trip_streak_v2 를 같은 왕복 순서로 적용한 결과."""
|
||||
trips = [("A", [-10.0]), ("B", [500.0, -20.0]), ("C", [-5.0]), ("D", [50.0, -80.0]), ("E", [0.0])]
|
||||
rows, i = [], 0
|
||||
for code, legs in trips:
|
||||
rows.append(_row(i, code, "BUY")); i += 1
|
||||
for leg in legs:
|
||||
rows.append(_row(i, code, "SELL", leg)); i += 1
|
||||
rebuilt = pm._rebuild_daily_state_from_rows(rows)["loss_streak_today"]
|
||||
|
||||
st = xs.day_trading_state
|
||||
saved = st.loss_streak_today
|
||||
try:
|
||||
st.loss_streak_today = 0
|
||||
for _code, legs in trips:
|
||||
xs._apply_round_trip_streak_v2(sum(legs))
|
||||
realtime = st.loss_streak_today
|
||||
finally:
|
||||
st.loss_streak_today = saved
|
||||
assert rebuilt == realtime == 2
|
||||
Reference in New Issue
Block a user