feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
304
kis_trader/backtest/breakout_backtest_common.py
Normal file
304
kis_trader/backtest/breakout_backtest_common.py
Normal file
@@ -0,0 +1,304 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
돌파매매 백테스트 공통 로더 — backtest_web / param_search 가
|
||||
동일한 캔들·유니버스·손익 계산을 쓰도록 단일 진입점.
|
||||
|
||||
청산: ``check_sell_signal_breakout_live`` — EOD → 익절 → 어깨 → 손절 → 트레일.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from kis_trader.backtest.backtest_portfolio_common import (
|
||||
attach_scalp_trade_pnl,
|
||||
backtest_slip_pct,
|
||||
build_budget_warning,
|
||||
fee_and_slot_from_env_row,
|
||||
merge_portfolio_into_params,
|
||||
min_invest_ratio_of_slot,
|
||||
resolve_portfolio_params,
|
||||
resolve_trigger_snapshots_for_backtest,
|
||||
summarize_trades,
|
||||
)
|
||||
from kis_trader.backtest.breakout_tick_loader import (
|
||||
load_breakout_ticks_by_code,
|
||||
tick_coverage_stats,
|
||||
)
|
||||
from kis_trader.engine.indicator_cache import (
|
||||
materialize_ws_candles_batch,
|
||||
ws_candles_select_indicator_cols,
|
||||
)
|
||||
from kis_trader.share.stock_share import attach_share_denoms_to_params
|
||||
from kis_trader.strategies.breakout import (
|
||||
breakout_backtest_wants_tick_replay,
|
||||
breakout_min_bars_required,
|
||||
run_breakout_backtest,
|
||||
)
|
||||
|
||||
BREAKOUT_STRATEGY_ID = "BREAKOUT"
|
||||
|
||||
|
||||
def breakout_backtest_universe_scan_at_enabled(params: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""백테 유니버스: 1분 슬롯 대신 초단위 스캔시각 타임라인 (기본 ON, 실매 정합).
|
||||
|
||||
실매 돌파는 봉 마감 시점의 조건검색 유니버스를 본다. 1분 슬롯(strict lag)은
|
||||
편입을 최대 1분 늦춰 실매와 어긋난다. 초단위 타임라인은 그 봉 마감(HH:MM:59)
|
||||
직전 최신 스냅샷을 그대로 써 실매 ``get_universe_at`` 와 정합.
|
||||
끄려면 env BREAKOUT_BACKTEST_UNIVERSE_SCAN_AT=0.
|
||||
"""
|
||||
if params is not None and params.get("backtest_universe_scan_at") is not None:
|
||||
s = str(params.get("backtest_universe_scan_at")).strip().lower()
|
||||
if s in ("1", "true", "t", "y", "yes", "on"):
|
||||
return True
|
||||
if s in ("0", "false", "f", "n", "no", "off", ""):
|
||||
return False
|
||||
from kis_trader.utils.env import get_env_bool
|
||||
return get_env_bool("BREAKOUT_BACKTEST_UNIVERSE_SCAN_AT", True)
|
||||
|
||||
|
||||
def date_keys(start: str, end: str) -> Tuple[str, str, str, str]:
|
||||
"""YYYY-MM-DD → candle_time 키 및 ymd."""
|
||||
start_key = start.replace("-", "") + "0000"
|
||||
end_key = end.replace("-", "") + "2359"
|
||||
return start_key, end_key, start_key[:8], end_key[:8]
|
||||
|
||||
|
||||
def resolve_breakout_universe(
|
||||
start_ymd: str,
|
||||
end_ymd: str,
|
||||
*,
|
||||
use_saved_history: bool,
|
||||
strategy_id: str = BREAKOUT_STRATEGY_ID,
|
||||
) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]:
|
||||
if use_saved_history and strategy_id:
|
||||
try:
|
||||
from kis_trader.database.db_manager import get_db as _get_ext_db
|
||||
|
||||
history = _get_ext_db().get_universe_by_candle_time(
|
||||
strategy_id=strategy_id,
|
||||
start_ymd=start_ymd,
|
||||
end_ymd=end_ymd,
|
||||
)
|
||||
if history:
|
||||
return history, "history", len(history), 1
|
||||
except Exception:
|
||||
pass
|
||||
return None, "all", 0, 1
|
||||
|
||||
|
||||
def load_breakout_candles_by_code(
|
||||
db,
|
||||
start_key: str,
|
||||
end_key: str,
|
||||
lookback_min: int = 1,
|
||||
vol_window: int = 7,
|
||||
) -> Tuple[Dict[str, List[Dict]], int]:
|
||||
"""ws_candles 1분봉 전 종목 로드."""
|
||||
min_bars = breakout_min_bars_required({
|
||||
"lookback_min": lookback_min,
|
||||
"vol_window": int(vol_window),
|
||||
})
|
||||
|
||||
ind_cols = ws_candles_select_indicator_cols(db)
|
||||
codes_raw = db.conn.execute(
|
||||
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
|
||||
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
|
||||
[start_key, end_key],
|
||||
).fetchall()
|
||||
codes = [r["code"] for r in codes_raw]
|
||||
|
||||
candles_by_code: Dict[str, List[Dict]] = {}
|
||||
total_candles = 0
|
||||
|
||||
for code in codes:
|
||||
rows = db.conn.execute(
|
||||
f"SELECT candle_time, open, high, low, close, volume{ind_cols} "
|
||||
"FROM ws_candles WHERE timeframe=1 AND code=%s "
|
||||
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
|
||||
"ORDER BY candle_time ASC",
|
||||
[code, start_key, end_key],
|
||||
).fetchall()
|
||||
if len(rows) < min_bars:
|
||||
continue
|
||||
candles_by_code[code] = [dict(r) for r in rows]
|
||||
total_candles += len(rows)
|
||||
|
||||
materialize_ws_candles_batch(db, candles_by_code, 1)
|
||||
return candles_by_code, total_candles
|
||||
|
||||
|
||||
def run_breakout_backtest_web_aligned(
|
||||
candles_by_code: Dict[str, List[Dict]],
|
||||
params: Dict[str, Any],
|
||||
universe_by_slot: Optional[Dict[str, List[str]]],
|
||||
*,
|
||||
slot_money: float,
|
||||
fee_rate: float,
|
||||
sell_tax: float,
|
||||
max_stocks: Optional[int] = None,
|
||||
total_budget_krw: Optional[float] = None,
|
||||
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
|
||||
orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
|
||||
program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
|
||||
meta_out: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict]:
|
||||
"""엔진 1회 + 웹과 동일 손익 부착."""
|
||||
engine_params = dict(params)
|
||||
engine_params["slot_money"] = float(slot_money)
|
||||
if max_stocks is not None:
|
||||
engine_params["max_stocks"] = int(max_stocks)
|
||||
if total_budget_krw is not None:
|
||||
tb = float(total_budget_krw)
|
||||
engine_params["total_budget_krw"] = tb if tb > 0 else float(
|
||||
int(engine_params.get("max_stocks") or 3) * slot_money
|
||||
)
|
||||
if universe_by_slot is not None:
|
||||
engine_params.setdefault("scan_interval_min", 1)
|
||||
engine_params.setdefault("portfolio_mode", True)
|
||||
|
||||
# ── 초단위 유니버스 타임라인 (실매 get_universe_at 정합) ──────────────
|
||||
# 1분 슬롯(strict lag)의 "편입 +최대 1분 지연" 을 제거. 봉 마감(HH:MM:59) 직전
|
||||
# 최신 조건검색 스냅샷을 그대로 조회해 실매와 동일 시점 유니버스로 매수 판정.
|
||||
if universe_by_slot is not None and breakout_backtest_universe_scan_at_enabled(engine_params):
|
||||
_sk = str((meta_out or {}).get("start_key") or "")
|
||||
_ek = str((meta_out or {}).get("end_key") or "")
|
||||
if len(_sk) < 8 or len(_ek) < 8:
|
||||
# meta_out 키 없으면 캔들 시각 min/max 일자로 폴백
|
||||
_days = [
|
||||
str(c.get("candle_time") or "")[:8]
|
||||
for rows in candles_by_code.values() for c in rows
|
||||
if c.get("candle_time")
|
||||
]
|
||||
if _days:
|
||||
_sk, _ek = min(_days), max(_days)
|
||||
if len(_sk) >= 8 and len(_ek) >= 8:
|
||||
from kis_trader.backtest.universe_timeline import build_universe_timeline
|
||||
_tl = build_universe_timeline(
|
||||
strategy_id=BREAKOUT_STRATEGY_ID,
|
||||
start_ymd=_sk[:8], end_ymd=_ek[:8],
|
||||
debounce_sec=0, strict=False, strict_lag_minutes=0,
|
||||
)
|
||||
if _tl is not None:
|
||||
engine_params["_universe_timeline"] = _tl
|
||||
if meta_out is not None:
|
||||
meta_out["universe_timing"] = "scan_at"
|
||||
meta_out["universe_timeline_snapshots"] = _tl.snapshot_count
|
||||
|
||||
db_for_share = (meta_out or {}).get("db")
|
||||
if db_for_share and "share_denom_by_code" not in engine_params:
|
||||
engine_params = attach_share_denoms_to_params(
|
||||
engine_params, db_for_share, candles_by_code.keys(),
|
||||
)
|
||||
|
||||
loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {})
|
||||
tick_meta: Dict[str, Any] = {}
|
||||
if breakout_backtest_wants_tick_replay(engine_params):
|
||||
if not loaded_ticks and meta_out is not None:
|
||||
start_key = str(meta_out.get("start_key") or "")
|
||||
end_key = str(meta_out.get("end_key") or "")
|
||||
db = meta_out.get("db")
|
||||
if db and start_key and end_key:
|
||||
loaded_ticks, tick_rows = load_breakout_ticks_by_code(
|
||||
db, start_key, end_key, set(candles_by_code.keys()),
|
||||
)
|
||||
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
|
||||
tick_meta["ws_tick_rows_loaded"] = tick_rows
|
||||
if tick_rows <= 0:
|
||||
from kis_trader.utils.logger import get_logger as _get_logger
|
||||
|
||||
_get_logger("kis_trader.breakout_backtest").warning(
|
||||
"⚠️ ws_ticks 데이터 없음 — B안 OHLC high 폴백 (틱 수집 후 재백테 권장)",
|
||||
)
|
||||
elif loaded_ticks:
|
||||
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
|
||||
tick_meta["ws_tick_rows_loaded"] = sum(
|
||||
len(lst) for cm in loaded_ticks.values() for lst in cm.values()
|
||||
)
|
||||
|
||||
ob_loaded, pg_loaded, snap_meta = resolve_trigger_snapshots_for_backtest(
|
||||
candles_by_code, engine_params, strategy="BREAKOUT", meta_out=meta_out,
|
||||
orderbook_by_code=orderbook_by_code, program_by_code=program_by_code,
|
||||
)
|
||||
if snap_meta.get("log_verdict_by_code"):
|
||||
engine_params["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"]
|
||||
|
||||
trades = run_breakout_backtest(
|
||||
candles_by_code,
|
||||
engine_params,
|
||||
universe_by_slot=universe_by_slot,
|
||||
ticks_by_code=loaded_ticks or None,
|
||||
orderbook_by_code=ob_loaded,
|
||||
program_by_code=pg_loaded,
|
||||
)
|
||||
attach_scalp_trade_pnl(
|
||||
trades, fee_rate=fee_rate, sell_tax=sell_tax,
|
||||
slip_pct=backtest_slip_pct(engine_params),
|
||||
)
|
||||
if meta_out is not None:
|
||||
skip_stats = engine_params.get("_portfolio_skip_stats") or {}
|
||||
meta_out["skip_stats"] = dict(skip_stats)
|
||||
meta_out["engine_params"] = engine_params
|
||||
if tick_meta:
|
||||
meta_out["tick_backtest"] = tick_meta
|
||||
mode = engine_params.get("entry_mode", "intrabar")
|
||||
if tick_meta.get("ws_tick_rows_loaded", 0) > 0:
|
||||
meta_out["backtest_buy_source"] = "ws_ticks"
|
||||
elif breakout_backtest_wants_tick_replay(engine_params):
|
||||
meta_out["backtest_buy_source"] = "ohlc_fallback"
|
||||
else:
|
||||
meta_out["backtest_buy_source"] = mode
|
||||
if snap_meta:
|
||||
meta_out["trigger_snapshot_backtest"] = snap_meta
|
||||
return trades
|
||||
|
||||
|
||||
def resolve_breakout_portfolio_params(
|
||||
env_row: Optional[Dict[str, Any]],
|
||||
base_defaults: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
slot_money: Optional[float] = None,
|
||||
max_stocks: Optional[int] = None,
|
||||
total_budget_krw: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return resolve_portfolio_params(
|
||||
env_row,
|
||||
base_defaults,
|
||||
strategy="BREAKOUT",
|
||||
slot_money=slot_money,
|
||||
max_stocks=max_stocks,
|
||||
total_budget_krw=total_budget_krw,
|
||||
)
|
||||
|
||||
|
||||
def merge_breakout_portfolio_into_params(
|
||||
params: Dict[str, Any],
|
||||
portfolio: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
return merge_portfolio_into_params(params, portfolio)
|
||||
|
||||
|
||||
def build_breakout_budget_warning(
|
||||
portfolio: Dict[str, Any],
|
||||
skip_stats: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[str]:
|
||||
ratio = min_invest_ratio_of_slot({}, strategy="BREAKOUT")
|
||||
return build_budget_warning(portfolio, skip_stats, min_invest_ratio=ratio)
|
||||
|
||||
|
||||
def summarize_breakout_trades(
|
||||
trades: List[Dict],
|
||||
*,
|
||||
total_budget_krw: float,
|
||||
period_days: int = 1,
|
||||
) -> Dict[str, Any]:
|
||||
return summarize_trades(
|
||||
trades,
|
||||
total_budget_krw=total_budget_krw,
|
||||
period_days=period_days,
|
||||
)
|
||||
|
||||
|
||||
def fee_and_slot_from_env(
|
||||
row: Optional[Dict[str, Any]],
|
||||
) -> Tuple[float, float, float]:
|
||||
return fee_and_slot_from_env_row(row, strategy="BREAKOUT")
|
||||
Reference in New Issue
Block a user