"""하드닝 R1 — 스윙 스케줄러 단계 상한·청산 판정 하트비트·텔레그램 경보 회귀 테스트. 결함: 1. 장중 청산 판정이 N분간 완료되지 않아도 경보 없음 (2026-05-15~09-21 휴면 중 손절 방치) 2. 스윙 루프 단계에 타임아웃 없음 → KIS 무응답 시 청산·MDD 루프 전체 정지 (DT 는 _guard 존재) 3. MDD fail-safe 매수 차단·청산 체크 반복 실패가 로그로만 남음 (텔레그램 없음) 목은 공통 종단(check_and_execute_exit_v2 / mdd_manager.execute_reduction / korea_investment_api.get_balance / telegram_notifier.notify_system_warning)에 건다. '멈춤'은 끝나지 않는 Event 대기로, 실패는 예외 발생형 목으로 재현한다. """ import asyncio from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest from app.core.config import settings _real_sleep = asyncio.sleep class _StopLoop(BaseException): pass class _SessionCM: def __init__(self, db=None): self.db = db if db is not None else MagicMock() async def __aenter__(self): return self.db async def __aexit__(self, *a): return False @pytest.fixture(autouse=True) def _reset_state(monkeypatch): import app.services.swing.scheduler as sch import app.services.telegram_notifier as tn sch._exit_health.update({ "last_success_at": None, "market_open_since": None, "consecutive_failures": 0, "last_error": None, "timeouts": 0, }) for t in list(sch._inflight_steps.values()): if not t.done(): t.cancel() sch._inflight_steps.clear() sch._alert_sent_at.clear() sch._step_timeouts.clear() sch._blind_prev["active"] = False sch._telegram_state["enabled"] = None notify = AsyncMock(return_value=True) # 실전송 차단: 예외 발생형(목킹 누락 시 즉시 터짐) + 전송 공통 종단은 성공 목 monkeypatch.setattr(tn.telegram_notifier, "notify_system_warning", AsyncMock(side_effect=AssertionError("R3 이후 경로 아님"))) monkeypatch.setattr(tn.telegram_notifier, "send_message", AsyncMock(side_effect=AssertionError("텔레그램 실전송 금지"))) monkeypatch.setattr(sch, "_send_system_warning_checked_v2", notify) monkeypatch.setattr(sch, "_inflight_meta", {}) monkeypatch.setattr(sch, "_hard_deadline_stats", {"cancels": 0, "exceeded": {}}) monkeypatch.setattr(sch, "_telegram_send_health", { "sent": 0, "failures": 0, "consecutive_failures": 0, "last_success_at": None, "last_failure_at": None, "last_error": None, }) monkeypatch.setattr(sch, "_mdd_knob_state", {"mismatches": []}) yield SimpleNamespace(sch=sch, notify=notify) for t in list(sch._inflight_steps.values()): if not t.done(): t.cancel() sch._inflight_steps.clear() async def _drain(): for _ in range(5): await _real_sleep(0) def _messages(notify): return [c.kwargs.get("message", "") for c in notify.await_args_list] def _loop_env(monkeypatch, *, exit_mock=None, balance=None, can_open=True): """swing_scheduler 1사이클 실행 환경. sleep(>=60) 에서 루프를 끊는다.""" import app.services.swing.scheduler as sch import app.services.korea_investment_api as kia import app.api.endpoints.recommendations as rec_mod import app.api.endpoints.mcp as mcp_mod import app.core.redis as redis_mod import app.services.swing.fill_sweep as fs calls = {"entry": 0} async def _sleep(sec): if sec >= 60: raise _StopLoop() await _real_sleep(0) monkeypatch.setattr(sch.asyncio, "sleep", _sleep) monkeypatch.setattr(sch, "is_market_open", lambda: True) monkeypatch.setattr(sch, "check_and_execute_exit_v2", exit_mock or AsyncMock(return_value=[])) monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM()) monkeypatch.setattr(sch, "_check_mdd_threshold_knobs_v2", AsyncMock(return_value=[])) monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0)) api = MagicMock() api.get_balance = balance or AsyncMock(return_value={"output2": [{"tot_evlu_amt": "10000000"}]}) monkeypatch.setattr(kia, "korea_investment_api", api) monkeypatch.setattr(sch.mdd_manager, "update", AsyncMock(return_value={"action": "NONE"})) # [R2] MDD 입력 가드가 실제 peak/단계를 읽는다 — 앞선 테스트의 싱글톤 상태가 새지 않게 고정 monkeypatch.setattr(sch.mdd_manager, "peak_balance", 0.0) monkeypatch.setattr(sch.mdd_manager, "reduction_applied", None) monkeypatch.setattr(sch, "_mdd_gate_state", {"prev": None, "pending": 0, "suspect": 0, "last_flow": None}) monkeypatch.setattr(sch.mdd_manager, "record_update_success", AsyncMock()) monkeypatch.setattr(sch.mdd_manager, "record_update_failure", AsyncMock()) monkeypatch.setattr(sch.mdd_manager, "can_open_new_position", AsyncMock(return_value=can_open)) monkeypatch.setattr(sch.mdd_manager, "get_update_health", lambda: {"blind_block_active": False}) monkeypatch.setattr(settings, "AUTO_TRADE_ENABLED", True) monkeypatch.setattr(rec_mod, "get_rule_based_recommendations_from_db", AsyncMock(return_value={})) monkeypatch.setattr(mcp_mod, "request_mcp_risk_assessment", AsyncMock(return_value=(True, ""))) monkeypatch.setattr(redis_mod, "get_json", AsyncMock(return_value=None)) async def _entry(*a, **k): calls["entry"] += 1 monkeypatch.setattr(sch, "_run_entry_phase_v2", _entry) return sch, api, calls async def _one_cycle(sch): with pytest.raises(_StopLoop): await sch.swing_scheduler() # ═══════════════════════════════════════════════════════════════════════════ # 결함 2 — 단계 상한 # ═══════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_hung_exit_check_does_not_freeze_loop_and_is_not_cancelled(monkeypatch, _reset_state): """청산 판정이 멈춰도 루프는 MDD·진입 단계로 진행하고, 판정 작업은 취소하지 않는다.""" started = asyncio.Event() never = asyncio.Event() async def _hang(db): started.set() await never.wait() exit_mock = AsyncMock(side_effect=_hang) sch, api, calls = _loop_env(monkeypatch, exit_mock=exit_mock) monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.05) await _one_cycle(sch) await _drain() assert started.is_set() api.get_balance.assert_awaited() # MDD 단계 도달 assert calls["entry"] == 1 # 진입 단계 도달 task = sch._inflight_steps["청산 판정"] assert not task.done() # 취소하지 않음(매도 이중 실행 방지) assert sch._exit_health["timeouts"] == 1 assert sch._step_timeouts["청산 판정"] == 1 assert any("청산 판정" in m and "초과" in m for m in _messages(_reset_state.notify)) @pytest.mark.asyncio async def test_hung_exit_check_not_restarted_while_inflight(monkeypatch, _reset_state): """상한 초과로 남은 청산 판정이 끝나기 전에는 새 판정을 시작하지 않는다(과다매도 방지).""" never = asyncio.Event() async def _hang(db): await never.wait() exit_mock = AsyncMock(side_effect=_hang) sch, _api, _calls = _loop_env(monkeypatch, exit_mock=exit_mock) monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.05) await _one_cycle(sch) await _one_cycle(sch) await _drain() assert exit_mock.await_count == 1 assert sch._exit_health["consecutive_failures"] == 2 never.set() await _drain() # 백그라운드 판정이 끝나면 스스로 성공을 기록한다 assert sch._exit_health["consecutive_failures"] == 0 assert sch._exit_health["last_success_at"] is not None @pytest.mark.asyncio async def test_hung_balance_counts_as_mdd_update_failure(monkeypatch, _reset_state): """get_balance 무응답은 취소되고 MDD 갱신 실패로 계수된다(fail-safe 경로 유지).""" never = asyncio.Event() async def _hang(): await never.wait() sch, api, calls = _loop_env(monkeypatch, balance=AsyncMock(side_effect=_hang)) monkeypatch.setattr(sch, "_DEFAULT_BALANCE_TIMEOUT_SEC", 0.05) await _one_cycle(sch) sch.mdd_manager.record_update_failure.assert_awaited_once() sch.mdd_manager.update.assert_not_awaited() assert calls["entry"] == 1 @pytest.mark.asyncio async def test_hung_mdd_reduction_is_detached_and_not_duplicated(monkeypatch, _reset_state): """MDD 축소가 멈추면 취소하지 않고 남겨 두며, 끝날 때까지 새 축소를 시작하지 않는다.""" never = asyncio.Event() async def _hang(action): await never.wait() sch, _api, calls = _loop_env(monkeypatch, can_open=False) reduce_mock = AsyncMock(side_effect=_hang) monkeypatch.setattr(sch.mdd_manager, "execute_reduction", reduce_mock) monkeypatch.setattr(sch.mdd_manager, "reduction_applied", "10pct") monkeypatch.setattr(sch, "_DEFAULT_MDD_REDUCTION_TIMEOUT_SEC", 0.05) await _one_cycle(sch) await _one_cycle(sch) await _drain() assert reduce_mock.await_count == 1 assert not sch._inflight_steps["MDD 축소"].done() assert calls["entry"] == 0 # MDD 차단 중 진입 없음 msgs = _messages(_reset_state.notify) assert any("MDD 축소" in m and "초과" in m for m in msgs) assert any("MDD 축소" in m and "진행 중" in m for m in msgs) @pytest.mark.asyncio async def test_hung_entry_phase_lets_next_cycle_run_exit(monkeypatch, _reset_state): """진입 단계가 멈춰도 다음 사이클 청산 판정이 돈다. 진입은 중복 시작하지 않는다.""" import app.services.swing.scheduler as sch_mod never = asyncio.Event() entry_calls = {"n": 0} async def _hang_entry(*a, **k): entry_calls["n"] += 1 await never.wait() exit_mock = AsyncMock(return_value=[]) sch, _api, _calls = _loop_env(monkeypatch, exit_mock=exit_mock) monkeypatch.setattr(sch_mod, "_run_entry_phase_v2", _hang_entry) monkeypatch.setattr(sch, "_DEFAULT_ENTRY_PHASE_TIMEOUT_SEC", 0.05) await _one_cycle(sch) await _one_cycle(sch) assert exit_mock.await_count == 2 assert entry_calls["n"] == 1 assert not sch._inflight_steps["진입"].done() @pytest.mark.asyncio async def test_guard_detached_propagates_step_exception(_reset_state): sch = _reset_state.sch async def _boom(): raise RuntimeError("x") with pytest.raises(RuntimeError): await sch._guard_detached_v2(_boom, "t", 1.0) assert "t" not in sch._inflight_steps @pytest.fixture def _real_settings_env(monkeypatch, tmp_path): """[R4] 실제 settings 인스턴스 기준. env_file 은 빈 임시 파일로 고정하고 캐시를 비운다.""" import app.services.swing.scheduler as sch from app.core.config import settings as real_settings monkeypatch.setattr(sch, "settings", real_settings) env_file = tmp_path / ".env" env_file.write_text("", encoding="utf-8") monkeypatch.setitem(type(real_settings).model_config, "env_file", str(env_file)) monkeypatch.setattr(sch, "_env_file_cache", {}) monkeypatch.setattr(sch, "_undeclared_env_warned", set()) for k in ("SWING_EXIT_STEP_TIMEOUT_SEC", "MDD_STAGE_CONFIRM_CYCLES", "MDD_BLIND_FAILURE_THRESHOLD"): monkeypatch.delenv(k, raising=False) return SimpleNamespace(sch=sch, settings=real_settings, env_file=env_file) def test_cfg_float_env_override_applies_on_real_settings(monkeypatch, _real_settings_env, caplog): """[R4] 미선언 키도 .env/compose 오버라이드가 실제로 적용돼야 한다(조용히 무시 금지).""" sch, st = _real_settings_env.sch, _real_settings_env.settings # 전제: Settings(extra=ignore)에 미선언 → getattr 만으로는 늘 기본값이었다 assert getattr(st, "SWING_EXIT_STEP_TIMEOUT_SEC", None) is None assert sch._cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", 180.0) == 180.0 monkeypatch.setenv("SWING_EXIT_STEP_TIMEOUT_SEC", "42") with caplog.at_level("WARNING"): assert sch._cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", 180.0) == 42.0 assert any("미선언" in r.getMessage() for r in caplog.records) # 0/음수/문자열은 상한을 끄지 않고 기본값으로 for bad in ("0", "-1", "abc"): monkeypatch.setenv("SWING_EXIT_STEP_TIMEOUT_SEC", bad) assert sch._cfg_float_v2("SWING_EXIT_STEP_TIMEOUT_SEC", 180.0) == 180.0 def test_cfg_int_env_file_override_applies_on_real_settings(_real_settings_env): """[R4] Settings 의 env_file(.env) 에만 있는 미선언 키도 적용된다.""" sch, st = _real_settings_env.sch, _real_settings_env.settings assert getattr(st, "MDD_STAGE_CONFIRM_CYCLES", None) is None _real_settings_env.env_file.write_text("MDD_STAGE_CONFIRM_CYCLES=4\n", encoding="utf-8") assert sch._cfg_int_v2("MDD_STAGE_CONFIRM_CYCLES", sch._DEFAULT_MDD_STAGE_CONFIRM_CYCLES) == 4 # minimum 미만은 기본값 sch._env_file_cache.clear() _real_settings_env.env_file.write_text("MDD_STAGE_CONFIRM_CYCLES=0\n", encoding="utf-8") assert (sch._cfg_int_v2("MDD_STAGE_CONFIRM_CYCLES", sch._DEFAULT_MDD_STAGE_CONFIRM_CYCLES) == sch._DEFAULT_MDD_STAGE_CONFIRM_CYCLES) def test_cfg_declared_settings_attr_wins_over_env(monkeypatch, _real_settings_env): """[R4] config.py 에 선언된 키는 settings 속성이 1순위(환경변수 직접 읽기는 미선언 키 한정).""" sch, st = _real_settings_env.sch, _real_settings_env.settings monkeypatch.setattr(st, "MDD_BLIND_FAILURE_THRESHOLD", 7) monkeypatch.setenv("MDD_BLIND_FAILURE_THRESHOLD", "99") assert sch._cfg_int_v2("MDD_BLIND_FAILURE_THRESHOLD", 5) == 7 # ═══════════════════════════════════════════════════════════════════════════ # 결함 1 — 청산 판정 하트비트 # ═══════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_exit_stale_alert_during_market_hours(_reset_state): sch = _reset_state.sch limit = sch._DEFAULT_EXIT_STALE_ALERT_SEC sch._note_market_open_v2(1000.0) assert sch._check_exit_staleness_v2(1000.0 + limit - 1) is False assert sch._check_exit_staleness_v2(1000.0 + limit + 1) is True await _drain() assert any("청산 판정" in m and "완료되지 않음" in m for m in _messages(_reset_state.notify)) # 재경보는 throttle assert sch._check_exit_staleness_v2(1000.0 + limit + 2) is True await _drain() assert _reset_state.notify.await_count == 1 @pytest.mark.asyncio async def test_exit_stale_ignores_overnight_gap(_reset_state): """전일 마지막 성공 이후 밤사이 공백은 세지 않는다 — 기준은 이번 장중 첫 관측.""" sch = _reset_state.sch sch._exit_health["last_success_at"] = 0.0 # 아주 오래 전(전일) sch._note_market_closed_v2() sch._note_market_open_v2(100000.0) assert sch._check_exit_staleness_v2(100000.0 + 60) is False h = sch.get_swing_scheduler_health_v2() assert "exit_stale" in h and "exit_unjudged_market_sec" in h def test_exit_stale_not_checked_when_market_closed(_reset_state): sch = _reset_state.sch sch._note_market_closed_v2() assert sch._check_exit_staleness_v2(10 ** 9) is False @pytest.mark.asyncio async def test_cycle_and_exit_heartbeats_recorded(monkeypatch, _reset_state): from app.core import heartbeat as hb hb.reset() sch, _api, _calls = _loop_env(monkeypatch) await _one_cycle(sch) assert hb.age_seconds("swing") is not None assert hb.age_seconds("swing_exit") is not None assert sch._exit_health["last_success_at"] is not None hb.reset() @pytest.mark.asyncio async def test_exit_failure_does_not_beat_exit_heartbeat(monkeypatch, _reset_state): from app.core import heartbeat as hb hb.reset() sch, _api, _calls = _loop_env(monkeypatch, exit_mock=AsyncMock(side_effect=RuntimeError("kis down"))) await _one_cycle(sch) assert hb.age_seconds("swing") is not None assert hb.age_seconds("swing_exit") is None assert sch._exit_health["consecutive_failures"] == 1 hb.reset() # ═══════════════════════════════════════════════════════════════════════════ # 결함 3 — 텔레그램 경보 # ═══════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_exit_consecutive_failures_alert_at_threshold_then_recovery(monkeypatch, _reset_state): import app.services.swing.fill_sweep as fs sch = _reset_state.sch monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM()) monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(return_value=0)) monkeypatch.setattr(sch, "check_and_execute_exit_v2", AsyncMock(side_effect=RuntimeError("boom"))) threshold = sch._DEFAULT_EXIT_FAIL_ALERT_COUNT for _ in range(threshold - 1): await sch._run_exit_phase_v2() await _drain() assert _reset_state.notify.await_count == 0 await sch._run_exit_phase_v2() await _drain() assert _reset_state.notify.await_count == 1 assert "연속 실패" in _messages(_reset_state.notify)[0] # 복구 시 1회 알림 + 카운터 리셋 monkeypatch.setattr(sch, "check_and_execute_exit_v2", AsyncMock(return_value=[])) await sch._run_exit_phase_v2() await _drain() assert sch._exit_health["consecutive_failures"] == 0 assert _reset_state.notify.await_count == 2 assert "복구" in _messages(_reset_state.notify)[1] @pytest.mark.asyncio async def test_exit_failure_still_runs_fill_sweep(monkeypatch, _reset_state): """[R8] 청산 판정이 실패해도 스케줄러 루프의 별도 단계에서 sweep 은 돈다.""" import app.services.swing.fill_sweep as fs sch, _api, _calls = _loop_env( monkeypatch, exit_mock=AsyncMock(side_effect=RuntimeError("boom"))) sweep = AsyncMock(return_value=0) monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", sweep) await _one_cycle(sch) sweep.assert_awaited_once() assert sch._exit_health["consecutive_failures"] == 1 # ═══════════════════════════════════════════════════════════════════════════ # 하드닝 R8 — sweep 을 청산 판정의 비취소형 상한에서 분리 # ═══════════════════════════════════════════════════════════════════════════ @pytest.mark.asyncio async def test_r8_exit_phase_does_not_run_sweep(monkeypatch, _reset_state): """청산 판정 단계 자체는 sweep 을 호출하지 않는다(상한 공유 제거).""" import app.services.swing.fill_sweep as fs sch = _reset_state.sch monkeypatch.setattr(sch, "AsyncSessionLocal", lambda: _SessionCM()) monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(side_effect=AssertionError("청산 판정 안에서 sweep 금지"))) monkeypatch.setattr(sch, "check_and_execute_exit_v2", AsyncMock(return_value=[])) await sch._run_exit_phase_v2() assert sch._exit_health["consecutive_failures"] == 0 assert sch._exit_health["last_success_at"] is not None @pytest.mark.asyncio async def test_r8_slow_sweep_does_not_mark_exit_failed_or_skip_next_cycle(monkeypatch, _reset_state): """[재현 반전] 이전: 청산 판정 + sweep 합이 청산 상한을 넘으면 청산 성공인데도 _STEP_TIMED_OUT → 실패 집계, 다음 사이클 _STEP_SKIPPED → 또 실패 집계.""" import app.services.swing.fill_sweep as fs exit_mock = AsyncMock(return_value=[]) sch, _api, calls = _loop_env(monkeypatch, exit_mock=exit_mock) sweep_started = [] sweep_cancelled = [] never = asyncio.Event() async def _hang_sweep(db): sweep_started.append(1) try: await never.wait() except asyncio.CancelledError: sweep_cancelled.append(1) raise monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", _hang_sweep) # sweep 상한 < 청산 상한 < (청산 + sweep 상한) — 이전 구조에서 청산 상한 초과를 재현하는 구성 monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.1) monkeypatch.setattr(sch, "_DEFAULT_FILL_SWEEP_TIMEOUT_SEC", 0.15) await _one_cycle(sch) await _one_cycle(sch) await _drain() assert exit_mock.await_count == 2 # 두 번째 사이클도 생략되지 않음 assert sch._exit_health["consecutive_failures"] == 0 assert sch._exit_health["timeouts"] == 0 assert "청산 판정" not in sch._step_timeouts assert sch._step_timeouts.get("체결가 후속 보정") == 2 # sweep 만 취소형으로 상한 초과 assert len(sweep_started) == 2 and len(sweep_cancelled) == 2 assert calls["entry"] == 2 assert not any("연속 실패" in m for m in _messages(_reset_state.notify)) @pytest.mark.asyncio async def test_r8_sweep_skipped_while_exit_inflight(monkeypatch, _reset_state): """청산 판정이 상한 초과로 백그라운드 진행 중이면 sweep 은 이번 사이클 생략(동시 보정 방지).""" import app.services.swing.fill_sweep as fs never = asyncio.Event() async def _hang(db): await never.wait() sch, _api, _calls = _loop_env(monkeypatch, exit_mock=AsyncMock(side_effect=_hang)) sweep = AsyncMock(side_effect=AssertionError("청산 판정 진행 중 sweep 금지")) monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", sweep) monkeypatch.setattr(sch, "_DEFAULT_EXIT_STEP_TIMEOUT_SEC", 0.05) await _one_cycle(sch) sweep.assert_not_awaited() never.set() await _drain() @pytest.mark.asyncio async def test_r8_sweep_exception_does_not_count_as_exit_failure(monkeypatch, _reset_state): import app.services.swing.fill_sweep as fs sch, _api, calls = _loop_env(monkeypatch) monkeypatch.setattr(fs, "sweep_unconfirmed_fills_v2", AsyncMock(side_effect=RuntimeError("db down"))) await _one_cycle(sch) assert sch._exit_health["consecutive_failures"] == 0 assert sch._exit_health["last_success_at"] is not None assert calls["entry"] == 1 @pytest.mark.asyncio async def test_mdd_blind_block_alert_on_enter_and_release(monkeypatch, _reset_state): sch = _reset_state.sch state = {"h": {"blind_block_active": True, "consecutive_failures": 5, "last_error": "KIS"}} monkeypatch.setattr(sch.mdd_manager, "get_update_health", lambda: state["h"]) sch._check_blind_block_transition_v2() sch._check_blind_block_transition_v2() # 유지 중에는 재전송 없음 await _drain() assert _reset_state.notify.await_count == 1 assert "신규 매수 전면 차단" in _messages(_reset_state.notify)[0] state["h"] = {"blind_block_active": False} sch._check_blind_block_transition_v2() await _drain() assert _reset_state.notify.await_count == 2 assert "해제" in _messages(_reset_state.notify)[1] @pytest.mark.asyncio async def test_blind_block_alert_fires_from_scheduler_loop(monkeypatch, _reset_state): sch, _api, _calls = _loop_env(monkeypatch, can_open=False) monkeypatch.setattr(sch.mdd_manager, "get_update_health", lambda: {"blind_block_active": True, "consecutive_failures": 5, "last_error": "e"}) monkeypatch.setattr(sch.mdd_manager, "reduction_applied", None) await _one_cycle(sch) await _drain() assert any("신규 매수 전면 차단" in m for m in _messages(_reset_state.notify)) @pytest.mark.asyncio async def test_telegram_send_failure_does_not_break_loop(monkeypatch, _reset_state): import app.services.telegram_notifier as tn sch = _reset_state.sch _reset_state.notify.side_effect = RuntimeError("tg down") monkeypatch.setattr(sch, "_DEFAULT_ALERT_RETRY_BACKOFF_SEC", 0.001) assert sch._notify_throttled_v2("k", "C", "m") is True for _ in range(20): await _real_sleep(0.005) # 백그라운드 예외는 실패 누계로 흡수(루프 비차단) assert sch._telegram_send_health["failures"] == 1 def test_telegram_unconfigured_logged_and_exposed(monkeypatch, _reset_state, caplog): sch = _reset_state.sch import app.services.telegram_notifier as tn monkeypatch.setattr(tn, "_is_enabled", lambda: False) with caplog.at_level("ERROR"): assert sch._check_telegram_configured_v2() is False assert any("텔레그램 미설정" in r.getMessage() for r in caplog.records) assert sch.get_swing_scheduler_health_v2()["telegram_enabled"] is False # ═══════════════════════════════════════════════════════════════════════════ # 하드닝 라운드1 — 운용 중 계좌의 tot_evlu_amt<=0 은 '빈 계좌'가 아니라 갱신 실패 # ═══════════════════════════════════════════════════════════════════════════ _ZERO_BALANCE = {"output2": [{"tot_evlu_amt": "0"}]} @pytest.mark.asyncio @pytest.mark.parametrize("peak,last", [(50_000_000.0, 0.0), (0.0, 30_000_000.0)]) async def test_zero_total_with_known_balance_is_update_failure(monkeypatch, _reset_state, peak, last): """peak 또는 마지막 잔고가 >0 인데 0 응답 → record_update_failure, 빈 계좌 성공 기록 금지.""" sch, api, calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=_ZERO_BALANCE)) monkeypatch.setattr(sch.mdd_manager, "peak_balance", peak) monkeypatch.setattr(sch.mdd_manager, "_last_balance", last) await _one_cycle(sch) sch.mdd_manager.record_update_failure.assert_awaited_once() sch.mdd_manager.record_update_success.assert_not_awaited() sch.mdd_manager.update.assert_not_awaited() @pytest.mark.asyncio async def test_zero_total_on_fresh_account_still_empty_account(monkeypatch, _reset_state): """자산 관측 이력이 없는(peak=0, last=0) 신규 계좌의 0 응답은 기존대로 빈 계좌 처리.""" sch, api, calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=_ZERO_BALANCE)) monkeypatch.setattr(sch.mdd_manager, "peak_balance", 0.0) monkeypatch.setattr(sch.mdd_manager, "_last_balance", 0.0) await _one_cycle(sch) sch.mdd_manager.record_update_success.assert_awaited_once_with(empty_account=True) sch.mdd_manager.record_update_failure.assert_not_awaited() @pytest.mark.asyncio async def test_repeated_zero_total_on_live_account_triggers_blind_block(monkeypatch, _reset_state): """실제 MDDManager 로: 운용 계좌에서 0 응답이 임계 횟수 반복되면 신규 진입이 차단된다.""" from app.services.mdd_manager import MDDManager sch, api, calls = _loop_env(monkeypatch, balance=AsyncMock(return_value=_ZERO_BALANCE)) real = MDDManager() real.peak_balance = 50_000_000.0 monkeypatch.setattr(sch, "mdd_manager", real) monkeypatch.setattr(real, "update", AsyncMock(side_effect=AssertionError("0 응답으로 update 금지"))) monkeypatch.setattr(settings, "MDD_BLIND_BLOCK_ENTRY", True) threshold = max(1, int(settings.MDD_BLIND_FAILURE_THRESHOLD)) for _ in range(threshold): await _one_cycle(sch) health = real.get_update_health() assert health["empty_account"] is False assert health["consecutive_failures"] == threshold assert real._is_update_blind() is True