"""schedulers.py 하드닝 R1 — 마감 후 확정 사이클 결정 / KRX 일봉 완전성 점검.""" import asyncio from datetime import date, datetime, timedelta import pytest import pytz from app.services import schedulers as S KST = pytz.timezone("Asia/Seoul") def _at(d, h, m): return KST.localize(datetime(d.year, d.month, d.day, h, m)) WED = date(2026, 10, 7) # 수요일 (평일) SAT = date(2026, 10, 10) def test_final_cycle_waits_until_1535(): assert S._batch_final_cycle_decide_v2(_at(WED, 15, 31), None)[0] == "wait" def test_final_cycle_runs_after_1535_once(): assert S._batch_final_cycle_decide_v2(_at(WED, 15, 40), None)[0] == "final" assert S._batch_final_cycle_decide_v2(_at(WED, 15, 40), WED)[0] == "sleep_tomorrow" def test_final_cycle_gives_up_after_1800_and_weekend(): assert S._batch_final_cycle_decide_v2(_at(WED, 18, 0), None)[0] == "sleep_tomorrow" assert S._batch_final_cycle_decide_v2(_at(SAT, 15, 40), None)[0] == "sleep_tomorrow" def test_completeness_flags_partial_day_and_lag(): rows = {date(2026, 10, 5): 2560, date(2026, 10, 6): 1670, date(2026, 10, 7): 2560} r = S._evaluate_krx_completeness_v2(rows, date(2026, 10, 8)) assert not r["ok"] assert any("부분 수집" in i for i in r["issues"]) assert any("기대" in i for i in r["issues"]) def test_completeness_flags_missing_trading_day(): rows = {date(2026, 10, 5): 2560, date(2026, 10, 7): 2560} r = S._evaluate_krx_completeness_v2(rows, date(2026, 10, 7)) assert any("누락" in i for i in r["issues"]) def test_completeness_ok_and_empty(): rows = {date(2026, 10, 6): 2560, date(2026, 10, 7): 2550} assert S._evaluate_krx_completeness_v2(rows, date(2026, 10, 7))["ok"] assert not S._evaluate_krx_completeness_v2({}, WED)["ok"] def test_expected_date_skips_weekend(): assert S._expected_last_trade_date_v2(date(2026, 10, 19)) == date(2026, 10, 16) # 월 -> 직전 금 def test_collector_decide_morning_then_evening(): assert S._krx_collector_decide_v2(_at(WED, 9, 5), None, None)[0] == "morning" assert S._krx_collector_decide_v2(_at(WED, 9, 5), None, WED)[0] == "sleep" assert S._krx_collector_decide_v2(_at(WED, 15, 45), None, WED)[0] == "evening" a, sec = S._krx_collector_decide_v2(_at(WED, 15, 45), WED, WED) assert a == "sleep" and sec > 3600 * 10 # 익일 09:00 까지 assert S._krx_collector_decide_v2(_at(SAT, 10, 0), None, None)[0] == "sleep" def test_check_failure_is_not_ok_and_alerts(monkeypatch): async def boom(): raise RuntimeError("db down") sent = [] class _T: async def notify_system_warning(self, c, m): sent.append((c, m)) import app.services.telegram_notifier as tn monkeypatch.setattr(S, "_fetch_krx_row_counts_v2", boom) monkeypatch.setattr(tn, "telegram_notifier", _T()) r = asyncio.run(S._check_krx_data_v2(WED, alert=True)) assert r["ok"] is False assert S.get_krx_data_health_v2()["ok"] is False assert sent and sent[0][0] == "KRX_DATA" # ── [하드닝 R2] 배치 분석 타임아웃/경보/헬스 ────────────────────────────────── def test_batch_fetch_times_out_on_hang(): async def hang(): await asyncio.sleep(3600) with pytest.raises(asyncio.TimeoutError): asyncio.run(S._batch_fetch_v2(hang, timeout=0.05)) def test_batch_timeout_reads_settings(monkeypatch): import types monkeypatch.setattr(S, "settings", types.SimpleNamespace(BATCH_ANALYSIS_TIMEOUT_SEC=123)) assert S._batch_timeout_sec_v2() == 123.0 monkeypatch.setattr(S, "settings", types.SimpleNamespace(BATCH_ANALYSIS_TIMEOUT_SEC=-1)) assert S._batch_timeout_sec_v2() == 900.0 monkeypatch.setattr(S, "settings", types.SimpleNamespace()) assert S._batch_timeout_sec_v2() == 900.0 def test_batch_failure_alerts_then_throttles_and_ok_resets(monkeypatch): sent = [] class _T: async def notify_system_warning(self, c, m): sent.append((c, m)) import app.services.telegram_notifier as tn from app.core import heartbeat as hb monkeypatch.setattr(tn, "telegram_notifier", _T()) S._BATCH_HEALTH.update(consecutive_failed_cycles=0, last_error=None) hb.reset() asyncio.run(S._batch_cycle_failed_v2(RuntimeError("x"), 3)) asyncio.run(S._batch_cycle_failed_v2(RuntimeError("x"), 3)) assert len(sent) == 1 and sent[0][0] == "BATCH_ANALYSIS" h = S.get_batch_analysis_health_v2() assert h["consecutive_failed_cycles"] == 2 and "RuntimeError" in h["last_error"] S._batch_cycle_ok_v2(198) h = S.get_batch_analysis_health_v2() assert h["consecutive_failed_cycles"] == 0 and h["last_ok_count"] == 198 assert hb.age_seconds("batch_analysis") is not None hb.reset() def test_batch_alert_failure_does_not_raise(monkeypatch): class _T: async def notify_system_warning(self, c, m): raise RuntimeError("tg down") import app.services.telegram_notifier as tn monkeypatch.setattr(tn, "telegram_notifier", _T()) S._BATCH_HEALTH.update(consecutive_failed_cycles=0) asyncio.run(S._batch_cycle_failed_v2(RuntimeError("x"), 3)) S._BATCH_HEALTH.update(consecutive_failed_cycles=0) def test_completeness_flags_old_partial_day_beyond_five_days(): """5거래일 밖(과거) 부분 수집일도 경보 대상이어야 한다.""" rows = {date(2026, 9, 14) + timedelta(days=i): 2560 for i in range(0, 14) if (date(2026, 9, 14) + timedelta(days=i)).weekday() < 5} first = min(rows) rows[first] = 1672 last = max(rows) r = S._evaluate_krx_completeness_v2(rows, last) assert any(str(first) in i and "부분 수집" in i for i in r["issues"]) assert S._KRX_CHECK_RECENT >= S._KRX_COMPLETE_WINDOW def test_morning_expected_date_lags_one_more_trading_day(): thu = date(2026, 10, 8) assert S._expected_trade_date_lag_v2(thu, 1) == S._expected_last_trade_date_v2(thu) assert S._expected_trade_date_lag_v2(thu, S._KRX_MORNING_LAG) == date(2026, 10, 6) # T+1 미발행(직전 거래일 0행) 상태에서 아침 점검은 정상 rows = {date(2026, 10, 5): 2560, date(2026, 10, 6): 2560} r = S._evaluate_krx_completeness_v2(rows, S._expected_trade_date_lag_v2(thu, S._KRX_MORNING_LAG)) assert r["ok"] def test_weekly_rescan_due_only_monday_once(): mon, tue = date(2026, 10, 5), date(2026, 10, 6) assert S._krx_rescan_due_v2(mon, None) assert not S._krx_rescan_due_v2(mon, mon) assert not S._krx_rescan_due_v2(tue, None) def test_close_reconcile_rows_use_krx_close(): rows = [ {"stock_code": "006260", "stock_name": "LS", "analysis_date": WED, "current_price": 281000, "rsi": 50}, {"stock_code": "000001", "stock_name": "A", "analysis_date": WED, "current_price": 5000}, {"stock_code": "000002", "stock_name": "B", "analysis_date": WED, "current_price": 7000}, ] krx = {"006260": (280000, None, 100), "000001": (5000, None, None), "000002": (0, None, None)} out = S._build_close_reconcile_rows_v2(rows, krx) assert [r["stock_code"] for r in out] == ["006260"] assert out[0]["current_price"] == 280000 and out[0]["volume"] == 100 assert out[0]["rsi"] == 50 and "updated_at" in out[0] def test_reconcile_failure_does_not_enqueue_when_db_raises(monkeypatch): class Boom: async def __aenter__(self): raise RuntimeError("db blocked") async def __aexit__(self, *a): return False monkeypatch.setattr(S, "AsyncSessionLocal", lambda: Boom()) with pytest.raises(RuntimeError): asyncio.run(S._reconcile_prev_analysis_close_v2(WED)) # ── 라운드 7: 보정 건너뛰기 조건 / 과거 부분일 분리 / 휴장일 수집 ────────────────── def test_reconcile_v3_updates_volume_and_rate_when_price_equal(): rows = [ {"stock_code": "A", "current_price": 5000, "volume": 60, "change_rate": 1.0}, {"stock_code": "B", "current_price": 5000, "volume": 100, "change_rate": 2.0}, ] krx = {"A": (5000, 2.5, 100), "B": (5000, 2.0, 100)} out = S._build_close_reconcile_rows_v3(rows, krx) assert [r["stock_code"] for r in out] == ["A"] assert out[0]["volume"] == 100 and out[0]["change_rate"] == 2.5 def test_evaluator_splits_stale_from_fresh(): rows = {date(2026, 10, 5): 2560, date(2026, 10, 6): 1670, date(2026, 10, 7): 2560} r = S._evaluate_krx_completeness_v2(rows, date(2026, 10, 7)) assert not r["ok"] and r["fresh_ok"] and r["stale_issues"] r2 = S._evaluate_krx_completeness_v2({date(2026, 10, 6): 2560}, date(2026, 10, 7)) assert not r2["fresh_ok"] def test_stale_only_alert_sent_once_per_day(monkeypatch): counts = {date(2026, 10, 5): 2560, date(2026, 10, 6): 1670, date(2026, 10, 7): 2560} sent = [] async def fake(): return counts class N: async def notify_system_warning(self, k, m): sent.append(m) import app.services.telegram_notifier as T monkeypatch.setattr(S, "_fetch_krx_row_counts_v2", fake) monkeypatch.setattr(T, "telegram_notifier", N()) S._KRX_STALE_ALERT_STATE["day"] = None r = asyncio.run(S._check_krx_data_v2(date(2026, 10, 8), alert=False, lag=1, alert_stale=True)) asyncio.run(S._check_krx_data_v2(date(2026, 10, 8), alert=False, lag=1, alert_stale=True)) assert r["fresh_ok"] and len(sent) == 1 def test_behind_trading_days(): from app.core.market_schedule import is_krx_holiday exp = sum(1 for d in (date(2026, 10, 8), date(2026, 10, 9)) if not is_krx_holiday(d)) assert S._krx_behind_days_v2(date(2026, 10, 7), date(2026, 10, 12)) == exp assert S._krx_behind_days_v2(None, WED) is None def test_offday_collect_once_at_noon(): assert S._krx_collector_decide_v3(_at(SAT, 11, 0), None, None, None)[0] == "sleep" assert S._krx_collector_decide_v3(_at(SAT, 12, 5), None, None, None)[0] == "offday" assert S._krx_collector_decide_v3(_at(SAT, 12, 5), None, None, SAT)[0] == "sleep" assert S._krx_collector_decide_v3(_at(WED, 9, 5), None, None)[0] == "morning"