#!/usr/bin/env python3 """ scripts/test_live_execution_validation.py ========================================= 실매 코어 스모크 (주문·REST 연타 없음). 반드시 **DB에서 읽은 실제 ON/OFF** 를 먼저 찍고, 그 값에 맞춰 기대 동작을 검사한다. 가상 호가 주입은 「DB에서 필터 ON인 전략」에만 실매와 동일하게 적용한다. (OFF 전략에 가짜 탈락을 찍지 않음 — 헷갈림 방지) 실행: python3 -u scripts/test_live_execution_validation.py # 로그 예: logs/test_live_execution_validation_*.log """ from __future__ import annotations import logging import sys import threading 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 ( live_universe_slot_align_enabled, 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 단절 · ALIGN (DB) ───────────────────────────────────── print("\n[5단계] 호가스냅 없음(None) · 유니버스 ALIGN (DB)") print(" hist = ALIGN 교집합용 이력테이블 (시세 LS WS 와 무관)") print(" kiwoom→target_candidates_history / ls→ls_candidates_history") for sid in 전략목록: 탈락, _ = orderbook_reject_for_entry( {"_backtest_orderbook_snapshot": None, "slot_money": 10_000_000}, sid, current_price=10000.0, ) if 탈락 is None: 동작 = "스냅 없으면 호가탈락 스킵(Fail-Open)" else: 동작 = f"스냅 없어도 차단({탈락})" print(f" {_전략표시(sid)} 스냅None → {동작}") for sid in 전략목록: align_on = live_universe_slot_align_enabled(sid) 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)} ALIGN={'ON' if align_on else 'OFF'} " f"| 유니버스소스={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", ): 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'}" ) 시세섹션 = 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"): if need not in ui키들: 실패목록.append(f"UI 누락 {need}") _실패(f"운영설정 UI에 {need} 없음") else: _성공(f"운영설정 UI 필드 있음: {need}") # ── 최종 ─────────────────────────────────────────────────────── 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)