diff --git a/backend/app/main.py b/backend/app/main.py index dcc5dd0..5b385d6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -262,6 +262,52 @@ def _kis_stream_health_v2(application, now: float | None = None, market_open: bo return {"alive": alive, "last_msg_age": None if age is None else round(age, 1), "silent": silent} +# 등록 0건 → 1건 이상으로 바뀐 시각(monotonic). 재구독 직후 옛 last_msg 로 오경보하지 않게 기준점으로 쓴다. +_kis_stream_regs_since: float | None = None + + +def _kis_stream_registrations_v2() -> int | None: + try: + from app.services.kis_realtime_stream import kis_stream + return len(kis_stream._registered) + except Exception as _swallowed: + warn_throttled(logger, "main:ks_regs", "[health] KIS 등록 수 확인 실패: %s", _swallowed) + return None + + +def _kis_stream_health_v3(application, now: float | None = None, market_open: bool | None = None, + registrations: int | None = -1) -> dict | None: + """v2 + 등록 0건이면 무수신을 정상으로 본다(2026-10-12). + + 스트림은 대시보드 구독 종목만 KIS 에 등록한다(매매 경로는 스트림을 쓰지 않는다). + 브라우저가 닫혀 등록이 0건이면 수신이 없는 것이 정상인데, v2 는 이를 장중 무수신으로 + error·텔레그램 경보했다(10/12 12:54·13:29·13:46 — 전부 구독 해제 10분 뒤). + 태스크 사망은 등록 수와 무관하게 계속 silent 다. 등록 수를 알 수 없으면(None) v2 판정을 따른다. + """ + global _kis_stream_regs_since + base = _kis_stream_health_v2(application, now=now, market_open=market_open) + if base is None: + return None + regs = _kis_stream_registrations_v2() if registrations == -1 else registrations + n = now if now is not None else time.monotonic() + base["registrations"] = regs + if regs is None: + return base + if regs <= 0: + _kis_stream_regs_since = None + if base["alive"]: + base["silent"] = False + return base + if _kis_stream_regs_since is None: + _kis_stream_regs_since = n + if base["silent"] and base["alive"]: + last = _kis_stream_last_msg_mono + ref = max(last if last is not None else -1e18, _kis_stream_regs_since) + base["silent"] = (n - ref) > float(_HB_KIS_STREAM_SILENT_SECONDS) + base["last_msg_age"] = round(n - ref, 1) + return base + + def _db_worker_health_v2(application) -> dict: task = getattr(application.state, "db_worker_task", None) try: @@ -343,7 +389,7 @@ async def _heartbeat_watchdog_v2(application): _notify_system_warning_bg( "SCHEDULER", f"'{name}' 장중 사이클 정체 {info['last_cycle_age']}초 (alive={info['alive']})" ) - ks = _kis_stream_health_v2(application) + ks = _kis_stream_health_v3(application) if ks is None or not ks["silent"]: _stale_alerted_at.pop("kis_stream", None) elif now - _stale_alerted_at.get("kis_stream", -1e9) >= realert: @@ -1288,7 +1334,7 @@ def _runtime_health_v2(market_open: bool | None = None) -> tuple[dict, list]: detail["swing"] = "unavailable" reasons.append("swing_health_unavailable") try: - ks = _kis_stream_health_v2(app, market_open=market_open) + ks = _kis_stream_health_v3(app, market_open=market_open) if ks is not None: detail["kis_stream"] = ks if ks["silent"]: diff --git a/backend/tests/test_main_kis_stream_regs_20261012.py b/backend/tests/test_main_kis_stream_regs_20261012.py new file mode 100644 index 0000000..995e40f --- /dev/null +++ b/backend/tests/test_main_kis_stream_regs_20261012.py @@ -0,0 +1,61 @@ +"""KIS 스트림 무수신 경보: 등록 0건(대시보드 미접속)은 정상 — 2026-10-12 운영 오경보 3건.""" +import app.main as m + + +class _T: + def __init__(self, done): + self._d = done + + def done(self): + return self._d + + +class _App: + class state: + pass + + +def _app(done=False): + a = _App() + a.state = type("S", (), {})() + a.state.kis_stream_task = _T(done) + return a + + +def test_zero_registrations_is_not_silent(monkeypatch): + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + monkeypatch.setattr(m, "_kis_stream_regs_since", None) + limit = m._HB_KIS_STREAM_SILENT_SECONDS + r = m._kis_stream_health_v3(_app(), now=100.0 + limit + 1, market_open=True, registrations=0) + assert r["silent"] is False and r["registrations"] == 0 + + +def test_dead_task_is_silent_even_with_zero_registrations(monkeypatch): + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + monkeypatch.setattr(m, "_kis_stream_regs_since", None) + r = m._kis_stream_health_v3(_app(done=True), now=101.0, market_open=True, registrations=0) + assert r["silent"] is True + + +def test_resubscribe_uses_registration_time_as_reference(monkeypatch): + limit = m._HB_KIS_STREAM_SILENT_SECONDS + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + monkeypatch.setattr(m, "_kis_stream_regs_since", None) + t0 = 100.0 + limit + 500 # 옛 메시지 이후 오래 지나 재구독 + assert m._kis_stream_health_v3(_app(), now=t0, market_open=True, registrations=8)["silent"] is False + assert m._kis_stream_health_v3(_app(), now=t0 + limit - 1, market_open=True, registrations=8)["silent"] is False + assert m._kis_stream_health_v3(_app(), now=t0 + limit + 1, market_open=True, registrations=8)["silent"] is True + + +def test_registered_and_receiving_is_not_silent(monkeypatch): + limit = m._HB_KIS_STREAM_SILENT_SECONDS + monkeypatch.setattr(m, "_kis_stream_regs_since", 0.0) + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 1000.0) + assert m._kis_stream_health_v3(_app(), now=1000.0 + limit - 5, market_open=True, registrations=8)["silent"] is False + + +def test_unknown_registrations_falls_back_to_v2(monkeypatch): + limit = m._HB_KIS_STREAM_SILENT_SECONDS + monkeypatch.setattr(m, "_kis_stream_last_msg_mono", 100.0) + monkeypatch.setattr(m, "_kis_stream_regs_since", None) + assert m._kis_stream_health_v3(_app(), now=100.0 + limit + 1, market_open=True, registrations=None)["silent"] is True