Files
auto-stock/backend/tests/test_dt_daily_state_rebuild_r2_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

275 lines
11 KiB
Python

"""[2026-10-09 하드닝 R2] DT 일일 안전장치 복원의 Redis 단일 소스 fail-open 회귀 테스트.
결함: load_daily_state() 가 Redis 에서만 복원 → Redis 재기동·volatile-lru 축출·저장 실패 뒤
재기동하면 daily_pnl=0 / 연패 0 / closed_today=∅ 로 같은 날 매매를 재개했다(warning 만).
수정: Redis 미스 시 당일 TradeHistory(SELECT 전용)로 재구성, 실패하면 DT 신규 진입 차단 +
텔레그램 + 재시도.
"""
import asyncio
import inspect
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from app.core.config import settings
from app.services.day_trading_strategy import is_daily_loss_exceeded
from app.services.daytrading import position_manager as pm
from app.services.daytrading.position_manager import DayTradingState
class _DBDown(Exception):
pass
def _row(i, code, order_type, net=None, status="COMPLETED", reason="", strategy="daytrading"):
now = datetime.combine(pm._today_kst(), datetime.min.time()).replace(hour=9, minute=10 + i)
return SimpleNamespace(
id=i, stock_code=code, order_type=order_type, status=status, exit_reason=reason,
net_profit_loss=net, strategy_type=strategy, created_at=now,
)
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 _raising_factory():
def _f():
raise _DBDown("db down")
return _f
@pytest.fixture(autouse=True)
def _isolate():
pm._daily_state_retry_task = None
notify = AsyncMock(return_value=None)
with patch.object(DayTradingState, "save_daily_state", AsyncMock(return_value=True)), \
patch("app.services.telegram_notifier.telegram_notifier.notify_system_warning", notify):
yield notify
t = pm._daily_state_retry_task
if t is not None and not t.done():
t.cancel()
pm._daily_state_retry_task = None
def _state():
st = DayTradingState()
st.begin_restore()
return st
def _assets_setter(st, value):
async def _ensure():
st.last_total_assets = value
return value
return _ensure
def _losses_over_limit(total_assets):
"""-3% 한도(settings)를 넘는 손실 3건(연패 3)."""
per = abs(float(settings.DAY_TRADE_DAILY_LOSS_LIMIT)) * total_assets / 2
return [
_row(0, "A00001", "BUY"), _row(1, "A00001", "SELL", -per),
_row(2, "A00002", "BUY"), _row(3, "A00002", "SELL", -per),
_row(4, "A00003", "BUY"), _row(5, "A00003", "SELL", -1000.0, reason="시간손절 30분"),
]
# ── 1. Redis 미스 → 원장으로 -3% 한도·3연패·재진입 차단 복원 ────────────────────
def test_rebuild_restores_loss_limit_streak_and_closed_today():
async def _t():
st = _state()
assets = 10_000_000.0
rows = _losses_over_limit(assets)
with patch("app.services.daytrading.exit_service._ensure_total_assets_for_exit_impl",
_assets_setter(st, assets)):
ok = await st.rebuild_daily_state_after_redis_miss_v2(_factory(rows))
assert ok is True
assert st.daily_state_unverified is False
assert st.daily_state_source == "trade_history"
expected = sum(float(r.net_profit_loss) for r in rows if r.order_type == "SELL")
assert st.daily_pnl == pytest.approx(expected)
assert is_daily_loss_exceeded(st.last_total_assets, st.daily_pnl) is True
assert st.loss_streak_today == 3 # 시간손절도 당일 연패에 포함(§5.1)
assert st.is_cooldown_active() is True
assert {"A00001", "A00002", "A00003"} <= st.closed_today
assert st.is_closed_today("A00001")
asyncio.run(_t())
def test_stale_and_orphan_sells_excluded_from_daily_pnl_but_block_reentry():
async def _t():
st = _state()
st.last_total_assets = 10_000_000.0
rows = [
_row(0, "S00001", "SELL", -500_000.0, reason="전일 미청산 포지션 강제 청산 (진입일 x)"),
_row(1, "S00002", "SELL", -400_000.0, reason="DT 고아 잔고 청산 (진입일 x)"),
_row(2, "S00003", "SELL", -300_000.0, reason="손절"), # 당일 BUY 없음 = 전일분
_row(3, "B00001", "BUY"), _row(4, "B00001", "SELL", 2_000.0),
]
ok = await st.rebuild_daily_state_after_redis_miss_v2(_factory(rows))
assert ok is True
assert st.daily_pnl == pytest.approx(2_000.0)
assert st.loss_streak_today == 0
assert {"S00001", "S00002", "S00003", "B00001"} <= st.closed_today
asyncio.run(_t())
def test_stale_prefixes_match_exit_service_reasons():
from app.services.daytrading import exit_service as es
src = inspect.getsource(es)
for p in pm._STALE_EXIT_REASON_PREFIXES:
assert p in src, f"exit_service 의 전일·고아 청산 사유 문자열과 동기화 필요: {p}"
def test_partial_sell_not_counted_as_full_close_and_cancelled_ignored():
rows = [
_row(0, "P00001", "BUY"),
_row(1, "L00001", "BUY"), _row(2, "L00001", "SELL", -100.0),
_row(3, "P00001", "SELL", 900.0), # 분할 익절 — 연패 리셋하지 않음
_row(4, "L00002", "BUY"), _row(5, "L00002", "SELL", -100.0),
_row(6, "P00001", "SELL", -50.0), # 잔량 전량 — 레그는 손실이나 왕복 +850 (R3: 이익 → 리셋)
_row(7, "C00001", "BUY"), _row(8, "C00001", "SELL", -99_999.0, status="CANCELLED"),
_row(9, "W00001", "SELL", 10.0, strategy="swing"),
_row(10, "L00003", "BUY"), _row(11, "L00003", "SELL", -100.0),
]
r = pm._rebuild_daily_state_from_rows(rows)
# [R3] L1(-) → 1, L2(-) → 2, P 왕복 +850 → 0, L3(-) → 1. 분할 레그 단독으로는 리셋하지 않는다.
assert r["loss_streak_today"] == 1
assert r["daily_pnl"] == pytest.approx(-100.0 + 900.0 - 100.0 - 50.0 - 100.0)
assert "C00001" in r["closed_today"] and "C00001" not in r["dt_exited_today"]
assert "W00001" in r["closed_today"] # 전략 간 당일 쿨다운
def test_no_rows_is_verified_empty_state():
async def _t():
st = _state()
ok = await st.rebuild_daily_state_after_redis_miss_v2(_factory([]))
assert ok is True
assert st.daily_state_unverified is False
assert st.is_cooldown_active() is False
asyncio.run(_t())
# ── 2. 재구성 실패 → 신규 진입 차단 + 텔레그램 + 재시도로 해제 ────────────────────
def test_db_failure_blocks_entry_and_alerts(_isolate):
async def _t():
st = _state()
with patch.object(pm, "_DAILY_STATE_REBUILD_RETRY_SEC", 3600.0):
ok = await st.rebuild_daily_state_after_redis_miss_v2(_raising_factory())
assert ok is False
assert st.daily_state_unverified is True
assert "db down" in st.daily_state_unverified_reason
assert st.is_cooldown_active() is True
assert _isolate.await_count == 1
assert pm._daily_state_retry_task is not None
asyncio.run(_t())
def test_retry_clears_block_when_db_recovers(_isolate):
async def _t():
st = _state()
calls = {"n": 0}
rows = [_row(0, "R00001", "BUY"), _row(1, "R00001", "SELL", 1_000.0)]
def _flaky():
calls["n"] += 1
if calls["n"] == 1:
raise _DBDown("db down")
return _Session(rows)
with patch.object(pm, "_DAILY_STATE_REBUILD_RETRY_SEC", 0.01):
ok = await st.rebuild_daily_state_after_redis_miss_v2(_flaky)
assert ok is False and st.is_cooldown_active() is True
await asyncio.wait_for(pm._daily_state_retry_task, timeout=2.0)
assert st.daily_state_unverified is False
assert st.is_cooldown_active() is False
assert st.daily_pnl == pytest.approx(1_000.0)
assert _isolate.await_count == 2 # 차단 알림 + 해제 알림
asyncio.run(_t())
def test_loss_without_total_assets_stays_blocked():
"""손실인데 분모 0 → is_daily_loss_exceeded 가 무조건 False 라 한도 무력화 → 미검증 유지."""
async def _t():
st = _state()
rows = [_row(0, "X00001", "BUY"), _row(1, "X00001", "SELL", -1_000_000.0)]
boom = AsyncMock(side_effect=_DBDown("kis down"))
with patch("app.services.daytrading.exit_service._ensure_total_assets_for_exit_impl", boom), \
patch.object(pm, "_DAILY_STATE_REBUILD_RETRY_SEC", 3600.0):
ok = await st.rebuild_daily_state_after_redis_miss_v2(_factory(rows))
assert boom.await_count == 1
assert ok is False
assert st.daily_pnl == pytest.approx(-1_000_000.0)
assert st.is_cooldown_active() is True
asyncio.run(_t())
def test_merge_is_conservative_with_in_memory_values():
async def _t():
st = _state()
st.last_total_assets = 10_000_000.0
st.daily_pnl = -5_000.0
st.loss_streak_today = 2
st.closed_today = {"M00001"}
rows = [_row(0, "N00001", "BUY"), _row(1, "N00001", "SELL", -3_000.0)]
await st.rebuild_daily_state_after_redis_miss_v2(_factory(rows))
assert st.daily_pnl == pytest.approx(-5_000.0) # min, 이중 합산 아님
assert st.loss_streak_today == 2
assert st.closed_today == {"M00001", "N00001"}
asyncio.run(_t())
def test_new_day_reset_clears_unverified_block():
st = _state()
st.daily_state_unverified = True
st.last_reset_date = None
assert st.reset_if_new_day() is True
assert st.daily_state_unverified is False
assert st.is_cooldown_active() is False
# ── 3. 배선 ─────────────────────────────────────────────────────────────────
def test_scheduler_calls_rebuild_only_after_redis_miss():
from app.services.daytrading import scheduler as sched
ssrc = inspect.getsource(sched.daytrading_scheduler)
i_load = ssrc.index("load_daily_state()")
i_rebuild = ssrc.index("rebuild_daily_state_after_redis_miss_v2()")
assert ssrc.index("begin_restore()") < i_load < i_rebuild
def test_exit_paths_do_not_consult_entry_block():
"""미검증 차단은 진입만 막는다 — 청산 경로가 is_cooldown_active 를 보면 안 된다(§8.1-11)."""
from app.services.daytrading import exit_service as es
assert "is_cooldown_active" not in inspect.getsource(es)