Files
kis_bot/scripts/test_live_execution_validation.py
Your Name 36a3e2b4a1 feat: Enhance trading system with new permanent subscription features and order book management
Changes:
- Added a new API endpoint for managing permanent subscriptions, allowing users to enable or disable subscriptions dynamically.
- Implemented a function to fill candle data from Kiwoom, ensuring that only relevant data is inserted into the database.
- Introduced a mechanism to handle master subscription states, improving the management of subscription statuses.
- Updated the database schema to include new fields for managing subscription states and order book filtering.

Impact:
- These enhancements improve the flexibility and reliability of the trading system, allowing for better management of subscriptions and order book data, while reducing the risk of data inconsistencies.

히스토리 align 제거 븅신같은 초기설계 아예 제거
진입모드에 구멍메움
호가진입을 켜도 호가가 안들어올때 호가 안보고 그냥 사버림
2026-08-15 23:01:14 +09:00

712 lines
32 KiB
Python
Executable File

#!/usr/bin/env python3
"""
scripts/test_live_execution_validation.py
=========================================
실매 코어 스모크 (주문·REST 연타 없음).
반드시 **DB에서 읽은 실제 ON/OFF** 를 먼저 찍고, 그 값에 맞춰 기대 동작을 검사한다.
가상 호가 주입은 「DB에서 필터 ON인 전략」에만 실매와 동일하게 적용한다.
(OFF 전략에 가짜 탈락을 찍지 않음 — 헷갈림 방지)
추가로 검증:
· ws_candles source/channel 정규화 · 실매=Optuna 읽기쌍
· 갭보정 키움 REST만 (KIS REST 강제 무시)
· 영구구독 마스터 · LS DB 적재 가드 · 운영설정 UI 키
실행:
python3 -u scripts/test_live_execution_validation.py
# 로그 예: logs/test_live_execution_validation_*.log
"""
from __future__ import annotations
import logging
import sys
import threading
import time
from pathlib import Path
from typing import Dict, List
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
logging.basicConfig(level=logging.WARNING, format="[%(levelname)s] %(message)s")
전략목록 = ["MOMENTUM", "BREAKOUT", "SCALP", "TAIL"]
전략한글 = {
"MOMENTUM": "모멘텀",
"BREAKOUT": "돌파",
"SCALP": "스캘핑",
"TAIL": "꼬리(SHORT=TAIL키)",
}
def _성공(msg: str) -> None:
print(f"{msg}")
def _주의(msg: str) -> None:
print(f" ⚠️ {msg}")
def _실패(msg: str) -> None:
print(f"{msg}")
def _전략표시(sid: str) -> str:
return f"{전략한글.get(sid, sid)}[{sid}]"
def _db원문(: str) -> str:
from kis_trader.utils.env import get_env_from_db
raw = get_env_from_db(, "")
if raw in (None, "", "None"):
return "(비어있음→코드기본값)"
return repr(str(raw))
def run_validation() -> bool:
print("=" * 78)
print("실매 엔진 검증 (DB 실제값 기준 · 호가/휩쏘/WS spill)")
print("=" * 78)
실패목록: List[str] = []
# ── 1) 모듈 로드 ───────────────────────────────────────────────
print("\n[1단계] 핵심 모듈 import")
try:
from database import TradeDB, ENV_CONFIG_KEYS
from kis_trader.strategies.momentum import MomentumStrategy
from kis_trader.strategies.breakout import BreakoutStrategy
from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry
from kis_trader.engine.orderbook_env import (
load_orderbook_threshold_cfg,
orderbook_filter_enabled,
)
from kis_trader.engine.whipsaw_filter import whipsaw_filter_enabled
from kis_trader.ws.orderbook_cache import OrderbookSnapshot, OrderbookLevel
from kis_trader.engine.momentum_hts_logic import need_ob_or_history
from kis_trader.network.ws_manager import WSManager
from kis_trader.ws.kis_ws import KISWebSocketPriceCache
from kis_trader.execution.order_manager import OrderManager
from kis_trader.utils.env import get_env_bool, get_env_from_db, invalidate_merged_env_cache
from kis_trader.utils.live_portfolio_common import (
resolve_live_universe_history_source,
)
from kis_trader.web.live_config_schema import build_live_config_groups
import kis_trader.engine.trigger_eval_collect as 트리거수집
import inspect
invalidate_merged_env_cache()
_성공("전략/호가/휩쏘/WSManager/체결매니저 로드 OK")
except Exception as exc:
_실패(f"import 실패: {exc}")
return False
db = TradeDB()
# ── 2) DB 진입필터 ON/OFF 표 (핵심) ─────────────────────────────
print("\n[2단계] DB에 저장된 「진입」필터 스위치 (이게 실매 기준)")
print(" · 진입호가필터 = {전략}_ORDERBOOK_FILTER_ENABLED")
print(" · 진입휩쏘필터 = {전략}_WHIPSAW_FILTER_ENABLED")
print(" · 청산호가(EXIT/STOP)는 「진입」과 별개 — 아래 3단계에서 표기")
print("-" * 78)
진입호가_켜진전략: List[str] = []
for sid in 전략목록:
호가키 = f"{sid}_ORDERBOOK_FILTER_ENABLED"
휩쏘키 = f"{sid}_WHIPSAW_FILTER_ENABLED"
호가_db = _db원문(호가키)
휩쏘_db = _db원문(휩쏘키)
호가_실매적용 = orderbook_filter_enabled(sid)
휩쏘_실매적용 = whipsaw_filter_enabled(sid)
임계 = load_orderbook_threshold_cfg(sid, {})
if 호가_실매적용:
진입호가_켜진전략.append(sid)
print(f" {_전략표시(sid)}")
print(
f" 진입 호가필터: {'켜짐(ON)' if 호가_실매적용 else '꺼짐(OFF)'} "
f"| DB {호가키}={호가_db}"
)
print(
f" 진입 휩쏘필터: {'켜짐(ON)' if 휩쏘_실매적용 else '꺼짐(OFF)'} "
f"| DB {휩쏘키}={휩쏘_db}"
)
if 호가_실매적용:
print(
f" 호가 임계(DB): 스프레드상한={임계.get('max_spread_pct')}% "
f"| 잔량비하한={임계.get('min_bid_ask_ratio')}"
)
else:
print(" → 실매 진입 시 호가 탈락검사 안 함 (스냅이 있어도 통과)")
print("-" * 78)
print(
f" 요약: 진입호가필터 ON 전략 = "
f"{', '.join(_전략표시(s) for s in 진입호가_켜진전략) or '(없음 — 전부 OFF)'}"
)
print(
f" 참고 전역키(전략키 없을 때 안 씀/호가는 전략키만): "
f"ORDERBOOK_FILTER_ENABLED={_db원문('ORDERBOOK_FILTER_ENABLED')} "
f"| WHIPSAW_FILTER_ENABLED={_db원문('WHIPSAW_FILTER_ENABLED')}"
)
# ── 3) 청산 호가컷 (진입과 분리) ────────────────────────────────
print("\n[3단계] 「청산」호가컷 DB (진입필터와 다름)")
try:
돌파 = BreakoutStrategy(db=db, client=None, ws=None, order_mgr=None)
print(
f" {_전략표시('BREAKOUT')} "
f"익절호가매도={'ON' if getattr(돌파, 'exit_ob_enabled', False) else 'OFF'} "
f"(DB {_db원문('BREAKOUT_EXIT_OB_ENABLED')}) | "
f"손절호가={'ON' if getattr(돌파, 'stop_ob_enabled', False) else 'OFF'} "
f"(DB {_db원문('BREAKOUT_STOP_OB_ENABLED')})"
)
mom_exit = get_env_bool("MOMENTUM_EXIT_OB_ENABLED", False)
mom_stop = get_env_bool("MOMENTUM_STOP_OB_ENABLED", False)
print(
f" {_전략표시('MOMENTUM')} "
f"익절호가매도={'ON' if mom_exit else 'OFF'} "
f"(DB {_db원문('MOMENTUM_EXIT_OB_ENABLED')}) | "
f"손절호가={'ON' if mom_stop else 'OFF'} "
f"(DB {_db원문('MOMENTUM_STOP_OB_ENABLED')}) | "
f"호가추적필요={need_ob_or_history({'exit_ob_enabled': mom_exit, 'stop_ob_enabled': mom_stop})}"
)
_성공("청산 호가컷은 진입 호가필터와 독립적으로 DB 값을 읽음")
except Exception as exc:
_실패(f"청산 호가컷 검증 오류: {exc}")
return False
# ── 4) 가상호가 시뮬 — DB ON인 전략만 ────────────────────────────────
print("\n[4단계] 가상 호가 주입 시뮬 (DB에서 진입호가필터 ON인 전략만)")
print(" 우량=스프레드 좁고 매수잔량 우세 | 위험=스프레드 과다")
print(" ※ 필터 OFF 전략은 「실매와 같이 탈락 안 함」만 확인하고 가짜 차단 로그를 안 찍음")
def _가짜호가(스프레드퍼센트: float, 매수잔량: int, 매도잔량: int) -> OrderbookSnapshot:
현재가 = 10000
매도1 = int(현재가 + (현재가 * 스프레드퍼센트 / 100.0))
매수1 = int(현재가)
매수호가 = [
OrderbookLevel(price=매수1 - i * 10, qty=int(매수잔량 / 10))
for i in range(10)
]
매도호가 = [
OrderbookLevel(price=매도1 + i * 10, qty=int(매도잔량 / 10))
for i in range(10)
]
return OrderbookSnapshot(
code="005930",
bids=매수호가,
asks=매도호가,
total_bid_qty=매수잔량,
total_ask_qty=매도잔량,
ts=1234567890.0,
source="kiwoom_0d",
)
우량호가 = _가짜호가(0.1, 6000, 2000)
위험호가 = _가짜호가(3.5, 500, 50000)
for sid in 전략목록:
필터켜짐 = orderbook_filter_enabled(sid)
if not 필터켜짐:
# OFF면 엔진에 스냅을 넣어도 실매 경로와 같이 탈락코드 없음이 정상
탈락코드, _ = orderbook_reject_for_entry(
{
"_backtest_orderbook_snapshot": 위험호가,
"slot_money": 10_000_000,
},
sid,
current_price=10000.0,
)
if 탈락코드:
실패목록.append(f"{sid}: 필터 OFF인데 탈락코드={탈락코드}")
_실패(
f"{_전략표시(sid)} DB=OFF 인데도 탈락({탈락코드}) — 버그 가능"
)
else:
_성공(
f"{_전략표시(sid)} 진입호가필터 OFF → 위험호가를 넣어도 탈락 없음(실매와 동일)"
)
continue
우량탈락, _ = orderbook_reject_for_entry(
{"_backtest_orderbook_snapshot": 우량호가, "slot_money": 10_000_000},
sid,
current_price=10000.0,
)
위험탈락, 위험사유 = orderbook_reject_for_entry(
{"_backtest_orderbook_snapshot": 위험호가, "slot_money": 10_000_000},
sid,
current_price=10000.0,
)
if 우량탈락:
실패목록.append(f"{sid}: 우량호가 탈락 {우량탈락}")
_실패(f"{_전략표시(sid)} 우량호가인데 탈락: {우량탈락}")
else:
_성공(f"{_전략표시(sid)} 우량호가 → 통과")
if not 위험탈락:
실패목록.append(f"{sid}: 위험호가 미차단")
_실패(f"{_전략표시(sid)} 위험호가 미차단 (필터 ON인데 통과함)")
else:
_성공(f"{_전략표시(sid)} 위험호가 → 차단 ({위험탈락}: {위험사유})")
# ── 5) WS 단절 · 유니버스 소스 (DB) ────────────────────────────────
print("\n[5단계] 호가스냅 없음(None) · 유니버스 소스 (DB)")
print(" 실매 후보=키움 RAM. history 는 백테 재생용.")
print(" kiwoom→target_candidates_history / ls→ls_candidates_history")
for sid in 전략목록:
탈락, 사유 = orderbook_reject_for_entry(
{"slot_money": 10_000_000},
sid,
current_price=10000.0,
)
필터켜짐 = orderbook_filter_enabled(sid)
if not 필터켜짐:
if 탈락:
실패목록.append(f"{sid}: 필터 OFF인데 스냅None 탈락={탈락}")
_실패(f"{_전략표시(sid)} 필터 OFF인데 스냅없음 탈락({탈락})")
else:
_성공(f"{_전략표시(sid)} 필터 OFF + 스냅없음 → 통과(막지 않음)")
continue
if 탈락 == "탈락-호가없음":
_성공(f"{_전략표시(sid)} 필터 ON + 스냅없음 → 차단({탈락})")
elif 탈락 is None:
실패목록.append(f"{sid}: 필터 ON인데 스냅없음 Fail-Open")
_실패(f"{_전략표시(sid)} 필터 ON인데 스냅없음 통과 — REJECT_IF_EMPTY 확인")
else:
실패목록.append(f"{sid}: 스냅없음 예상외 탈락={탈락}")
_실패(f"{_전략표시(sid)} 스냅없음 예상외({탈락}: {사유})")
print(" B안: 저장TTL보다 오래된 RAM 호가도 필터 ON이면 검사")
class _오래된호가WS:
def __init__(self, snap: OrderbookSnapshot) -> None:
self.snap = snap
def get_orderbook_snapshot(self, code: str, max_age_sec: float = 3.0):
if max_age_sec > 0 and (time.time() - float(self.snap.ts or 0)) > max_age_sec:
return None
return self.snap
_stale_ws = _오래된호가WS(위험호가)
for sid in 전략목록:
if not orderbook_filter_enabled(sid):
continue
탈락, _ = orderbook_reject_for_entry(
{
"_orderbook_ws": _stale_ws,
"_orderbook_code": "005930",
"slot_money": 10_000_000,
},
sid,
current_price=10000.0,
)
if 탈락:
_성공(f"{_전략표시(sid)} 오래된 RAM 호가 → 차단({탈락}) B안 OK")
else:
실패목록.append(f"{sid}: 오래된 호가 Fail-Open")
_실패(f"{_전략표시(sid)} 오래된 RAM 호가인데 통과 — FILTER_MAX_AGE 확인")
for sid in 전략목록:
hist = resolve_live_universe_history_source(sid) # DB UNIVERSE_SOURCE
univ = (get_env_from_db(f"{sid}_UNIVERSE_SOURCE", "") or "").strip() or "(기본)"
print(
f" {_전략표시(sid)} 유니버스소스={univ} | 이력테이블쪽={hist}"
)
# ── 6) 수집 스위치 (필터와 분리) ────────────────────────────────
print("\n[6단계] 호가/프로그램 「수집」스위치 (진입필터와 별개 · DB 연동만 확인)")
with patch(
"kis_trader.engine.trigger_eval_collect.get_env_bool"
) as mock_bool, patch(
"kis_trader.engine.trigger_eval_collect.get_env_from_db", return_value=""
), patch(
"kis_trader.engine.trigger_eval_collect.trigger_eval_save_master_enabled",
return_value=True,
):
mock_bool.return_value = True
if not 트리거수집.orderbook_collect_enabled({}, "MOMENTUM"):
실패목록.append("수집스위치 ON 무시")
_실패("호가수집이 DB ON을 무시함")
mock_bool.return_value = False
if 트리거수집.orderbook_collect_enabled({}, "MOMENTUM"):
실패목록.append("수집스위치 OFF 무시")
_실패("호가수집이 DB OFF를 무시함")
else:
_성공("수집 스위치가 get_env_bool 따라감 (하드코딩 없음)")
# ── 7) WS spill ────────────────────────────────────────────────
print("\n[7단계] 구독 spill (한도초과→다음 증권사, sleep 없음)")
try:
src = inspect.getsource(KISWebSocketPriceCache.subscribe)
if "return False" not in src or "return True" not in src:
실패목록.append("kis subscribe bool 아님")
_실패("kis_ws.subscribe bool 반환 패턴 없음")
else:
_성공("한투(kis) subscribe → 성공/한도초과 bool")
class _가짜DB:
pass
허브 = WSManager(db=_가짜DB(), kis_client=MagicMock())
틱체인 = 허브._build_subscribe_chain("tick")
호가체인 = 허브._build_subscribe_chain("ob")
if not 틱체인 or 틱체인[-1] != "ls":
실패목록.append(f"틱체인 말단 ls 아님: {틱체인}")
_실패(f"틱 spill 체인 이상: {틱체인}")
else:
_성공(f"틱 구독 우선순위={틱체인} (마지막=3차 LS)")
if not 호가체인 or 호가체인[-1] != "ls":
실패목록.append(f"호가체인 말단 ls 아님: {호가체인}")
_실패(f"호가 spill 체인 이상: {호가체인}")
else:
_성공(f"호가 구독 우선순위={호가체인}")
# 한투 한도 → LS
한투 = MagicMock()
한투.subscribe = MagicMock(return_value=False)
한투.is_active = True
한투._subscribed = set()
한투._sub_lock = threading.Lock()
허브.ws_cache = 한투
허브._kiwoom_ws = None
엘에스 = MagicMock()
엘에스.is_connected = MagicMock(return_value=True)
엘에스.subscribe = MagicMock(return_value=True)
엘에스._subscribed = set()
엘에스._us_subscribed = set()
엘에스._sub_lock = threading.Lock()
허브._get_ls_ws = MagicMock(return_value=엘에스)
home = 허브._subscribe_tick_prefer_or_spill("005930", "kis")
라벨 = 허브.get_tick_feed_label("005930")
if home != "ls" or "spill" not in 라벨:
실패목록.append(f"한투→LS spill 실패 {home}/{라벨}")
_실패(f"한투 한도 spill 실패: {home} / {라벨}")
else:
_성공(f"한투 한도초과 → 즉시 LS 이관 (시세라벨={라벨})")
# 키움 한도 → 한투
허브2 = WSManager(db=_가짜DB(), kis_client=MagicMock())
키움 = MagicMock()
키움.subscribe = MagicMock(return_value=False)
키움.is_connected = MagicMock(return_value=True)
키움._subscribed = set()
키움._sub_lock = threading.Lock()
한투2 = MagicMock()
한투2.subscribe = MagicMock(return_value=True)
한투2.is_active = True
한투2._subscribed = set()
한투2._sub_lock = threading.Lock()
허브2._kiwoom_ws = 키움
허브2.ws_cache = 한투2
허브2._get_ls_ws = MagicMock(return_value=None)
home2 = 허브2._subscribe_tick_prefer_or_spill("000660", "kiwoom")
라벨2 = 허브2.get_tick_feed_label("000660")
if home2 != "kis" or 라벨2 != "kis(spill)":
실패목록.append(f"키움→한투 spill 실패 {home2}/{라벨2}")
_실패(f"키움 한도 spill 실패: {home2}/{라벨2}")
else:
_성공(f"키움 한도초과 → 즉시 한투 이관 (시세라벨={라벨2})")
주문 = OrderManager(client=MagicMock(), db=MagicMock())
주문.tick_feed_label_provider = 허브2.get_tick_feed_label
mm줄 = 주문._tick_feed_mm_line("000660")
if "시세:" not in mm줄:
실패목록.append(f"MM 시세줄 이상 {mm줄}")
_실패(f"매터모스트 시세 표기 이상: {mm줄}")
else:
_성공(f"체결 알림 시세 한 줄 예: {mm줄}")
허브3 = WSManager(db=_가짜DB(), kis_client=MagicMock())
한투3 = MagicMock()
한투3.subscribe = MagicMock(return_value=False)
한투3.is_active = True
허브3.ws_cache = 한투3
허브3._kiwoom_ws = None
허브3._get_ls_ws = MagicMock(return_value=엘에스)
허브3._subscribe_spill_enabled = lambda: False # type: ignore[method-assign]
home3 = 허브3._subscribe_tick_prefer_or_spill("035420", "kis")
if home3 is not None:
실패목록.append("spill OFF인데 이관됨")
_실패(f"WS_SUBSCRIBE_SPILL 꺼도 이관됨: {home3}")
else:
_성공("구독 spill 스위치 OFF → 한도 시 거절(예전과 동일)")
except Exception as exc:
_실패(f"spill 검증 오류: {exc}")
import traceback
traceback.print_exc()
return False
# ── 8) env / 운영설정 UI ───────────────────────────────────────
print("\n[8단계] 운영설정·ENV 키 (LS WS / spill / 영구구독 / 갭보정)")
for in (
"LS_WS_ENABLED",
"WS_SUBSCRIBE_SPILL",
"WS_TICK_SUBSCRIBE_CHAIN",
"WS_OB_SUBSCRIBE_CHAIN",
"PERMANENT_SUBSCRIBE_ENABLED",
"WS_GAP_FILL_KIS_FALLBACK",
"PERM_LS_FILL_BARS",
"PERM_LS_FILL_SLEEP_MIN",
"PERM_LS_FILL_SLEEP_MAX",
"WS_CANDLE_FREEZE_ON_CONFIRM",
):
if not in ENV_CONFIG_KEYS:
실패목록.append(f"ENV 누락 {}")
_실패(f"database.ENV_CONFIG_KEYS 에 {} 없음")
else:
_성공(f"ENV 등록됨: {} (현재값={_db원문()})")
print(
f" 현재 실매: 구독spill={'ON' if get_env_bool('WS_SUBSCRIBE_SPILL', True) else 'OFF'} | "
f"LS WS기동={'ON' if get_env_bool('LS_WS_ENABLED', False) else 'OFF'} | "
f"영구구독마스터={'ON' if get_env_bool('PERMANENT_SUBSCRIBE_ENABLED', True) else 'OFF'} | "
f"틱메인={_db원문('LIVE_TICK_PROVIDER')} | "
f"CANDLE_SOURCE={_db원문('CANDLE_SOURCE')}"
)
시세섹션 = next(
(g for g in build_live_config_groups() if g.get("id") == "ws_feed_settings"),
None,
)
if not 시세섹션:
실패목록.append("운영설정 시세섹션 없음")
_실패("운영설정「시세 소스」섹션 없음")
else:
ui키들 = {f.get("key") for f in 시세섹션.get("fields") or []}
for need in (
"LS_WS_ENABLED",
"WS_SUBSCRIBE_SPILL",
"PERMANENT_SUBSCRIBE_ENABLED",
"WS_GAP_FILL_KIS_FALLBACK",
"PERM_LS_FILL_BARS",
):
if need not in ui키들:
실패목록.append(f"UI 누락 {need}")
_실패(f"운영설정 UI에 {need} 없음")
else:
_성공(f"운영설정 UI 필드 있음: {need}")
# ── 9) 봉 source/channel · 실매=Optuna 읽기 ───────────────────────
print("\n[9단계] ws_candles source/channel · 실매=백테 읽기쌍")
try:
from kis_trader.ws.candle_series import (
dedupe_by_read_pairs,
live_read_pairs,
normalize_source_channel,
)
from kis_trader.backtest.bt_candle_source import resolve_bt_read_pairs
from kis_trader.ws.kis_ws import CandleAggregator
for raw, expect in (
(("rest", ""), ("kiwoom", "rest")),
(("kw_rest", ""), ("kiwoom", "rest")),
(("rollup_1m", ""), ("kiwoom", "rollup")),
(("ws", ""), ("kis", "ws")),
(("kiwoom", ""), ("kiwoom", "ws")),
(("kis", "ws"), ("kis", "ws")),
):
got = normalize_source_channel(raw[0], raw[1])
if got != expect:
실패목록.append(f"normalize {raw}{got}")
_실패(f"normalize_source_channel{raw} = {got} (기대 {expect})")
else:
_성공(f"정규화 {raw[0]!r}/{raw[1]!r}{got}")
pairs = live_read_pairs()
bt_pairs = resolve_bt_read_pairs()
if pairs != bt_pairs:
실패목록.append(f"실매≠Optuna 읽기쌍 {pairs} vs {bt_pairs}")
_실패(f"live_read_pairs≠resolve_bt_read_pairs: {pairs} / {bt_pairs}")
else:
_성공(f"실매=Optuna 읽기쌍 {pairs}")
if pairs[0][1] != "ws" or ("kiwoom", "rest") not in pairs:
실패목록.append(f"읽기쌍 규칙 위반 {pairs}")
_실패(f"메인은 WS, 구멍은 kiwoom+rest 여야 함: {pairs}")
else:
_성공("메인 channel=ws, 구멍에 kiwoom+rest 포함")
# 같은 candle_time: 메인 WS 승, rest 구멍만 채움
rows = [
{
"candle_time": "202608131000",
"open": 1, "high": 1, "low": 1, "close": 100,
"volume": 1, "source": "kiwoom", "channel": "rest",
},
{
"candle_time": "202608131000",
"open": 2, "high": 2, "low": 2, "close": 200,
"volume": 2, "source": "kiwoom", "channel": "ws",
},
{
"candle_time": "202608131001",
"open": 3, "high": 3, "low": 3, "close": 300,
"volume": 3, "source": "kiwoom", "channel": "rest",
},
]
deduped = dedupe_by_read_pairs(
rows, (("kiwoom", "ws"), ("kiwoom", "rest")),
)
by_t = {r["candle_time"]: r["close"] for r in deduped}
if by_t.get("202608131000") != 200:
실패목록.append("dedupe WS 우선 실패")
_실패(f"같은 시각에 WS가 이겨야 함: {by_t}")
elif by_t.get("202608131001") != 300:
실패목록.append("dedupe rest 구멍 실패")
_실패(f"구멍 rest 유지 실패: {by_t}")
else:
_성공("dedupe: WS 우선·rest 구멍만 채움")
# 갭보정 쓰기 라벨 (소스 코드)
fill_src = inspect.getsource(CandleAggregator.fill_gap_from_rest)
if '"source": "kiwoom"' not in fill_src or '"channel": "rest"' not in fill_src:
실패목록.append("fill_gap source/channel 아님")
_실패("fill_gap_from_rest 가 kiwoom+rest 로 안 씀")
else:
_성공("갭보정 쓰기 = source=kiwoom, channel=rest")
if not hasattr(CandleAggregator, "_bar_key"):
실패목록.append("CandleAggregator._bar_key 없음")
_실패("RAM 키가 (code,tf,source,channel) 아님")
else:
k = CandleAggregator._bar_key("005930", 1, "rest", "")
if k != ("005930", 1, "kiwoom", "rest"):
실패목록.append(f"_bar_key {k}")
_실패(f"_bar_key 정규화 실패: {k}")
else:
_성공(f"RAM _bar_key 정규화 OK {k}")
except Exception as exc:
실패목록.append(f"봉소스 검증 오류: {exc}")
_실패(f"봉 source/channel 검증 오류: {exc}")
import traceback
traceback.print_exc()
# ── 10) 갭보정 KIS REST 금지 · 영구구독 갭제외 ─────────────────
print("\n[10단계] 갭보정=키움만 · 영구구독은 ls_ws_candles")
try:
import permanent_subs as ps
fill_gap_src = inspect.getsource(WSManager._fill_gap_for_code)
if "get_minute_chart" in fill_gap_src:
실패목록.append("갭보정에 KIS get_minute_chart 잔존")
_실패("WSManager._fill_gap_for_code 에 KIS REST 호출이 남아 있음")
else:
_성공("갭보정 경로에 KIS get_minute_chart 없음")
if "WS_GAP_FILL_KIS_FALLBACK ON 무시" not in fill_gap_src:
실패목록.append("KIS fallback 무시 로그 없음")
_실패("WS_GAP_FILL_KIS_FALLBACK ON 무시 가드 문구 없음")
else:
_성공("WS_GAP_FILL_KIS_FALLBACK ON 이어도 호출 안 함(가드 있음)")
sync_src = inspect.getsource(WSManager._sync_permanent_to_ls)
if "subscribe_master_enabled" not in sync_src:
실패목록.append("permanent sync 마스터 미적용")
_실패("_sync_permanent_to_ls 가 마스터 스위치를 안 봄")
else:
_성공("영구구독→LS sync 가 마스터 OFF 시 빈 집합")
# 갭 집합에 perm 합집합 금지 (후보|보유만)
recon_src = inspect.getsource(WSManager._reconcile_split_subscriptions)
if "_gap_refill_codes = set(kis_want) | set(kw_want) | perm" in recon_src:
실패목록.append("갭보정에 영구구독 포함")
_실패("_gap_refill_codes 가 영구구독(perm)을 포함함 — ls_ws_candles 전용이어야 함")
elif "_gap_refill_codes = set(kis_want) | set(kw_want)" not in recon_src:
실패목록.append("갭보정 집합 패턴 변경")
_주의("_reconcile_split_subscriptions 갭보정 집합 패턴이 예상과 다름 — 수동 확인")
else:
_성공("자동 갭보정 집합 = 후보·보유만 (영구구독 제외)")
if not ps.subscribe_master_enabled():
_주의("DB PERMANENT_SUBSCRIBE_ENABLED=OFF (행 유지·구독만 끔)")
else:
_성공("영구구독 마스터 ON")
if not ps.should_persist_ls("005930", {"005930", "069500"}):
실패목록.append("should_persist_ls True 기대")
_실패("영구구독 코드인데 LS 적재 가드 False")
else:
_성공("should_persist_ls: 영구구독 코드만 True")
if ps.should_persist_ls("999999", {"005930"}):
실패목록.append("should_persist_ls False 기대")
_실패("비영구 코드인데 LS 적재 허용")
else:
_성공("should_persist_ls: 후보 spill 코드 False")
if not hasattr(db, "insert_ls_ws_candle_if_absent"):
실패목록.append("insert_ls_ws_candle_if_absent 없음")
_실패("영구구독 확정봉 INSERT IGNORE 헬퍼 없음")
else:
_성공("insert_ls_ws_candle_if_absent 존재")
if not hasattr(ps, "fill_ls_candles_from_kiwoom"):
실패목록.append("fill_ls_candles_from_kiwoom 없음")
_실패("영구구독 확정봉 가져오기 헬퍼 없음")
else:
_성공("fill_ls_candles_from_kiwoom 존재 (키움→ls_ws_candles)")
except Exception as exc:
실패목록.append(f"갭/영구구독 검증 오류: {exc}")
_실패(f"갭보정·영구구독 검증 오류: {exc}")
import traceback
traceback.print_exc()
# ── 11) DB 스키마 channel · UNIQUE ─────────────────────────────
print("\n[11단계] DB ws_candles.channel · UNIQUE(source,channel)")
try:
cols = {r["Field"] for r in db.conn.execute("SHOW COLUMNS FROM ws_candles").fetchall()}
if "channel" not in cols:
실패목록.append("ws_candles.channel 없음")
_실패("ws_candles.channel 컬럼 없음")
else:
_성공("ws_candles.channel 컬럼 있음")
idx = db.conn.execute(
"SHOW INDEX FROM ws_candles WHERE Key_name='uq_candle'"
).fetchall()
idx_cols = [r["Column_name"] for r in (idx or [])]
want = ["code", "timeframe", "candle_time", "source", "channel"]
if idx_cols != want:
실패목록.append(f"uq_candle={idx_cols}")
_실패(f"UNIQUE uq_candle 기대 {want}, 실제 {idx_cols}")
else:
_성공(f"UNIQUE uq_candle={idx_cols}")
dist = db.conn.execute(
"SELECT source, channel, COUNT(*) AS n FROM ws_candles "
"GROUP BY source, channel ORDER BY n DESC"
).fetchall()
print(" 현재 source/channel 분포:")
for r in dist or []:
print(f" {r['source']}+{r['channel']}: {int(r['n']):,}")
bad = [
r for r in (dist or [])
if str(r["source"]) in ("rest", "kw_rest", "ws", "rollup_1m", "rollup")
]
if bad:
실패목록.append(f"레거시 source 잔존 {bad}")
_실패(f"레거시 source 라벨 잔존: {bad}")
else:
_성공("레거시 source=rest/ws/rollup_1m 잔존 없음")
except Exception as exc:
실패목록.append(f"스키마 검증 오류: {exc}")
_실패(f"ws_candles 스키마 검증 오류: {exc}")
# ── 최종 ───────────────────────────────────────────────────────
print("\n" + "=" * 78)
if 실패목록:
print(f"최종: 실패 {len(실패목록)}")
for x in 실패목록:
print(f" - {x}")
print("=" * 78)
return False
print("최종: 통과 (위 DB ON/OFF 표기가 실매 기준과 일치하는지 함께 확인)")
print("=" * 78)
return True
if __name__ == "__main__":
ok = run_validation()
sys.exit(0 if ok else 1)