Files
auto-stock/backend/tests/test_schedulers_hardening_r1.py
shuim a264561576 fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 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
2026-10-10 12:17:49 +09:00

247 lines
9.9 KiB
Python

"""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"