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.
452 lines
20 KiB
Python
Executable File
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)
|