169 lines
5.5 KiB
Python
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)
|