feat(ws): 키움 WS 시세 마이그레이션 검증 인프라

KIS WS(41 한도) → 키움 WS(100 한도) 본격 전환 전, 두 소스를 동시 운영해
가격 일치성을 데이터로 검증하기 위한 인프라.

신규
- kiwoom_ws.py: 키움 WS 클라이언트 (KIS WS 와 동일 get_price 인터페이스, 메모리 dict 캐시)
- kis_trader/network/ws_validator.py: 5초마다 KIS↔키움 가격 비교, ws_price_validation 테이블에 1행 INSERT, |diff|≥WARN_PCT 시 WARN 로그
- test_kiwoom_ws.py: 키움 WS 단독 동작 확인 스크립트 (토큰/LOGIN/REG/시세)

수정
- database.py: ws_price_validation 테이블 + ENV 키 (WS_PROVIDER, WS_VALIDATION_INTERVAL_SEC, WS_VALIDATION_DIFF_WARN_PCT) + insert_ws_price_validation/get_ws_validation_stats 헬퍼
- kis_trader/main.py: WS_PROVIDER=kis_with_validation 시 키움 WS + Validator 백그라운드 기동, 종료 시 정리

운영 영향: 0. 매매·시세 의사결정은 항상 KIS WS만 사용. 키움 WS는 백그라운드 비교 기록만.
적용: DB env_config 에 WS_PROVIDER=kis_with_validation INSERT 후 봇 재시작.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-05 21:28:22 +09:00
parent f61c471aac
commit 3fa9eb9bf7
5 changed files with 1087 additions and 1 deletions

View File

@@ -13,7 +13,7 @@ import os
import datetime
import logging
import threading
from typing import Dict, List, Optional, Tuple
from typing import Any, Dict, List, Optional, Tuple
try:
import pymysql
@@ -417,6 +417,17 @@ ENV_CONFIG_KEYS = (
# 미설정(0/빈값) 시 글로벌 MAX_STOCKS 로 폴백 → 구버전 호환.
# 권장: 합계 ≤ MAX_STOCKS (계좌 슬롯 분산), 예: 3+2+2=7.
"SCALP_MAX_STOCKS", "SHORT_MAX_STOCKS", "BREAKOUT_MAX_STOCKS",
# ── 시세 WS 공급자 토글 (키움 시세 마이그레이션) ─────────────────
# 운영(매매 의사결정)에는 항상 KIS WS 만 사용. 키움 WS 는 검증 모드에서만
# 백그라운드 동시 구독 → ws_price_validation 테이블에 가격 비교 기록.
# kis_only : 현행 (기본). 키움 WS 미기동.
# kis_with_validation : KIS WS 운영 + 키움 WS 검증 동시 (매매 영향 없음)
# kiwoom_only : 시세를 키움으로 전환 (검증 통과 후에만 사용)
"WS_PROVIDER",
# 검증 비교 주기(초) — 너무 짧으면 부하, 너무 길면 표본 부족. 기본 5.
"WS_VALIDATION_INTERVAL_SEC",
# 차이 경고 임계(%). |diff| 가 이 값 이상이면 WARN 로그. 기본 0.10.
"WS_VALIDATION_DIFF_WARN_PCT",
)
@@ -762,6 +773,30 @@ class TradeDB:
logger.info("📌 target_candidates_history 테이블 확인/생성")
except Exception as e:
logger.warning(f"migrate target_candidates_history 실패(이력 미적재 가능): {e}")
# ── ws_price_validation (KIS↔키움 시세 검증, 마이그레이션 단계용) ────
# 5초마다 같은 종목의 KIS WS 가격과 키움 WS 가격을 비교해 한 행 INSERT.
# diff_pct = (kiwoom - kis) / kis × 100.
# 운영에는 영향 없음 (검증 모드 ON 일 때만 채워짐). 1~2주 누적 후
# 통계 분석 → 본격 마이그레이션 결정 근거.
try:
self.conn.execute("""
CREATE TABLE IF NOT EXISTS ws_price_validation (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
ts DATETIME(3) NOT NULL,
code VARCHAR(20) NOT NULL,
kis_price DOUBLE,
kiwoom_price DOUBLE,
diff_pct DOUBLE,
kis_age_ms INT,
kiwoom_age_ms INT,
INDEX idx_ts (ts),
INDEX idx_code (code),
INDEX idx_diff (diff_pct)
) CHARACTER SET utf8mb4
""")
logger.info("📌 ws_price_validation 테이블 확인/생성")
except Exception as e:
logger.warning(f"migrate ws_price_validation 실패: {e}")
def _migrate_env_config_to_columns(self):
"""env_config가 예전 JSON 컬럼(snapshot_json)이면 컬럼 스키마로 이전"""
@@ -1837,6 +1872,85 @@ class TradeDB:
logger.error(f"❌ 날짜별 조회 실패: {e}")
return []
# ============================================================
# [ws_price_validation] KIS↔키움 시세 비교 검증
# ============================================================
def insert_ws_price_validation(
self,
*,
code: str,
kis_price: Optional[float],
kiwoom_price: Optional[float],
kis_age_ms: Optional[int] = None,
kiwoom_age_ms: Optional[int] = None,
) -> bool:
"""단일 비교 결과 1행 INSERT.
둘 다 None 이면 저장 안 함. 한쪽만 있어도 저장(소스별 가용성 분석용).
diff_pct 는 둘 다 있을 때만 계산.
"""
if kis_price is None and kiwoom_price is None:
return False
diff_pct: Optional[float] = None
if kis_price not in (None, 0) and kiwoom_price is not None:
try:
diff_pct = (float(kiwoom_price) - float(kis_price)) / float(kis_price) * 100.0
except (ValueError, ZeroDivisionError):
diff_pct = None
try:
now = datetime.datetime.now()
self.conn.execute(
"INSERT INTO ws_price_validation "
"(ts, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms) "
"VALUES (%s, %s, %s, %s, %s, %s, %s)",
(now, code, kis_price, kiwoom_price, diff_pct, kis_age_ms, kiwoom_age_ms),
)
return True
except Exception as e:
logger.debug("ws_price_validation INSERT 실패: %s", e)
return False
def get_ws_validation_stats(
self, *, hours: int = 24, code: Optional[str] = None,
) -> Dict[str, Any]:
"""최근 N시간 검증 통계 (운영자용 분석).
Returns:
{
"samples": 1234,
"both_present": 1100, # KIS·키움 둘 다 가격 있던 비율
"avg_diff_pct": 0.012,
"max_abs_diff_pct": 0.45,
"stddev_diff_pct": 0.08,
"kis_only": 80, # KIS 만 가격 있던 횟수 (키움 미수신)
"kiwoom_only": 30, # 키움 만 가격 있던 횟수
}
"""
try:
where = ["ts >= NOW() - INTERVAL %s HOUR"]
args: List[Any] = [hours]
if code:
where.append("code = %s")
args.append(code)
wsql = " AND ".join(where)
row = self.conn.execute(f"""
SELECT
COUNT(*) AS samples,
SUM(kis_price IS NOT NULL AND kiwoom_price IS NOT NULL) AS both_present,
AVG(diff_pct) AS avg_diff_pct,
MAX(ABS(diff_pct)) AS max_abs_diff_pct,
STDDEV(diff_pct) AS stddev_diff_pct,
SUM(kis_price IS NOT NULL AND kiwoom_price IS NULL) AS kis_only,
SUM(kis_price IS NULL AND kiwoom_price IS NOT NULL) AS kiwoom_only
FROM ws_price_validation
WHERE {wsql}
""", args).fetchone()
return dict(row) if row else {}
except Exception as e:
logger.debug("ws_validation_stats 조회 실패: %s", e)
return {}
# ============================================================
# [env_config] 관리자용 env (INSERT만, 최신 1건 = 살아있는 값)
# ============================================================