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:
@@ -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"]:
|
||||
|
||||
61
backend/tests/test_main_kis_stream_regs_20261012.py
Normal file
61
backend/tests/test_main_kis_stream_regs_20261012.py
Normal 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
|
||||
Reference in New Issue
Block a user