"""[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)