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