ls증권 히스토리 구독 넣음
This commit is contained in:
168
kis_trader/network/ls_ws_validator.py
Normal file
168
kis_trader/network/ls_ws_validator.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user