#!/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)