"""하드닝 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()