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

241 lines
11 KiB
Python

"""하드닝 R2 — MDD 갱신 입력 가드 회귀 테스트 (swing/scheduler._mdd_update_guarded_v2).
결함: tot_evlu_amt 원값을 그대로 mdd_manager.update() 에 넣어
- 출금 10%+ 한 번으로 drawdown<=-10% → is_paused + 전 보유 30% 시장가 축소,
- KIS 가 일부 종목 평가를 0 으로 준 응답 한 번으로 동일 축소,
- 입금이 peak 를 부풀리거나 진행 중인 MDD 정지를 해제했다.
실제 MDDManager 상태 전이(_update_locked_v2)를 쓰고, 외부 I/O(Redis 저장·텔레그램)만
예외 발생형/무동작 목으로 막는다. 임계값은 settings 를 참조한다.
"""
import asyncio
from unittest.mock import AsyncMock
import pytest
from app.core.config import settings
class _NoIO(BaseException):
"""Redis/텔레그램 실호출이 새면 즉시 터지게 하는 예외."""
@pytest.fixture
def env(monkeypatch):
import app.services.swing.scheduler as sch
import app.services.telegram_notifier as tn
from app.services.mdd_manager import MDDManager
mgr = MDDManager()
async def _no_write(key, state): # Redis 쓰기 차단(무동작) — 상태 전이는 실제 로직
return None
monkeypatch.setattr(mgr, "_write_state_v2", _no_write)
monkeypatch.setattr(mgr, "save_to_redis", AsyncMock())
monkeypatch.setattr(sch, "mdd_manager", mgr)
monkeypatch.setattr(sch, "_mdd_gate_state", {"prev": None, "pending": 0, "suspect": 0, "last_flow": None})
monkeypatch.setattr(sch, "_alert_sent_at", {})
monkeypatch.setattr(tn.telegram_notifier, "notify_mdd_warning", AsyncMock())
monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", AsyncMock())
monkeypatch.setattr(sch, "_send_system_warning_checked_v2", AsyncMock(return_value=True))
return sch, mgr
def _resp(total, scts, holdings=None):
return {
"output1": holdings if holdings is not None else [],
"output2": [{"tot_evlu_amt": str(total), "scts_evlu_amt": str(scts)}],
}
async def _feed(sch, total, scts, holdings=None):
return await sch._mdd_update_guarded_v2(float(total), _resp(total, scts, holdings))
def _pause_ratio():
# [R3] 확인 대상 판정은 mdd_manager 실효 임계(_mdd_stage_thresholds_v2)와 같다
import app.services.swing.scheduler as sch
return 1 + sch._mdd_stage_thresholds_v2()[0] / 100.0
# ── 1. 출금은 낙폭이 아니다 ──────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_withdrawal_does_not_trigger_mdd_reduction(env):
sch, mgr = env
await _feed(sch, 10_000_000, 6_000_000) # peak 10M
assert mgr.peak_balance == 10_000_000
# 현금 4M 중 1.5M 출금 (총자산 -15%), 유가평가 불변
r = await _feed(sch, 8_500_000, 6_000_000)
assert r["status"] == "applied"
assert r["result"]["action"] == "NONE"
assert mgr.reduction_applied is None and not mgr.is_paused
assert mgr.peak_balance == pytest.approx(8_500_000)
assert sch._mdd_gate_state["last_flow"] == pytest.approx(-1_500_000)
@pytest.mark.asyncio
async def test_withdrawal_preserves_existing_drawdown_pct(env):
sch, mgr = env
await _feed(sch, 10_000_000, 6_000_000)
await _feed(sch, 9_500_000, 5_500_000) # 실제 -5% (유가 손실)
await _feed(sch, 4_500_000, 5_500_000) # 현금 5M 출금
dd = (4_500_000 - mgr.peak_balance) / mgr.peak_balance * 100
assert dd == pytest.approx(-5.0, abs=1e-6)
assert mgr.reduction_applied is None
# ── 2. 입금이 진행 중인 MDD 정지를 해제하지 않는다 ─────────────────────────────
@pytest.mark.asyncio
async def test_deposit_does_not_clear_active_pause(env):
sch, mgr = env
mgr.peak_balance = 10_000_000.0
mgr.reduction_applied = "10pct"
mgr.is_paused = True
sch._mdd_gate_state["prev"] = {"total": 8_900_000.0, "scts": 6_000_000.0}
r = await _feed(sch, 11_900_000, 6_000_000) # 3M 입금 → 원값이면 peak 초과
assert r["status"] == "applied"
assert mgr.reduction_applied == "10pct" and mgr.is_paused
assert mgr.peak_balance == pytest.approx(10_000_000 * 11_900_000 / 8_900_000)
# ── 3. 매매(현금↔유가 동반 이동)는 입출금으로 보지 않는다 ─────────────────────
@pytest.mark.asyncio
async def test_buy_trade_not_treated_as_cash_flow(env):
sch, mgr = env
await _feed(sch, 10_000_000, 6_000_000)
await _feed(sch, 9_998_000, 6_598_000) # 60만원 매수(수수료 2천)
assert sch._mdd_gate_state["last_flow"] is None
assert mgr.peak_balance == 10_000_000
@pytest.mark.asyncio
async def test_real_securities_loss_not_hidden_as_cash_flow(env):
"""유가평가 급락은 입출금으로 숨기지 않고 확인 후 축소한다(안전 방향)."""
sch, mgr = env
await _feed(sch, 10_000_000, 6_000_000)
r1 = await _feed(sch, 8_800_000, 4_800_000) # 유가 -1.2M → -12%
assert sch._mdd_gate_state["last_flow"] is None
assert r1["status"] == "pending"
assert mgr.reduction_applied is None # 1회 관측으로는 전이하지 않음
r2 = await _feed(sch, 8_800_000, 4_800_000)
assert r2["status"] == "applied"
assert r2["result"]["action"] == "REDUCE_AND_PAUSE"
assert mgr.reduction_applied == "10pct" and mgr.is_paused
# ── 4. 단일 사이클 급락은 확인 전까지 반영하지 않는다 ─────────────────────────
@pytest.mark.asyncio
async def test_single_cycle_blip_is_not_applied(env):
sch, mgr = env
await _feed(sch, 10_000_000, 6_000_000)
low = 10_000_000 * _pause_ratio() - 100_000
r = await _feed(sch, low, low - 4_000_000)
assert r["status"] == "pending"
r = await _feed(sch, 9_900_000, 5_900_000) # 다음 사이클 정상 복귀
assert r["status"] == "applied"
assert sch._mdd_gate_state["pending"] == 0
assert mgr.reduction_applied is None and not mgr.is_paused
@pytest.mark.asyncio
async def test_confirm_cycles_setting_respected(env, monkeypatch):
sch, mgr = env
monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_CONFIRM_CYCLES", 3) # config 키 미편입 — 기본값 경로
await _feed(sch, 10_000_000, 6_000_000)
for _ in range(2):
assert (await _feed(sch, 8_500_000, 4_500_000))["status"] == "pending"
assert (await _feed(sch, 8_500_000, 4_500_000))["status"] == "applied"
assert mgr.reduction_applied == "10pct"
# ── 5. 부분응답(보유 평가 0) 교차검증 ─────────────────────────────────────────
@pytest.mark.asyncio
async def test_partial_response_raises_and_keeps_state(env):
sch, mgr = env
held = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "6000000", "prpr": "600000"}]
await _feed(sch, 10_000_000, 6_000_000, held)
broken = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "0", "prpr": "0"}]
with pytest.raises(ValueError, match="부분응답"):
await _feed(sch, 4_000_000, 0, broken)
assert mgr.peak_balance == 10_000_000
assert mgr.reduction_applied is None
assert sch._mdd_gate_state["prev"]["total"] == 10_000_000 # 부분응답은 기준값으로 쓰지 않음
@pytest.mark.asyncio
async def test_persistent_partial_response_eventually_applied(env, monkeypatch):
"""의심이 상한만큼 연속이면 실제 낙폭을 무기한 억제하지 않는다(§5.3)."""
sch, mgr = env
monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_SUSPECT_MAX_HOLDS", 2)
monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_CONFIRM_CYCLES", 1) # config 키 미편입 — 기본값 경로
mgr.peak_balance = 10_000_000.0
broken = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "0", "prpr": "0"}]
with pytest.raises(ValueError):
await _feed(sch, 4_000_000, 0, broken)
r = await _feed(sch, 4_000_000, 0, broken)
assert r["status"] == "applied"
assert mgr.reduction_applied == "20pct"
@pytest.mark.asyncio
async def test_missing_eval_fields_do_not_block_real_mdd(env, monkeypatch):
"""필드가 없으면 의심으로 보지 않는다 — 필드명 불일치로 실제 MDD 를 막지 않게."""
sch, mgr = env
monkeypatch.setattr(sch, "_DEFAULT_MDD_STAGE_CONFIRM_CYCLES", 1) # config 키 미편입 — 기본값 경로
mgr.peak_balance = 10_000_000.0
held = [{"pdno": "005930", "hldg_qty": "10"}]
r = await _feed(sch, 8_500_000, 4_500_000, held)
assert r["status"] == "applied"
assert mgr.reduction_applied == "10pct"
@pytest.mark.asyncio
async def test_eval_sum_mismatch_is_suspect(env):
sch, mgr = env
mgr.peak_balance = 10_000_000.0
held = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "6000000", "prpr": "600000"}]
with pytest.raises(ValueError, match="평가합"):
await _feed(sch, 8_000_000, 4_000_000, held) # scts 4M < 보유 평가합 6M
assert mgr.reduction_applied is None
# ── 6. 스케줄러 루프 배선 ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_loop_pending_blocks_entry_without_reduction(monkeypatch, env):
"""확인 대기 사이클: update 미호출 + 축소 미실행 + 신규 진입 차단."""
sch, mgr = env
from test_swing_scheduler_hardening_r1 import _loop_env, _one_cycle
resp = _resp(8_500_000, 4_500_000)
sch2, _api, calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=resp))
# _loop_env 가 싱글톤 상태를 0 으로 고정하므로 그 뒤에 시나리오 상태를 세팅
monkeypatch.setattr(sch2.mdd_manager, "peak_balance", 10_000_000.0)
monkeypatch.setattr(sch2, "_mdd_gate_state", {
"prev": {"total": 10_000_000.0, "scts": 6_000_000.0}, "pending": 0, "suspect": 0, "last_flow": None,
})
reduce = AsyncMock(side_effect=AssertionError("확인 전 축소 금지"))
monkeypatch.setattr(sch2.mdd_manager, "execute_reduction", reduce)
await _one_cycle(sch2)
sch2.mdd_manager.update.assert_not_awaited()
reduce.assert_not_awaited()
assert calls["entry"] == 0
assert sch2._mdd_gate_state["pending"] == 1
@pytest.mark.asyncio
async def test_loop_partial_response_counts_as_update_failure(monkeypatch, env):
sch, mgr = env
from test_swing_scheduler_hardening_r1 import _loop_env, _one_cycle
broken = [{"pdno": "005930", "hldg_qty": "10", "evlu_amt": "0", "prpr": "0"}]
sch2, _api, _calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=_resp(4_000_000, 0, broken)))
monkeypatch.setattr(sch2.mdd_manager, "peak_balance", 10_000_000.0)
await _one_cycle(sch2)
sch2.mdd_manager.record_update_failure.assert_awaited_once()
sch2.mdd_manager.update.assert_not_awaited()