""" kis_trader/network/ws_validator.py — KIS↔키움 WS 가격 검증 ============================================================ 주기적으로 같은 종목의 KIS WS 캐시와 키움 WS 캐시를 비교해 ``ws_price_validation`` 테이블에 1행 INSERT. 운영(매매)에는 영향 없음 — **읽기만** 함. 검증 모드(``WS_PROVIDER=kis_with_validation``)에서만 기동. 데이터 흐름:: [KIS WS] ─┐ │ 각각 메모리 dict 캐시 [키움 WS] ─┘ │ ▼ [Validator] ── 5s 주기 ──► ws_price_validation 테이블 │ │ │ └─→ |diff| ≥ WARN_PCT 면 WARN 로그 │ └─→ 24h/1주 통계 분석 → 마이그레이션 전환 결정 근거 비교 대상 종목 -------------- KIS WS 가 구독 중인 종목 = 봇이 매매에 쓰는 가격이 실제로 들어오고 있는 종목. 키움 WS 도 같은 종목을 구독하도록 동기화 (subscribe). env_config 토글 --------------- ``WS_VALIDATION_INTERVAL_SEC`` (기본 5) ``WS_VALIDATION_DIFF_WARN_PCT`` (기본 0.10 — 0.1%p) """ 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.ws_validator") class WSPriceValidator: """KIS↔키움 WS 가격 비교 백그라운드 워커. 매 N초마다: 1) KIS WS 의 구독 중인 종목 목록을 읽음 2) 키움 WS 가 같은 종목을 구독하도록 동기화 3) 두 캐시에서 가격 조회 → DB INSERT 4) |diff_pct| ≥ warn_pct 시 WARN 로그 """ def __init__( self, *, kis_ws, # kis_ws.KISWebSocketPriceCache 인스턴스 kiwoom_ws, # kiwoom_ws.KiwoomWebSocketPriceCache 인스턴스 db, # database.TradeDB sync_kiwoom_to_kis: bool = True, ): self.kis_ws = kis_ws self.kiwoom_ws = kiwoom_ws self.db = db # WS_SUBSCRIBE_KIS_MINIMAL 시 키움 구독은 WSManager 가 전담 → 여기서 REMOVE 금지 self._sync_kiwoom_to_kis = bool(sync_kiwoom_to_kis) self._thread: Optional[threading.Thread] = None self._running = False self._last_warn_ts: dict = {} # code → 최근 WARN 로그 시각 (스팸 방지) # ------------------------------------------------------------------ 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="WSPriceValidator", ) self._thread.start() logger.info( "✅ WS 가격 검증기 시작 — KIS↔키움 비교 (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("WS_VALIDATION_INTERVAL_SEC", 5)) def _warn_pct(self) -> float: return max(0.0, get_env_float("WS_VALIDATION_DIFF_WARN_PCT", 0.10)) # ------------------------------------------------------------------ def _kis_subscribed(self) -> Set[str]: """KIS WS 가 현재 구독 중인 종목 set.""" # KISWebSocketPriceCache 의 _subscribed 직접 참조 (kis_ws.py 정의) try: with self.kis_ws._sub_lock: # type: ignore[attr-defined] return set(self.kis_ws._subscribed) # type: ignore[attr-defined] except AttributeError: return set() def _kiwoom_subscribed(self) -> Set[str]: try: with self.kiwoom_ws._sub_lock: # type: ignore[attr-defined] return set(self.kiwoom_ws._subscribed) # type: ignore[attr-defined] except AttributeError: return set() def _sync_kiwoom_subscriptions(self, target: Set[str]) -> None: """키움 WS 구독 = KIS WS 구독 으로 맞춤.""" try: current = set() with self.kiwoom_ws._sub_lock: # type: ignore[attr-defined] current = set(self.kiwoom_ws._subscribed) # type: ignore[attr-defined] to_add = target - current to_remove = current - target for code in to_add: self.kiwoom_ws.subscribe(code) for code in to_remove: self.kiwoom_ws.unsubscribe(code) except Exception as e: logger.debug("키움 구독 동기화 실패: %s", e) # ------------------------------------------------------------------ def _loop(self) -> None: # 시작 직후 KIS/키움 둘 다 캐시 채워질 시간 약간 줌 time.sleep(15) while self._running: try: self._tick() except Exception as e: logger.warning("WS 검증기 tick 예외: %s", e) time.sleep(self._interval_sec()) def _tick(self) -> None: """1회 비교.""" kis_codes = self._kis_subscribed() if self._sync_kiwoom_to_kis: if not kis_codes: return self._sync_kiwoom_subscriptions(kis_codes) codes = kis_codes else: codes = kis_codes | self._kiwoom_subscribed() if not codes: return # 키움 WS 연결되어 있어야 의미 있음. 미연결이면 한쪽만 기록. kiwoom_ready = self.kiwoom_ws.is_connected() warn_pct = self._warn_pct() warn_count = 0 sample_count = 0 for code in codes: kis_data = self.kis_ws.get_price(code, max_age_sec=10.0) kw_data = self.kiwoom_ws.get_price(code, max_age_sec=10.0) if kiwoom_ready else None kis_price = self._parse_price(kis_data, "stck_prpr") kw_price = self._parse_price(kw_data, "stck_prpr") kis_age = self._parse_age_ms(kis_data) kw_age = self._parse_age_ms(kw_data) if kis_price is None and kw_price is None: continue # DB 1행 INSERT self.db.insert_ws_price_validation( code=code, kis_price=kis_price, kiwoom_price=kw_price, kis_age_ms=kis_age, kiwoom_age_ms=kw_age, ) sample_count += 1 # 차이 경고 if kis_price not in (None, 0) and kw_price is not None: diff_pct = (kw_price - kis_price) / kis_price * 100.0 if abs(diff_pct) >= warn_pct: # 종목당 60초 1회만 로그 (스팸 방지) now = time.time() last = self._last_warn_ts.get(code, 0) if now - last >= 60: self._last_warn_ts[code] = now logger.warning( "⚠️ [WS 검증] %s 가격 차이 %.3f%% (KIS=%.0f, 키움=%.0f)", code, diff_pct, kis_price, kw_price, ) warn_count += 1 if sample_count > 0: logger.debug( "📊 [WS 검증] tick: %d종목 비교 (warn=%d, 키움연결=%s)", sample_count, warn_count, kiwoom_ready, ) # ------------------------------------------------------------------ @staticmethod def _parse_price(data: Optional[dict], key: str) -> Optional[float]: if not data: return None try: v = float(str(data.get(key, "0")).replace(",", "")) return abs(v) if v else None except (ValueError, TypeError): return None @staticmethod def _parse_age_ms(data: Optional[dict]) -> Optional[int]: if not data: return None v = data.get("_age_ms") if v is None: return None try: return int(v) except (ValueError, TypeError): return None