#!/usr/bin/env python3 """ 스캘핑·모멘텀 백테스트 공통 로더 — backtest_web / param_search 가 동일한 캔들·유니버스·손익 계산을 쓰도록 단일 진입점. """ from __future__ import annotations from datetime import datetime 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, summarize_trades, ) from kis_trader.backtest.breakout_tick_loader import ( load_breakout_ticks_by_code, tick_coverage_stats, ) from kis_trader.engine import scalping_engine as se from kis_trader.engine.tick_exit_common import strategy_use_tick_exit from kis_trader.utils.env import get_env_bool SCALP_STRATEGY_ID = "SCALP" MOMENTUM_STRATEGY_ID = "MOMENTUM" 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_scalp_universe( start_ymd: str, end_ymd: str, *, use_saved_history: bool, strategy_id: str = SCALP_STRATEGY_ID, history_source: str = "kiwoom", ) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]: """ backtest_web 유니버스 해석과 동일. Returns: (universe_by_slot, source_label, history_slot_count, scan_interval_min) """ if use_saved_history and strategy_id: try: from kis_trader.database.db_manager import get_db as _get_ext_db from kis_trader.backtest.universe_history_source import ( history_source_label, resolve_backtest_universe_history_source, ) debounce_sec = scalp_universe_exit_debounce_sec() hs = resolve_backtest_universe_history_source(history_source) history = _get_ext_db().get_universe_by_candle_time( strategy_id=strategy_id, start_ymd=start_ymd, end_ymd=end_ymd, exit_debounce_sec=debounce_sec, history_source=hs, ) if history: return history, history_source_label(hs), len(history), 1 except Exception: pass return None, "all", 0, 1 def scalp_universe_exit_debounce_sec() -> int: """실매 ``CONDITION_EXIT_GRACE_SEC`` 정합 — 스냅샷 축소 시 N초 유지.""" from kis_trader.backtest.universe_timeline import universe_exit_debounce_sec_for_strategy return universe_exit_debounce_sec_for_strategy("SCALP") def scalp_backtest_candle_warmup_bars() -> int: """백테 RSI 등 warm-up — 실매 봉 버퍼와 같이 기간 시작 전 N봉 prepend.""" from kis_trader.utils.env import get_env_int return max(0, int(get_env_int("SCALP_BACKTEST_CANDLE_WARMUP_BARS", 50))) def prepend_scalp_candle_warmup( db, candles_by_code: Dict[str, List[Dict]], period_start_key: str, *, warmup_bars: Optional[int] = None, history_source: str = "kiwoom", ) -> int: """ ``period_start_key``(YYYYMMDDHHMM) 이전 N봉을 종목별로 prepend. RSI 판별용 — 포트폴리오 all_times 는 ``_backtest_period_start_key`` 로 기간만 사용. """ wb = ( scalp_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 hs = str(history_source or "kiwoom").strip().lower() if hs in ("ls", "ls_condition", "ls_ws"): from kis_trader.backtest.ls_history_loaders import prepend_ls_candle_warmup return prepend_ls_candle_warmup( db, candles_by_code, str(period_start_key)[:12], wb, ) ps = str(period_start_key)[:12] 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 # 이미 기간 전 봉이 있으면 skip (idempotent) 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( "SELECT candle_time, open, high, low, close, volume " "FROM ws_candles WHERE timeframe=1 AND code=%s " "AND candle_time < %s AND is_confirmed=1 " "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) return total_prepended def load_scalp_candles_by_code( db, start_key: str, end_key: str, rsi_period: int = 3, *, history_source: str = "kiwoom", ) -> Tuple[Dict[str, List[Dict]], int]: """1분봉 전 종목 로드 (+ 웜업). history_source=ls → ls_ws_candles. 반환은 ``(candles_by_code, total_rows)`` 만 — 호출부에 ``codes`` 리스트 없음. 웹 ``codes_analyzed`` 는 ``_codes_from_candles_map`` 으로 바인딩할 것. """ min_bars = int(rsi_period) + 5 hs = str(history_source or "kiwoom").strip().lower() if hs in ("ls", "ls_condition", "ls_ws"): from kis_trader.backtest.ls_history_loaders import load_ls_candles_by_code candles_by_code, total_candles = load_ls_candles_by_code( db, start_key, end_key, min_bars=min_bars, ) prepend_scalp_candle_warmup( db, candles_by_code, str(start_key)[:12], history_source="ls", ) return candles_by_code, total_candles 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( "SELECT candle_time, open, high, low, close, volume " "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) prepend_scalp_candle_warmup(db, candles_by_code, str(start_key)[:12]) return candles_by_code, total_candles def _scalp_backtest_wants_ticks(params: Optional[Dict[str, Any]] = None) -> bool: """청산·진입 틱 재생이 필요한지 (기본 ON).""" if strategy_use_tick_exit(params, "SCALP_BACKTEST_USE_TICK_EXIT", default=True): return True if params is not None and params.get("backtest_use_tick_entry") is not None: return se._to_bool(params.get("backtest_use_tick_entry"), True) return get_env_bool("SCALP_BACKTEST_USE_TICK_ENTRY", True) def run_scalping_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, meta_out: Optional[Dict[str, Any]] = None, mode: str = "reversal", ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None, ) -> List[Dict]: """엔진 1회 + 웹과 동일 손익 부착 (reversal / momentum).""" 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) # 기간 시작 키 — 웜업 봉이 all_times 에 섞이지 않도록 (돌파·모멘텀과 동일) _sk_w = str((meta_out or {}).get("start_key") or "")[:12] if _sk_w: engine_params["_backtest_period_start_key"] = _sk_w _db_w = (meta_out or {}).get("db") if _db_w is not None and str(mode).strip().lower() != "momentum": _hs_w = str( engine_params.get("_universe_history_source") or engine_params.get("universe_history_source") or (meta_out or {}).get("universe_history_source") or "kiwoom" ).strip().lower() prepend_scalp_candle_warmup( _db_w, candles_by_code, _sk_w, history_source=_hs_w, ) from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params attach_backtest_env_timeline_to_params(engine_params, meta_out, "SCALP") if str(mode).strip().lower() == "momentum": from kis_trader.backtest import momentum_backtest_common as mbc trades = mbc.run_momentum_backtest_web_aligned( candles_by_code, engine_params, universe_by_slot, slot_money=float(slot_money), fee_rate=float(fee_rate), sell_tax=float(sell_tax), max_stocks=int(engine_params.get("max_stocks") or max_stocks or 3), total_budget_krw=float(engine_params.get("total_budget_krw") or 0), meta_out=meta_out, ) else: loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {}) tick_meta: Dict[str, Any] = {} if _scalp_backtest_wants_ticks(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 is None and start_key and end_key: from kis_trader.backtest.backtest_portfolio_common import ensure_meta_db db = ensure_meta_db(meta_out) if db and start_key and end_key: _hs_tick = str( engine_params.get("_universe_history_source") or engine_params.get("universe_history_source") or (meta_out or {}).get("universe_history_source") or "kiwoom" ).strip().lower() if _hs_tick in ("ls", "ls_condition", "ls_ws"): from kis_trader.backtest.ls_history_loaders import ( load_ls_ticks_by_code, ) loaded_ticks, tick_rows = load_ls_ticks_by_code( db, start_key, end_key, set(candles_by_code.keys()), ) _tick_tbl = "ls_ws_ticks" else: loaded_ticks, tick_rows = load_breakout_ticks_by_code( db, start_key, end_key, set(candles_by_code.keys()), ) _tick_tbl = "ws_ticks" tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks) tick_meta["ws_tick_rows_loaded"] = tick_rows tick_meta["tick_table"] = _tick_tbl if tick_rows <= 0: from kis_trader.utils.logger import get_logger as _get_logger _get_logger("kis_trader.scalping_backtest").warning( "⚠️ %s 데이터 없음 — SCALP 틱 청산/진입 스킵 " "(틱 수집 후 재백테 권장, FALLBACK_OHLC=1 시 OHLC)", _tick_tbl, ) 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() ) trades = se.run_scalping_backtest( candles_by_code, engine_params, universe_by_slot=universe_by_slot, ticks_by_code=loaded_ticks or None, ) if meta_out is not None and tick_meta: from kis_trader.backtest.breakout_tick_loader import enrich_tick_meta_with_traded_codes tick_meta = enrich_tick_meta_with_traded_codes( tick_meta, candles_by_code, loaded_ticks, trades, ) meta_out["tick_backtest"] = tick_meta if tick_meta.get("ws_tick_rows_loaded", 0) > 0: meta_out["backtest_buy_source"] = "ws_ticks" elif _scalp_backtest_wants_ticks(engine_params): meta_out["backtest_buy_source"] = "ohlc_fallback" else: meta_out["backtest_buy_source"] = "align" if meta_out is not None: meta_out["universe_exit_debounce_sec"] = scalp_universe_exit_debounce_sec() meta_out["candle_warmup_bars"] = scalp_backtest_candle_warmup_bars() 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 and str(mode).strip().lower() != "momentum": skip_stats = engine_params.get("_portfolio_skip_stats") or {} meta_out["skip_stats"] = dict(skip_stats) meta_out["engine_params"] = engine_params return trades def resolve_scalp_portfolio_params( env_row: Optional[Dict[str, Any]], base_defaults: Optional[Dict[str, Any]] = None, *, strategy: str = "SCALP", 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=strategy, slot_money=slot_money, max_stocks=max_stocks, total_budget_krw=total_budget_krw, ) def merge_scalp_portfolio_into_params( params: Dict[str, Any], portfolio: Dict[str, Any], ) -> Dict[str, Any]: return merge_portfolio_into_params(params, portfolio) def build_scalp_budget_warning( portfolio: Dict[str, Any], skip_stats: Optional[Dict[str, Any]] = None, *, strategy: str = "SCALP", ) -> Optional[str]: ratio = min_invest_ratio_of_slot({}, strategy=strategy) return build_budget_warning(portfolio, skip_stats, min_invest_ratio=ratio) def summarize_scalp_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]], *, strategy: str = "SCALP", ) -> Tuple[float, float, float]: return fee_and_slot_from_env_row(row, strategy=strategy)