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:
116
database.py
116
database.py
@@ -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건 = 살아있는 값)
|
||||
# ============================================================
|
||||
|
||||
Reference in New Issue
Block a user