#!/usr/bin/env python3 """ scripts/test_live_execution_validation.py ========================================= 실매 코어 스모크 (주문·REST 연타·실WS 연결 없음). 반드시 **DB에서 읽은 실제 ON/OFF** 를 먼저 찍고, 그 값에 맞춰 기대 동작을 검사한다. 가상 호가 주입은 「DB에서 필터 ON인 전략」에만 실매와 동일하게 적용한다. (OFF 전략에 가짜 탈락을 찍지 않음 — 헷갈림 방지) 추가로 검증: · ws_candles source/channel 정규화 · 실매=Optuna 읽기쌍 · 갭보정 키움 REST만 (KIS REST 강제 무시) · 영구구독 마스터 · LS DB 적재 가드 · 운영설정 UI 키 · WS close 전 구독해제(한투/LS/키움) · freeze ON · 호가 TTL 분리 · Optuna OHLC 폴백 강제 OFF · approval 공유 · SKIP_HTS DB 표기 실행 (중요 코드 수정 후 완료 보고 전 최소 1회): python3 -u scripts/test_live_execution_validation.py # 로그: logs/test_live_execution_validation_YYYYMMDD_HHMMSS.log """ from __future__ import annotations import logging import sys import threading import time from datetime import datetime from pathlib import Path from typing import Any, 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 _소스필수(실패목록: List[str], obj: Any, needle: str, 라벨: str) -> None: """실소켓/REST 없이 소스 문자열만 검사.""" import inspect try: src = inspect.getsource(obj) except (OSError, TypeError) as exc: 실패목록.append(f"{라벨} getsource 실패") _실패(f"{라벨}: getsource 실패 ({exc})") return if needle not in src: 실패목록.append(f"{라벨} 누락") _실패(f"{라벨}: `{needle[:72]}` 없음") else: _성공(라벨) def _파일필수(실패목록: List[str], rel: str, needle: str, 라벨: str) -> None: path = ROOT / rel try: text = path.read_text(encoding="utf-8") except OSError as exc: 실패목록.append(f"{라벨} 파일없음") _실패(f"{라벨}: {rel} 읽기 실패 ({exc})") return if needle not in text: 실패목록.append(f"{라벨} 누락") _실패(f"{라벨}: `{needle[:72]}` 없음 ({rel})") else: _성공(라벨) def _파일금지(실패목록: List[str], rel: str, needle: str, 라벨: str) -> None: path = ROOT / rel try: text = path.read_text(encoding="utf-8") except OSError as exc: 실패목록.append(f"{라벨} 파일없음") _실패(f"{라벨}: {rel} 읽기 실패 ({exc})") return if needle in text: 실패목록.append(f"{라벨} 금지패턴") _실패(f"{라벨}: `{needle[:72]}` 가 있으면 안 됨 ({rel})") else: _성공(라벨) 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 "kis" not in 라벨2 or "spill" not in 라벨2: 실패목록.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 주문.ob_feed_label_provider = getattr(허브2, "get_ob_feed_label", None) mm줄 = 주문._tick_feed_mm_line("000660") if "시세:" not in mm줄 or "호가:" not in mm줄: 실패목록.append(f"MM 시세줄 이상 {mm줄}") _실패(f"매터모스트 시세/호가 표기 이상: {mm줄}") else: _성공(f"체결 알림 시세·호가 한 줄 예: {mm줄}") # 메인 2초 실패 → 2차 get_price (RAM 없음=실패, REST 없음) 허브_fb = WSManager(db=_가짜DB(), kis_client=MagicMock()) 키움_stale = MagicMock() 키움_stale.get_price = MagicMock(return_value=None) 한투_ok = MagicMock() 한투_ok.get_price = MagicMock(return_value={"stck_prpr": "12345"}) 허브_fb._kiwoom_ws = 키움_stale 허브_fb.ws_cache = 한투_ok 허브_fb._get_ls_ws = MagicMock(return_value=None) px_fb = 허브_fb.get_price("005930") lab_fb = 허브_fb.get_tick_feed_label("005930") if not isinstance(px_fb, dict) or str(px_fb.get("stck_prpr") or "") != "12345": 실패목록.append(f"get_price 2차 폴백 실패 {px_fb}") _실패(f"메인 None 인데 2차 현재가를 안 씀: {px_fb}") elif "kis" not in str(lab_fb): 실패목록.append(f"2차 시세라벨 이상 {lab_fb}") _실패(f"2차 폴백 라벨에 kis 없음: {lab_fb}") else: _성공(f"메인 실패→2차 시세 {px_fb.get('stck_prpr')} 라벨={lab_fb}") 허브_fb3 = WSManager(db=_가짜DB(), kis_client=MagicMock()) 키움_n = MagicMock(); 키움_n.get_price = MagicMock(return_value=None) 한투_n = MagicMock(); 한투_n.get_price = MagicMock(return_value=None) 엘에스_ok = MagicMock(); 엘에스_ok.get_price = MagicMock(return_value={"stck_prpr": "777"}) 허브_fb3._kiwoom_ws = 키움_n 허브_fb3.ws_cache = 한투_n 허브_fb3._get_ls_ws = MagicMock(return_value=엘에스_ok) px3 = 허브_fb3.get_price("005930") lab3 = 허브_fb3.get_tick_feed_label("005930") if not isinstance(px3, dict) or str(px3.get("stck_prpr") or "") != "777": 실패목록.append(f"get_price 3차 폴백 실패 {px3}") _실패(f"1·2차 None 인데 3차 LS 를 안 씀: {px3}") elif "ls" not in str(lab3): 실패목록.append(f"3차 시세라벨 이상 {lab3}") _실패(f"3차 폴백 라벨에 ls 없음: {lab3}") else: _성공(f"1·2차 실패→3차 LS {px3.get('stck_prpr')} 라벨={lab3}") from kis_trader.engine.feed_fallback import merge_ticks_time_axis_fallback merged_empty = merge_ticks_time_axis_fallback( [ {"tick_time": "20260819120000", "source": "kiwoom", "price": 1, "_lag_sec": 9}, {"tick_time": "20260819120000", "source": "kis", "price": 2, "_lag_sec": 8}, ], main_src="kiwoom", max_lag_sec=2.0, ) if merged_empty: 실패목록.append(f"늦은 보조를 씀 {merged_empty}") _실패("메인·보조 둘 다 2초 초과인데 틱을 남김") else: _성공("메인·보조 둘 다 늦으면 그 초 틱 없음(OHLC 메우지 않음)") 허브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", "LIVE_FEED_FALLBACK_MAX_AGE_SEC", "LS_FEED_FALLBACK_SUBSCRIBE", "LS_WS_HOLD_RAM_KEEP_HOLDINGS", "LS_WS_MAX_SUBSCRIPTIONS", "LS_GAP_FILL_CANDIDATES", "LS_WS_TICK_SAVE", "LS_WS_ALSO_HOGA", "LS_WS_ORDERBOOK_SAVE", ): 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", "LIVE_FEED_FALLBACK_MAX_AGE_SEC", "LS_FEED_FALLBACK_SUBSCRIBE", "LS_WS_HOLD_RAM_KEEP_HOLDINGS", "LS_WS_MAX_SUBSCRIPTIONS", "LS_WS_TICK_SAVE", "LS_WS_ALSO_HOGA", "LS_WS_ORDERBOOK_SAVE", "CANDLE_GARBAGE_FALLBACK", "WS_ORDERBOOK_SAVE_KIS", ): 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}") elif ("ls", "ws") not in pairs or len(pairs) < 5: 실패목록.append(f"읽기쌍 길이 {pairs}") _실패(f"메인WS+2차WS+LS+rest+rollup 5쌍이어야 함: {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 시 빈 집합") if not hasattr(WSManager, "_sync_feed_fallback_to_ls"): 실패목록.append("_sync_feed_fallback_to_ls 없음") _실패("LS 3차 폴백 구독 owner=_feed_fallback 없음") else: fb_src = inspect.getsource(WSManager._sync_feed_fallback_to_ls) if "LS_FEED_FALLBACK_SUBSCRIBE" not in fb_src: 실패목록.append("LS_FEED_FALLBACK_SUBSCRIBE 미적용") _실패("_sync_feed_fallback_to_ls 가 LS_FEED_FALLBACK_SUBSCRIBE 를 안 봄") else: _성공("LS 3차 폴백 구독 _sync_feed_fallback_to_ls") rec_src = inspect.getsource(WSManager._reconcile_split_subscriptions_locked) if "_finalize_ls_subscriptions" not in rec_src: 실패목록.append("split reconcile LS finalize 없음") _실패("_reconcile_split_subscriptions 가 _finalize_ls_subscriptions 를 안 부름") else: _성공("split reconcile → _finalize_ls_subscriptions (spill/permanent)") if not hasattr(WSManager, "_prune_ls_ephemeral_subscriptions"): 실패목록.append("_prune_ls_ephemeral_subscriptions 없음") _실패("LS spill grace 정리 헬퍼 없음") else: prune_src = inspect.getsource(WSManager._prune_ls_ephemeral_subscriptions) if 'owner="spill"' not in prune_src: 실패목록.append("LS spill prune 없음") _실패("_prune_ls_ephemeral_subscriptions 가 spill unsubscribe 안 함") else: _성공("LS spill/_feed_fallback ephemeral prune") st_src = inspect.getsource(WSManager.sync_targets) if "_finalize_ls_subscriptions" not in st_src: 실패목록.append("sync_targets LS finalize 없음") _실패("sync_targets 가 _finalize_ls_subscriptions 를 안 부름") else: _성공("sync_targets 끝에서 LS finalize (MINIMAL OFF 경로)") fb_src = inspect.getsource(WSManager._sync_feed_fallback_to_ls) if 'LS_FEED_FALLBACK_SUBSCRIBE", False' in fb_src or "LS_FEED_FALLBACK_SUBSCRIBE', False" in fb_src: _성공("LS_FEED_FALLBACK_SUBSCRIBE 코드 기본값=false (미러 OFF)") else: 실패목록.append("LS_FEED_FALLBACK 기본 false 아님") _실패("_sync_feed_fallback_to_ls get_env_bool 기본값이 false 가 아님") db_fb = get_env_bool("LS_FEED_FALLBACK_SUBSCRIBE", False) if db_fb: print( f" ⚠️ DB LS_FEED_FALLBACK_SUBSCRIBE=ON — 레거시 미러 활성. " f"spill-only 운영 시 웹/DB에서 OFF 권장 (현재={_db원문('LS_FEED_FALLBACK_SUBSCRIBE')})" ) start_src = inspect.getsource(WSManager.start) if "kis_ws_ob = self.ws_cache" in start_src: 실패목록.append("한투 호가 메인 폴백") _실패("WSManager.start 가 kis_ws_ob=메인 폴백(ASP0 합산)을 함") elif "kis_ws_ob = None" not in start_src: 실패목록.append("kis_ws_ob None 미설정") _실패("2키 실패 시 kis_ws_ob=None 이 없음") else: _성공("한투 호가: 2키 전용, 메인 ASP0 폴백 없음") try_ob = inspect.getsource(WSManager._try_subscribe_vendor_ob) if "ob is self.ws_cache" not in try_ob: 실패목록.append("OB 구독 메인 거절 없음") _실패("_try_subscribe_vendor_ob 가 메인=OB 를 거절하지 않음") else: _성공("KIS 호가 구독은 전용 세션만") from unittest.mock import patch as _patch from kis_trader.engine.feed_fallback import bar_is_garbage, live_bar_is_garbage _ct = "202608191000" with _patch( "kis_trader.engine.feed_fallback.candle_garbage_fallback_enabled", return_value=True, ), _patch( "kis_trader.engine.feed_fallback.live_feed_fallback_max_age_sec", return_value=3.0, ): if not bar_is_garbage([], candle_time=_ct, tf_min=1, source="kiwoom"): 실패목록.append("쓰레기 0건 실패") _실패("bar_is_garbage 틱 0건은 구멍이어야 함") else: _성공("분봉 쓰레기: 그 분 틱 0건 = 구멍") stale_in = [{ "source": "kiwoom", "tick_time": "20260819100000", "tick_time_raw": "20260819100000", }] if not bar_is_garbage(stale_in, candle_time=_ct, tf_min=1, source="kiwoom"): 실패목록.append("쓰레기 stale 실패") _실패("bar_is_garbage 전부 봉끝 대비 나이초과면 구멍이어야 함") else: _성공("분봉 쓰레기: 전부 봉끝 대비 나이초과 = 구멍") fresh = [{ "source": "kiwoom", "tick_time": "20260819100058", "tick_time_raw": "20260819100058", }] if bar_is_garbage(fresh, candle_time=_ct, tf_min=1, source="kiwoom"): 실패목록.append("신선 봉을 쓰레기로") _실패("봉끝 2초 전 틱은 유지여야 함") else: _성공("분봉 쓰레기: 봉끝 직전 틱이면 유지") other = [{ "source": "kiwoom", "tick_time": "20260819100000", "tick_time_raw": "20260819100000", }] if live_bar_is_garbage(other, candle_time=_ct, tf_min=1, source="ls"): 실패목록.append("LS 봉 오판") _실패("실매 링에 LS 틱 없으면 LS 봉을 버리면 안 됨") else: _성공("실매 링: 해당 소스 틱 없으면 봉 유지") from kis_trader.ws.candle_series import pick_bar_by_read_pairs kis_ok = [{ "source": "kis", "tick_time": "20260819100058", "tick_time_raw": "20260819100058", }] picked = pick_bar_by_read_pairs( [ {"candle_time": _ct, "close": 1, "source": "kiwoom", "channel": "ws"}, {"candle_time": _ct, "close": 2, "source": "kis", "channel": "ws"}, ], (("kiwoom", "ws"), ("kis", "ws")), ticks=stale_in + kis_ok, tf_min=1, missing_policy="hole", live_cover=False, ) if not picked or int(picked.get("close") or 0) != 2: 실패목록.append("pick 2차 실패") _실패(f"메인 쓰레기면 2차 봉 통째: {picked}") else: _성공("pick_bar: 메인 쓰레기 → 2차 봉 통째") from kis_trader.ws.ls_ws import LSWebSocketPriceCache ls_msg_src = inspect.getsource(LSWebSocketPriceCache._on_message) if '_tick_recorder and get_env_bool("LS_WS_TICK_SAVE"' in ls_msg_src: 실패목록.append("LS 틱콜백이 TICK_SAVE에 묶임") _실패("LS_WS_TICK_SAVE OFF 가 호가 틱동기 콜백까지 끔") elif "_tick_recorder" not in ls_msg_src: 실패목록.append("LS _tick_recorder 없음") _실패("LS _on_message 에 _tick_recorder 없음") else: _성공("LS 틱콜백은 TICK_SAVE와 분리(호가 적재 가능)") gap_src = inspect.getsource(WSManager._enqueue_ls_gap_fill) if "LS_GAP_FILL_CANDIDATES" not in gap_src: 실패목록.append("LS_GAP_FILL_CANDIDATES 가드 없음") _실패("후보 LS 갭 REST 기본 OFF 가드 없음") else: _성공("후보 LS 갭 REST 는 LS_GAP_FILL_CANDIDATES 가드") # 갭 집합에 perm 합집합 금지 (후보|보유만) recon_src = inspect.getsource(WSManager._reconcile_split_subscriptions_locked) 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 봉/VI 가드 False") else: _성공("should_persist_ls: 영구구독 코드만 True (봉·VI)") if ps.should_persist_ls("999999", {"005930"}): 실패목록.append("should_persist_ls False 기대") _실패("비영구 코드인데 LS 봉/VI 적재 허용") else: _성공("should_persist_ls: 후보 spill 코드 False (봉·VI)") # 체결틱 DB 게이트 = 구독 전체 (_ls_is_subscribed), 영구만이 아님 try: from pathlib import Path as _Path _main_py = (_Path(__file__).resolve().parents[1] / "kis_trader" / "main.py").read_text( encoding="utf-8", errors="replace", ) except Exception: _main_py = "" if "LS_WS_TICK_SAVE" in _main_py and "_ls_is_subscribed(code)" in _main_py: _성공("LS 체결틱 DB: 구독 전체(_ls_is_subscribed)") elif "LS_WS_TICK_SAVE" in _main_py and "and self._ls_should_persist(code)" in _main_py: 실패목록.append("LS 틱이 영구게이트") _실패("LS_WS_TICK_SAVE 가 _ls_should_persist(영구) — 구독전체여야 함") else: _주의("LS 틱 SAVE 게이트 패턴 확인 필요") from kis_trader.engine.feed_fallback import merge_ls_ticks_third_fallback _base = { "005930": { "202608191200": [ {"tick_time": "20260819120000", "source": "kis", "price": 1, "_lag_sec": 0}, ] } } _ls = { "005930": { "202608191200": [ {"tick_time": "20260819120000", "source": "ls", "price": 9, "_lag_sec": 0}, {"tick_time": "20260819120001", "source": "ls", "price": 8, "_lag_sec": 0}, ] } } _add = merge_ls_ticks_third_fallback(_base, _ls, max_lag_sec=3.0) _sec0 = [t for t in _base["005930"]["202608191200"] if str(t.get("tick_time")) == "20260819120000"] _sec1 = [t for t in _base["005930"]["202608191200"] if str(t.get("tick_time")) == "20260819120001"] if _add != 1 or any(str(t.get("source")) == "ls" for t in _sec0) or not _sec1: 실패목록.append(f"LS 틱3차 merge 이상 add={_add}") _실패(f"같은초 LS 중복/빈초 미채움: add={_add} sec0={_sec0} sec1={_sec1}") else: _성공("틱 3차 LS merge: 같은초 스킵·빈초만 채움") 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}") # ── 12) WS close 전 구독해제 (실소켓 없음 · 소스만) ────────────── print("\n[12단계] WS 종료·양보 전 구독해제 (한투 공문 정상 케이스)") print(" 소켓만 close 금지. 실WS/approval REST 호출 없음.") try: from kis_trader.ws.kis_ws_overseas import KISOverseasWebSocket from kis_trader.ws.kiwoom_ws import KiwoomWebSocketPriceCache from kis_trader.ws.ls_ws import LSWebSocketPriceCache _소스필수( 실패목록, KISWebSocketPriceCache.stop, "_unsubscribe_all_before_close", "한투 국내 stop → 구독해제", ) _소스필수( 실패목록, KISWebSocketPriceCache._force_close_socket, "_unsubscribe_all_before_close", "한투 국내 장양보 → 구독해제", ) _소스필수( 실패목록, KISOverseasWebSocket.stop, "_unsubscribe_all_before_close", "한투 해외 stop → 구독해제", ) _소스필수( 실패목록, KISOverseasWebSocket._force_close_socket, "_unsubscribe_all_before_close", "한투 해외 장양보 → 구독해제", ) _소스필수( 실패목록, LSWebSocketPriceCache.stop, "_graceful_unreg_all", "LS stop → UNREG", ) _소스필수( 실패목록, LSWebSocketPriceCache._force_close_socket, "ram_keep_codes", "LS 장양보 → UNREG 후 RAM trim(영구·보유 pin)", ) _소스필수( 실패목록, LSWebSocketPriceCache._send_raw, "_force_close_socket", "LS send 실패/락타임아웃 → UNREG 후 close", ) _소스필수( 실패목록, KiwoomWebSocketPriceCache.stop, "_unsubscribe_all_before_close", "키움 stop → REMOVE", ) _소스필수( 실패목록, WSManager.stop, "kis_ws_ob.stop", "WSManager.stop → 호가전용 소켓도 stop", ) _파일필수( 실패목록, "kis_trader/network/kiwoom_condition_manager.py", "CNSRCLR", "키움 조건검색 stop → CNSRCLR", ) except Exception as exc: 실패목록.append(f"구독해제 검증 오류: {exc}") _실패(f"구독해제 검증 오류: {exc}") import traceback traceback.print_exc() # ── 13) CRITICAL 잠금 (freeze / 호가TTL / OHLC / HTS skip / approval) ─ print("\n[13단계] CRITICAL 잠금 · 호가 TTL 분리 · Optuna OHLC 폴백 OFF") try: from kis_trader.engine.orderbook_filter import _fetch_snapshot from kis_trader.ws.kis_ws import CandleAggregator freeze_on = get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", True) print( f" CANDLE_FREEZE={'ON' if freeze_on else 'OFF'} " f"| DB {_db원문('WS_CANDLE_FREEZE_ON_CONFIRM')} " f"(코드기본=True, OFF는 사용자 명시 승인 전 금지)" ) if not freeze_on: 실패목록.append("WS_CANDLE_FREEZE_ON_CONFIRM=OFF") _실패("봉 freeze OFF — 사용자 승인 없이 금지") else: _성공("봉 freeze ON (확정봉 OHLCV 유지)") _파일금지( 실패목록, "kis_trader/ws/kis_ws.py", 'get_env_bool("WS_CANDLE_FREEZE_ON_CONFIRM", False)', "kis_ws freeze 코드기본 True", ) _소스필수( 실패목록, CandleAggregator.merge_confirmed_bars, "WS_CANDLE_FREEZE_ON_CONFIRM", "갭보정 병합 경로에 freeze 키 존재", ) _소스필수( 실패목록, _fetch_snapshot, "WS_ORDERBOOK_FILTER_MAX_AGE_SEC", "호가필터 판정나이 = FILTER_MAX_AGE", ) _소스필수( 실패목록, _fetch_snapshot, "WS_ORDERBOOK_TICK_MAX_AGE_SEC", "호가 저장TTL = TICK_MAX_AGE (판정과 분리)", ) print( f" 호가나이 DB: FILTER_MAX_AGE={_db원문('WS_ORDERBOOK_FILTER_MAX_AGE_SEC')} " f"| TICK_MAX_AGE={_db원문('WS_ORDERBOOK_TICK_MAX_AGE_SEC')}" ) # 호가 snap_time = 틱 packet_lag 와 동일 LIVE_FEED_FALLBACK 컷 from datetime import datetime as _dt from kis_trader.engine.feed_fallback import ( is_orderbook_snap_time_stale, live_feed_fallback_max_age_sec, ) from kis_trader.ws.orderbook_cache import OrderbookCache, OrderbookLevel, OrderbookSnapshot _fb = float(live_feed_fallback_max_age_sec() or 3.0) _fresh_st = _dt.now().strftime("%Y%m%d%H%M%S") _stale_st = (_dt.now().replace(year=2020)).strftime("%Y%m%d") + "130248" if _fb > 0: if is_orderbook_snap_time_stale(_stale_st): _성공(f"호가 snap_time 낡은 시각 → stale (LIVE_FEED_FALLBACK={_fb:g}s)") else: 실패목록.append("orderbook snap_time stale 미검출") _실패("호가 snap_time 낡음인데 is_orderbook_snap_time_stale=False") if not is_orderbook_snap_time_stale(_fresh_st): _성공("호가 snap_time 신선 → 통과") else: 실패목록.append("orderbook snap_time fresh 오탐") _실패("호가 snap_time 신선한데 stale 오탐") if not is_orderbook_snap_time_stale(""): _성공("호가 snap_time 빈값 → 버리지 않음(틱 lag None 과 동일)") else: 실패목록.append("orderbook snap_time empty 오탐") _실패("호가 snap_time 빈값인데 stale 오탐") _obc = OrderbookCache() # 강제로 매우 낡은 snap 주입 후 get(3) 거부·get(0) 유지 검증 _inj = OrderbookSnapshot( code="375500", bids=[OrderbookLevel(74000, 10)], asks=[OrderbookLevel(74100, 10)], total_bid_qty=10, total_ask_qty=10, ts=time.time(), # 수신 ts 는 신선 source="ls_uh1", snap_time=_stale_st, ) with _obc._lock: _obc._data["375500"] = _inj if _obc.get("375500", max_age_sec=3.0) is None: _성공("호가 get(3s): ts 신선·snap 낡음 → None (체인 실패)") else: 실패목록.append("orderbook get snap stale pass") _실패("호가 get(3s) 가 snap 낡은데 통과") if _obc.get("375500", max_age_sec=0.0) is not None: _성공("호가 get(0)=마지막 RAM: snap 낡아도 반환 (FILTER 구멍 방지)") else: 실패목록.append("orderbook get(0) snap reject") _실패("호가 get(0) 이 snap 때문에 None — FILTER_MAX_AGE=0 위반") # 신선한 LS 적재는 성공 _ok = _obc.update_from_ls_hoga( "005930", { "offerho1": "70000", "bidho1": "69900", "offerrem1": "5", "bidrem1": "5", "hotime": _dt.now().strftime("%H%M%S"), }, source="ls_uh1", ) if _ok is not None and _obc.get("005930", max_age_sec=3.0) is not None: _성공("호가 LS 신선 snap → RAM 적재·get(3s) OK") else: 실패목록.append("orderbook fresh ls commit fail") _실패("호가 신선 LS 적재/조회 실패") # stale body: hotime 가 지금과 3초 초과면 skip (장중 130248 은 거의 항상 skip) # 단위: 명시적 stale snap_time 주입 경로로 이미 get 검증함. ingest skip 은 helper 로. _파일필수( 실패목록, "kis_trader/ws/orderbook_cache.py", "_commit_if_fresh", "호가 RAM snap_time 컷(틱 skip_ram 정합)", ) _파일필수( 실패목록, "kis_trader/engine/feed_fallback.py", "is_orderbook_snap_time_stale", "호가 snap lag 헬퍼 = 틱 packet_lag", ) else: _주의("LIVE_FEED_FALLBACK=0 — 호가 snap_time 컷 OFF (레거시)") _파일필수( 실패목록, "kis_trader/backtest/param_search_optuna.py", 'base_params["backtest_tick_fallback_ohlc"] = False', "Optuna OHLC 폴백 강제 OFF", ) for sid in 전략목록: 키 = f"{sid}_SKIP_HTS_SCAN_DUPES" if 키 not in ENV_CONFIG_KEYS: 실패목록.append(f"ENV 누락 {키}") _실패(f"ENV_CONFIG_KEYS 에 {키} 없음") else: on = get_env_bool(키, False) print(f" {_전략표시(sid)} {키}={'ON' if on else 'OFF'} | DB {_db원문(키)}") if on: _주의(f"{키}=true — 운영 기본은 false. 사용자 지시 없이 바꾸지 말 것") else: _성공(f"{키}=false (운영 기본 유지)") _파일필수( 실패목록, "kis_trader/execution/order_worker.py", "AccountOrderWorker", "계좌 단일 주문 큐 Worker", ) _파일필수( 실패목록, "kis_trader/strategies/base.py", "_enqueue_sell", "매도 signal → OrderWorker", ) _파일필수( 실패목록, "kis_trader/strategies/base.py", "_enqueue_buy", "매수 signal → OrderWorker", ) _파일필수( 실패목록, "kis_trader/strategies/base.py", "_sell_inflight", "틱·루프 종목당 매도 intent 1장", ) if "ORDER_WORKER_MAX_QUEUE" not in ENV_CONFIG_KEYS: 실패목록.append("ENV 누락 ORDER_WORKER_MAX_QUEUE") _실패("ENV_CONFIG_KEYS 에 ORDER_WORKER_MAX_QUEUE 없음") else: _성공("ORDER_WORKER_MAX_QUEUE env 등록") if "TICK_SELL_ASYNC_PLACE" not in ENV_CONFIG_KEYS: 실패목록.append("ENV 누락 TICK_SELL_ASYNC_PLACE") _실패("ENV_CONFIG_KEYS 에 TICK_SELL_ASYNC_PLACE 없음") else: _성공("TICK_SELL_ASYNC_PLACE env 등록 (레거시·틱 place는 OrderWorker 경유)") _소스필수( 실패목록, KISWebSocketPriceCache._get_approval_key, "KISApprovalManager.instance", "한투 approval_key 공유 캐시", ) except Exception as exc: 실패목록.append(f"CRITICAL 잠금 검증 오류: {exc}") _실패(f"CRITICAL 잠금 검증 오류: {exc}") import traceback traceback.print_exc() # ── 최종 ─────────────────────────────────────────────────────── print("\n" + "=" * 78) if 실패목록: print(f"최종: 실패 {len(실패목록)}건") for x in 실패목록: print(f" - {x}") print("=" * 78) return False print("최종: 통과 (위 DB ON/OFF 표기가 실매 기준과 일치하는지 함께 확인)") print("👑 [최종 판정] 완결!") print("=" * 78) return True class _Tee: def __init__(self, *files: Any) -> None: self.files = files def write(self, s: str) -> None: for f in self.files: f.write(s) f.flush() def flush(self) -> None: for f in self.files: f.flush() def _setup_run_log() -> tuple: log_dir = ROOT / "logs" log_dir.mkdir(exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") path = log_dir / f"test_live_execution_validation_{ts}.log" fp = path.open("w", encoding="utf-8") sys.stdout = _Tee(sys.__stdout__, fp) # type: ignore[assignment] sys.stderr = _Tee(sys.__stderr__, fp) # type: ignore[assignment] print(f"로그 파일: {path}") return path, fp if __name__ == "__main__": log_path, log_fp = _setup_run_log() code = 1 try: ok = run_validation() print(f"로그 파일: {log_path}") code = 0 if ok else 1 finally: sys.stdout = sys.__stdout__ sys.stderr = sys.__stderr__ try: log_fp.close() except Exception: pass sys.exit(code)