#!/usr/bin/env python3 """ 모멘텀 백테스트 공통 — backtest_web / param_search 단일 진입점. 실매 MomentumStrategy 와 동일: - 진입: ``momentum_engine.check_buy_signal_momentum_live`` - 청산: ws_ticks 틱 리플레이(실매 체결가) 또는 ``check_sell_signal_momentum_backtest_bar`` 폴백 """ 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, resolve_portfolio_params, resolve_trigger_snapshots_for_backtest, summarize_trades, ) from kis_trader.engine.momentum_engine import ( MOMENTUM_STRATEGY_ID, get_momentum_defaults_from_db, run_momentum_backtest, ) from kis_trader.engine.indicator_cache import ( materialize_ws_candles_batch, ws_candles_select_indicator_cols, ) MOMENTUM_STRATEGY = MOMENTUM_STRATEGY_ID def momentum_backtest_universe_strict_enabled() -> bool: """모멘텀 백테: 종목별 첫 event_time 이후 분만 유니버스 (실매 정합).""" from kis_trader.utils.env import get_env_bool return get_env_bool("MOMENTUM_BACKTEST_UNIVERSE_STRICT", True) def momentum_backtest_universe_strict_lag_min() -> int: """첫 편입 분 이후 추가 대기 분 (기본 1 → 09:42:25 편입은 09:43 분봉부터).""" from kis_trader.utils.env import get_env_int return max(0, int(get_env_int("MOMENTUM_BACKTEST_UNIVERSE_STRICT_LAG_MIN", 1))) def momentum_universe_exit_debounce_sec() -> int: """ 백테 EXIT 디바운스(초). 실매 ``CONDITION_EXIT_GRACE_SEC``(기본 120)가 이미 스냅샷에 반영되어 있으면 이중 디바운스를 쓰지 않음 — env ``MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC`` 로만 강제 가능. """ from kis_trader.utils.env import get_env_from_db, get_env_int raw = get_env_from_db("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", None) if raw not in (None, ""): try: return max(0, int(float(raw))) except (TypeError, ValueError): pass grace = int(get_env_int("CONDITION_EXIT_GRACE_SEC", 0)) if grace > 0: return 0 return max(0, int(get_env_int("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", 30))) def momentum_backtest_candle_warmup_bars() -> int: """백테 지표 warm-up — 실매 ``get_candles(50)`` 과 동일하게 전일·당일 장전 봉 선행.""" from kis_trader.utils.env import get_env_int return max(0, int(get_env_int("MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", 50))) def prepend_momentum_candle_warmup( db, candles_by_code: Dict[str, List[Dict]], period_start_key: str, *, warmup_bars: Optional[int] = None, ) -> int: """ ``period_start_key``(YYYYMMDDHHMM) 이전 N봉을 종목별로 prepend. RSI·EMA·패턴 판별용 — 루프 시각(all_times)에는 포함하지 않음. """ wb = ( momentum_backtest_candle_warmup_bars() if warmup_bars is None else max(0, int(warmup_bars)) ) if wb <= 0 or db is None or not period_start_key: return 0 ps = str(period_start_key)[:12] ind_cols = ws_candles_select_indicator_cols(db) total_prepended = 0 for code, rows in list(candles_by_code.items()): if not rows: continue first_period_idx = None for i, r in enumerate(rows): ct = str(r.get("candle_time") or "") if ct >= ps: first_period_idx = i break if first_period_idx is None: continue if first_period_idx > 0: continue first_ct = str(rows[first_period_idx].get("candle_time") or "") if not first_ct: continue warm_rows = db.conn.execute( f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} " "FROM ws_candles WHERE timeframe=1 AND code=%s " "AND candle_time < %s ORDER BY candle_time DESC LIMIT %s", [code, first_ct, wb], ).fetchall() if not warm_rows: continue prefix = [dict(r) for r in reversed(warm_rows)] candles_by_code[code] = prefix + [dict(r) for r in rows] total_prepended += len(prefix) if total_prepended > 0: materialize_ws_candles_batch(db, candles_by_code, 1) return total_prepended def date_keys(start: str, end: str) -> Tuple[str, str, str, str]: start_key = start.replace("-", "") + "0000" end_key = end.replace("-", "") + "2359" return start_key, end_key, start_key[:8], end_key[:8] def resolve_momentum_universe( start_ymd: str, end_ymd: str, *, use_saved_history: bool, strategy_id: str = MOMENTUM_STRATEGY_ID, ) -> Tuple[Optional[Dict[str, List[str]]], str, int, int, str]: """ Returns: universe_by_slot, source_label, slot_count, scan_interval_min, universe_timing universe_timing: 'strict' | 'minute' | 'all' """ if use_saved_history and strategy_id: try: from kis_trader.database.db_manager import get_db as _get_ext_db strict = momentum_backtest_universe_strict_enabled() lag_min = momentum_backtest_universe_strict_lag_min() debounce_sec = momentum_universe_exit_debounce_sec() history = _get_ext_db().get_universe_by_candle_time( strategy_id=strategy_id, start_ymd=start_ymd, end_ymd=end_ymd, strict=strict, strict_lag_minutes=lag_min, exit_debounce_sec=debounce_sec, ) if history: timing = "strict" if strict else "minute" label = "history_strict" if strict else "history" return history, label, len(history), 1, timing except Exception: pass return None, "all", 0, 1, "all" def load_momentum_candles_by_code( db, start_key: str, end_key: str, *, warmup_bars: Optional[int] = None, ) -> Tuple[Dict[str, List[Dict]], int]: period_start = str(start_key)[:12] 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] ind_cols = ws_candles_select_indicator_cols(db) candles_by_code: Dict[str, List[Dict]] = {} total = 0 for code in codes: rows = db.conn.execute( f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} " "FROM ws_candles WHERE timeframe=1 AND code=%s " "AND candle_time >= %s AND candle_time <= %s " "ORDER BY candle_time ASC", [code, start_key, end_key], ).fetchall() if len(rows) < 6: continue candles_by_code[code] = [dict(r) for r in rows] total += len(rows) prepend_momentum_candle_warmup( db, candles_by_code, period_start, warmup_bars=warmup_bars, ) materialize_ws_candles_batch(db, candles_by_code, 1) return candles_by_code, total def resolve_momentum_portfolio_params( env_row: Dict[str, Any], base_params: Dict[str, Any], *, 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_params, strategy="MOMENTUM", slot_money=slot_money, max_stocks=max_stocks, total_budget_krw=total_budget_krw, ) def run_momentum_backtest_web_aligned( candles_by_code: Dict[str, List[Dict]], params: Dict[str, Any], universe_by_slot: Optional[Dict[str, List[str]]] = None, *, slot_money: float = 3_000_000.0, fee_rate: float = 0.00015, sell_tax: float = 0.0018, max_stocks: int = 3, total_budget_krw: float = 0.0, 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]: p = dict(params) warmup_prepended = 0 period_start_key = "" if meta_out is not None: start_key_meta = str(meta_out.get("start_key") or "") if len(start_key_meta) >= 12: period_start_key = start_key_meta[:12] p["_backtest_period_start_key"] = period_start_key db_meta = meta_out.get("db") if db_meta is not None and period_start_key: warmup_prepended = prepend_momentum_candle_warmup( db_meta, candles_by_code, period_start_key, ) p["slot_money"] = float(slot_money) p["fee_rate"] = float(fee_rate) p["sell_tax"] = float(sell_tax) p["max_stocks"] = int(max_stocks) if total_budget_krw > 0: p["total_budget_krw"] = float(total_budget_krw) p.setdefault("portfolio_mode", True) loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {}) tick_meta: Dict[str, Any] = {} from kis_trader.engine.momentum_tick_replay import ( momentum_backtest_use_tick_entry, momentum_backtest_use_tick_exit, ) if momentum_backtest_use_tick_exit(p) or momentum_backtest_use_tick_entry(p): from kis_trader.backtest.momentum_tick_loader import ( load_momentum_ticks_by_code, tick_coverage_stats, ) 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_momentum_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.momentum_backtest").warning( "⚠️ ws_ticks 데이터 없음 — 1분봉 OHLC 청산 폴백 (틱 수집 후 재백테 권장)", ) 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() ) # ── 틱 전무 종목 제외 (실매 정합) ────────────────────────────── # 틱이 하나도 없는 종목은 개장 순간 반짝 후보(유니버스 2분 존속)라 실제 # 매매 불가. 남겨두면 OHLC 폴백으로 유령 거래를 만들어 순위를 오염시킨다. from kis_trader.engine.momentum_tick_replay import ( momentum_backtest_tick_only_codes, ) if loaded_ticks and momentum_backtest_tick_only_codes(p): tick_codes = {c for c, m in loaded_ticks.items() if any(m.values())} before_n = len(candles_by_code) dropped = [c for c in candles_by_code if c not in tick_codes] if dropped and len(tick_codes) > 0: candles_by_code = { c: v for c, v in candles_by_code.items() if c in tick_codes } tick_meta["tick_only_codes_dropped"] = len(dropped) tick_meta["tick_only_codes_kept"] = len(candles_by_code) from kis_trader.utils.logger import get_logger as _get_logger _get_logger("kis_trader.momentum_backtest").info( "🎯 틱 전무 종목 %d개 제외 (%d→%d종목) — 틱 있는 종목만 백테", len(dropped), before_n, len(candles_by_code), ) ob_loaded, pg_loaded, snap_meta = resolve_trigger_snapshots_for_backtest( candles_by_code, p, strategy="MOMENTUM", meta_out=meta_out, orderbook_by_code=orderbook_by_code, program_by_code=program_by_code, ) if snap_meta.get("log_verdict_by_code"): p["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"] if meta_out is not None: start_key = str(meta_out.get("start_key") or "") end_key = str(meta_out.get("end_key") or "") if len(start_key) >= 8 and len(end_key) >= 8: from kis_trader.backtest.momentum_universe_timeline import ( attach_momentum_universe_timeline_to_params, ) attach_momentum_universe_timeline_to_params( p, start_ymd=start_key[:8], end_ymd=end_key[:8], strategy_id=MOMENTUM_STRATEGY_ID, use_saved_history=universe_by_slot is not None, ) trades = run_momentum_backtest( candles_by_code, p, universe_by_slot=universe_by_slot, ticks_by_code=loaded_ticks or ticks_by_code, orderbook_by_code=ob_loaded, program_by_code=pg_loaded, ) if not p.get("portfolio_mode"): attach_scalp_trade_pnl( trades, fee_rate=fee_rate, sell_tax=sell_tax, slip_pct=backtest_slip_pct(p), ) if meta_out is not None: meta_out["skip_stats"] = p.get("_portfolio_skip_stats") or {} if warmup_prepended > 0 or momentum_backtest_candle_warmup_bars() > 0: meta_out["skip_stats"]["candle_warmup_bars"] = momentum_backtest_candle_warmup_bars() meta_out["skip_stats"]["candle_warmup_prepended"] = warmup_prepended meta_out["universe_timing"] = ( "strict" if momentum_backtest_universe_strict_enabled() else "minute" ) if p.get("_universe_timeline_meta"): meta_out["universe_timeline"] = p.get("_universe_timeline_meta") if snap_meta: meta_out["trigger_snapshot_backtest"] = snap_meta if tick_meta: meta_out["tick_backtest"] = tick_meta return trades def summarize_momentum_trades( trades: List[Dict], *, total_budget_krw: float, period_days: int, ) -> Dict[str, Any]: return summarize_trades(trades, total_budget_krw=total_budget_krw, period_days=period_days) def count_momentum_sell_reasons(trades: List[Dict]) -> Dict[str, int]: out: Dict[str, int] = {} for t in trades: r = str(t.get("sell_reason") or "기타") out[r] = out.get(r, 0) + 1 return out