fix(health): KIS 스트림 무수신 경보를 등록 0건(대시보드 미접속)일 때 억제

스트림은 대시보드 구독 종목만 등록하므로 브라우저가 닫히면 수신 0이 정상이다.
10/12 장중 오경보 3건(12:54·13:29·13:46)이 모두 구독 해제 10분 뒤였다.
재구독 직후에는 등록 시각을 기준점으로 써 옛 last_msg 로 즉시 오경보하지 않게 했다.
태스크 사망은 등록 수와 무관하게 계속 경보한다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-12 13:52:01 +09:00
parent a264561576
commit 2e1a6e688c
2 changed files with 109 additions and 2 deletions

View File

@@ -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} 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: def _db_worker_health_v2(application) -> dict:
task = getattr(application.state, "db_worker_task", None) task = getattr(application.state, "db_worker_task", None)
try: try:
@@ -343,7 +389,7 @@ async def _heartbeat_watchdog_v2(application):
_notify_system_warning_bg( _notify_system_warning_bg(
"SCHEDULER", f"'{name}' 장중 사이클 정체 {info['last_cycle_age']}초 (alive={info['alive']})" "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"]: if ks is None or not ks["silent"]:
_stale_alerted_at.pop("kis_stream", None) _stale_alerted_at.pop("kis_stream", None)
elif now - _stale_alerted_at.get("kis_stream", -1e9) >= realert: 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" detail["swing"] = "unavailable"
reasons.append("swing_health_unavailable") reasons.append("swing_health_unavailable")
try: 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: if ks is not None:
detail["kis_stream"] = ks detail["kis_stream"] = ks
if ks["silent"]: if ks["silent"]:

View File

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