Files
kis_bot/kis_trader/network/ls_ws_validator.py
2026-07-30 18:05:07 +09:00

169 lines
5.5 KiB
Python

"""
kis_trader/network/ls_ws_validator.py — KIS/키움 ↔ LS 시세 갭 검증
================================================================
``LS_WS_VALIDATION_ENABLED=true`` 일 때만 기동.
실매 시세 경로는 건드리지 않음 — 구독 동기화 + DB 비교 INSERT 만.
"""
from __future__ import annotations
import threading
import time
from typing import Optional, Set
from ..utils.env import get_env_float, get_env_int
from ..utils.logger import get_logger
logger = get_logger("kis_trader.ls_ws_validator")
class LSWSPriceValidator:
"""KIS(+키움) 구독 종목을 LS 에도 맞추고 가격 갭을 기록."""
def __init__(
self,
*,
ls_ws,
db,
kis_ws=None,
kiwoom_ws=None,
) -> None:
self.ls_ws = ls_ws
self.db = db
self.kis_ws = kis_ws
self.kiwoom_ws = kiwoom_ws
self._thread: Optional[threading.Thread] = None
self._running = False
self._last_warn_ts: dict = {}
def start(self) -> bool:
if self._thread and self._thread.is_alive():
return True
self._running = True
self._thread = threading.Thread(
target=self._loop, daemon=True, name="LSWSPriceValidator",
)
self._thread.start()
logger.info(
"✅ LS WS 갭 검증기 시작 (interval=%ds warn≥%.2f%%)",
self._interval_sec(),
self._warn_pct(),
)
return True
def stop(self) -> None:
self._running = False
def _interval_sec(self) -> int:
return max(1, get_env_int("LS_WS_VALIDATION_INTERVAL_SEC", 5))
def _warn_pct(self) -> float:
return max(0.0, get_env_float("LS_WS_VALIDATION_DIFF_WARN_PCT", 0.10))
def _codes_from(self, ws) -> Set[str]:
if ws is None:
return set()
try:
with ws._sub_lock: # type: ignore[attr-defined]
return set(ws._subscribed) # type: ignore[attr-defined]
except Exception:
return set()
def _sync_ls(self, target: Set[str]) -> None:
try:
with self.ls_ws._sub_lock:
current = set(self.ls_ws._subscribed)
# KR 6자리만 동기화 (US 는 영구구독에서 별도 붙일 수 있음)
kr_target = {c for c in target if c.isdigit() and len(c) == 6}
for code in kr_target - current:
self.ls_ws.subscribe(code)
for code in current - kr_target:
self.ls_ws.unsubscribe(code)
except Exception as e:
logger.debug("LS 구독 동기화 실패: %s", e)
def _parse_price(self, data, key: str = "stck_prpr") -> Optional[float]:
if not data:
return None
raw = data.get(key)
if raw is None and "_price_f" in data:
raw = data.get("_price_f")
try:
v = float(str(raw).replace(",", ""))
return v if v > 0 else None
except (TypeError, ValueError):
return None
def _parse_age_ms(self, data) -> Optional[int]:
if not data:
return None
try:
return int(data.get("_age_ms"))
except (TypeError, ValueError):
return None
def _loop(self) -> None:
time.sleep(15)
while self._running:
try:
self._tick()
except Exception as e:
logger.warning("LS 검증기 tick 예외: %s", e)
time.sleep(self._interval_sec())
def _tick(self) -> None:
kis_codes = self._codes_from(self.kis_ws)
kw_codes = self._codes_from(self.kiwoom_ws)
codes = kis_codes | kw_codes
if not codes:
return
self._sync_ls(codes)
if not self.ls_ws.is_connected():
return
warn_pct = self._warn_pct()
n = 0
for code in codes:
if not (code.isdigit() and len(code) == 6):
continue
kis_data = (
self.kis_ws.get_price(code, max_age_sec=10.0)
if self.kis_ws is not None
else None
)
kw_data = (
self.kiwoom_ws.get_price(code, max_age_sec=10.0)
if self.kiwoom_ws is not None and self.kiwoom_ws.is_connected()
else None
)
ls_data = self.ls_ws.get_price(code, max_age_sec=10.0)
kis_p = self._parse_price(kis_data)
kw_p = self._parse_price(kw_data)
ls_p = self._parse_price(ls_data)
if ls_p is None and kis_p is None and kw_p is None:
continue
self.db.insert_ws_price_validation_ls(
code=code,
kis_price=kis_p,
kiwoom_price=kw_p,
ls_price=ls_p,
kis_age_ms=self._parse_age_ms(kis_data),
kiwoom_age_ms=self._parse_age_ms(kw_data),
ls_age_ms=self._parse_age_ms(ls_data),
)
n += 1
if kis_p not in (None, 0) and ls_p is not None:
diff = (ls_p - kis_p) / kis_p * 100.0
if abs(diff) >= warn_pct:
now = time.time()
if now - self._last_warn_ts.get(code, 0) >= 60:
self._last_warn_ts[code] = now
logger.warning(
"⚠️ [LS 갭] %s KIS↔LS %.3f%% (KIS=%.0f LS=%.0f)",
code, diff, kis_p, ls_p,
)
if n > 0:
logger.debug("LS 갭 검증 샘플 %d", n)