- 스윙 체결가 확인 전 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
624 lines
28 KiB
Python
624 lines
28 KiB
Python
"""하드닝 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
|