"""schedulers.py 하드닝 R4 — 매수 0건 조기 탐지(퍼널 집계 / 0건 경보 판정).""" import asyncio from datetime import date, datetime import pytest import pytz from app.services import schedulers as S KST = pytz.timezone("Asia/Seoul") WED = date(2026, 10, 7) def _at(d, h, m=0): return KST.localize(datetime(d.year, d.month, d.day, h, m)) @pytest.fixture(autouse=True) def _reset(monkeypatch): S._ENTRY_FUNNEL.update(day=None, blocks={}, attempts=0, last_alert_day=None) monkeypatch.setattr(S, "now_kst", lambda: _at(WED, 11)) def test_funnel_top_sorted_and_daily_reset(monkeypatch): for g, n in (("mdd", 1), ("ml_hold", 5), ("circuit", 3)): S.record_entry_gate_block_v2(g, n) S.record_entry_attempt_v2() f = S.get_entry_funnel_v2(2) assert f["blocks_top"] == [("ml_hold", 5), ("circuit", 3)] and f["attempts"] == 1 monkeypatch.setattr(S, "now_kst", lambda: _at(date(2026, 10, 8), 10)) assert S.get_entry_funnel_v2()["blocks_top"] == [] S.record_entry_gate_block_v2("mdd") assert S._ENTRY_FUNNEL["blocks"] == {"mdd": 1} def test_gate_cardinality_capped(): for i in range(S._ENTRY_FUNNEL_MAX_GATES + 10): S.record_entry_gate_block_v2(f"g{i}") assert len(S._ENTRY_FUNNEL["blocks"]) <= S._ENTRY_FUNNEL_MAX_GATES + 1 def test_record_never_raises(monkeypatch): def boom(): raise RuntimeError("x") monkeypatch.setattr(S, "now_kst", boom) S.record_entry_gate_block_v2("a") S.record_entry_attempt_v2() def test_zero_buy_decide(): h = S._zero_buy_alert_hours_v2() ok = _at(WED, 13) assert S._zero_buy_decide_v2(ok, 0, "bull", None, h) assert not S._zero_buy_decide_v2(ok, 1, "bull", None, h) assert not S._zero_buy_decide_v2(ok, 0, "bear", None, h) assert not S._zero_buy_decide_v2(ok, 0, "bull", WED, h) assert not S._zero_buy_decide_v2(_at(WED, 10), 0, "bull", None, h) assert not S._zero_buy_decide_v2(_at(WED, 16), 0, "bull", None, h) assert not S._zero_buy_decide_v2(_at(date(2026, 10, 10), 13), 0, "bull", None, h) def test_alert_hours_uses_settings(monkeypatch): from types import SimpleNamespace monkeypatch.setattr(S, "settings", SimpleNamespace(ZERO_BUY_ALERT_HOURS=1.5)) assert S._zero_buy_alert_hours_v2() == 1.5 monkeypatch.setattr(S, "settings", SimpleNamespace(ZERO_BUY_ALERT_HOURS=-1)) assert S._zero_buy_alert_hours_v2() == 3.0 def test_count_today_buys_groups_and_defaults_swing(): class R: def all(self): return [(None, 2), ("daytrading", 3)] class DB: async def execute(self, q): return R() out = asyncio.run(S._count_today_buys_v2(DB())) assert out == {"swing": 2, "daytrading": 3} def test_format_includes_buys_and_top(): S.record_entry_gate_block_v2("ml_hold", 4) t = S._format_funnel_v2({"swing": 0}) assert "BUY 0건" in t and "ml_hold" in t