Files
kis_trader/scripts/test_live_execution_validation.py
Your Name 2c7ad867f4 거래 빠르게 안티에서 병신만든거 커서로
feat: Implement backtest source management and enhance candle data handling

Changes:
- Introduced a new function `_apply_backtest_source_env_from_request` to manage the environment variables for candle, tick, and order book sources based on incoming requests.
- Added a teardown function `_teardown_backtest_source_env` to ensure that environment variables do not persist between requests, enhancing the stability of the backtesting environment.
- Refactored existing code to utilize the new source management functions, improving code readability and maintainability.
- Added new utility functions in `bt_candle_source.py` for fetching and managing candle data, ensuring consistency with live trading data sources.

Impact:
- These changes improve the flexibility and reliability of the backtesting framework, allowing for better management of data sources and reducing the risk of cross-request contamination.
2026-08-13 16:03:40 +09:00

452 lines
20 KiB
Python
Executable File

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