#!/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_invest_amount_krw, breakout_min_bars_required, normalize_breakout_max_loss_krw, resolve_breakout_skip_hts_scan_dupes, run_breakout_backtest, ) BREAKOUT_STRATEGY_ID = "BREAKOUT" def _bo_golden_end_to_hm(s: str) -> int: """'10:30' 또는 HHMM → 1030 (실패 시 1030).""" try: raw = str(s or "").strip() if ":" in raw: hh, mm = raw.split(":", 1) return int(hh) * 100 + int(mm) if raw.isdigit(): return int(raw[:4]) if len(raw) >= 4 else int(raw) except Exception: pass return 1030 def _breakout_trigger_filter_enabled( env: Dict[str, Any], *, prefix: str, kind: str, global_key: str, ) -> bool: """전략별 TRIGGER 필터 ON/OFF. ORDERBOOK=전략키만(없으면 OFF). PROGRAM=전략→글로벌.""" sk = f"{prefix}_{kind}_FILTER_ENABLED" raw = env.get(sk) if raw not in (None, "", "None"): return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") if kind == "ORDERBOOK": return False # 글로벌 ORDERBOOK_FILTER_ENABLED 폐기 raw_g = env.get(global_key) if raw_g not in (None, "", "None"): return str(raw_g).strip().lower() in ("1", "true", "t", "y", "yes", "on") return True def get_breakout_defaults_from_env_row(env: Dict[str, Any]) -> Dict[str, Any]: """돌파 엔진 params — env_row 스냅샷만 사용 (웹 백테 env 타임라인).""" fee_rate, sell_tax, _slot = fee_and_slot_from_env_row(env, strategy="BREAKOUT") def pick(keys: Tuple[str, ...], default: Any, cast=float): for k in keys: v = env.get(k) if v not in (None, "", "None"): try: return cast(v) except (ValueError, TypeError): continue return default sl_r = pick(("BREAKOUT_STOP_LOSS_PCT",), -0.02, float) tp_r = pick(("BREAKOUT_TAKE_PROFIT_PCT",), 0.05, float) tr_r = pick(("BREAKOUT_TRAIL_PCT",), 0.015, float) tra_r = pick(("BREAKOUT_TRAIL_ARM_PCT",), 0.0, float) smh_r = pick(("BREAKOUT_SHOULDER_MIN_HIGH_PCT",), 0.02, float) sc_r = pick(("BREAKOUT_SHOULDER_CUT_PCT",), 0.01, float) def pct_ui(ratio: float) -> float: av = abs(float(ratio)) if av == 0: return 0.0 return round(av * 100, 3) if av < 0.5 else round(av, 3) time_end_raw = env.get("BREAKOUT_TIME_END") if time_end_raw not in (None, "", "None"): try: time_end_hm = int(float(time_end_raw)) except (ValueError, TypeError): time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30"))) else: time_end_hm = _bo_golden_end_to_hm(str(env.get("BREAKOUT_GOLDEN_END_HM", "10:30"))) cd_sec = pick(("BREAKOUT_COOLDOWN_SEC",), 0.0, float) if cd_sec and cd_sec > 0: cooldown_min = int(cd_sec / 60) if cd_sec > 120 else int(cd_sec) else: re_sec = pick(("REENTRY_COOLDOWN_SEC",), 1800.0, float) cooldown_min = int(re_sec / 60) if re_sec > 120 else int(re_sec) sl_pct_ui = pct_ui(sl_r) max_loss_raw = pick( ("BREAKOUT_MAX_LOSS_PER_TRADE_KRW", "MAX_LOSS_PER_TRADE_KRW"), 200_000, lambda v: int(float(v)), ) max_loss_krw = normalize_breakout_max_loss_krw(max_loss_raw) slot_cap = pick( ("BREAKOUT_SLOT_MONEY", "SLOT_MONEY_DEFAULT"), 2_000_000, lambda v: int(float(v)), ) slot_money = int(breakout_invest_amount_krw(max_loss_krw, sl_pct_ui, slot_cap)) portfolio = resolve_breakout_portfolio_params( env, None, slot_money=float(slot_money), ) max_stocks_v = int(portfolio["max_stocks"]) total_budget_v = int(float(portfolio["total_budget_krw"])) eod_raw = str(env.get("BREAKOUT_EOD_HM") or "15:15").strip() if eod_raw in ("", "None"): eod_hm = "15:15" elif ":" in eod_raw: eod_hm = eod_raw elif len(eod_raw) == 4 and eod_raw.isdigit(): eod_hm = f"{eod_raw[:2]}:{eod_raw[2:]}" else: eod_hm = eod_raw eod_enabled_raw = env.get("BREAKOUT_EOD_ENABLED") if eod_enabled_raw in (None, "", "None"): eod_enabled = True else: eod_enabled = str(eod_enabled_raw).strip().lower() in ("1", "true", "t", "y", "yes", "on") return { "lookback_min": pick(("BREAKOUT_LOOKBACK_MIN",), 1, lambda v: int(float(v))), "vol_window": pick(("BREAKOUT_VOL_WIN",), 1, lambda v: int(float(v))), "vol_mult": pick(("BREAKOUT_VOL_MULT",), 0.0, float), "min_turnover_1m_pct": pick(("BREAKOUT_MIN_TURNOVER_1M_PCT",), 0.05, float), "prev_chg_min": pick(("BREAKOUT_PREV_CHG_MIN",), 1.0, float), "prev_chg_max": pick(("BREAKOUT_PREV_CHG_MAX",), 10.0, float), "sl_pct": sl_pct_ui, "tp_pct": pct_ui(tp_r), "trail_pct": pct_ui(tr_r), "trail_arm_pct": pct_ui(tra_r), "shoulder_min_high_pct": pct_ui(smh_r), "shoulder_cut_pct": pct_ui(sc_r), "sl_mode": str(env.get("BREAKOUT_SL_MODE") or "fixed").strip().lower() or "fixed", "atr_period": pick(("BREAKOUT_ATR_PERIOD",), 14, lambda v: int(float(v))), "atr_sl_mult": pick(("BREAKOUT_ATR_SL_MULT",), 2.0, float), "atr_sl_min_pct": pick(("BREAKOUT_ATR_SL_MIN_PCT",), 0.8, float), "atr_sl_max_pct": pick(("BREAKOUT_ATR_SL_MAX_PCT",), 6.0, float), "max_hold_bars": pick(("BREAKOUT_MAX_HOLD_BARS",), 0, lambda v: int(float(v))), "ratchet_tiers": str(env.get("BREAKOUT_RATCHET_TIERS") or ""), "time_start_hm": pick(("BREAKOUT_TIME_START",), 900, lambda v: int(float(v))), "time_end_hm": time_end_hm, "eod_enabled": eod_enabled, "eod_hm": eod_hm, "max_daily": pick(("BREAKOUT_MAX_DAILY",), 1, lambda v: int(float(v))), "cooldown_min": cooldown_min, "max_daily_chg": pick(("BREAKOUT_MAX_DAILY_CHG",), 15.0, float), "min_price": pick(("BREAKOUT_MIN_PRICE", "MIN_STOCK_PRICE"), 1000.0, float), "confirm_margin_pct": pick(("BREAKOUT_CONFIRM_MARGIN_PCT",), 0.0, float), "body_min_pct": pick(("BREAKOUT_BODY_MIN_PCT",), 0.0, float), "max_loss_krw": max_loss_krw, "slot_money": slot_money, "max_stocks": max_stocks_v, "total_budget_krw": total_budget_v, "fee_rate_pct": fee_rate * 100.0 if fee_rate < 1 else fee_rate, "sell_tax_pct": sell_tax * 100.0 if sell_tax < 1 else sell_tax, "entry_mode": str(env.get("BREAKOUT_ENTRY_MODE") or "intrabar").strip().lower(), "intrabar_slippage_pct": float(pick(("BREAKOUT_INTRABAR_SLIPPAGE_PCT",), 0.0, float)), "use_ema_filter": ( str(env.get("BREAKOUT_USE_EMA_FILTER")).strip().lower() in ("1", "true", "t", "y", "yes", "on") if env.get("BREAKOUT_USE_EMA_FILTER") not in (None, "", "None") else False ), "ema_fast_period": pick(("BREAKOUT_EMA_FAST_PERIOD",), 9, lambda v: int(float(v))), "ema_slow_period": pick(("BREAKOUT_EMA_SLOW_PERIOD",), 21, lambda v: int(float(v))), "skip_hts_scan_dupes": resolve_breakout_skip_hts_scan_dupes(env), "ob_filter_enabled": _breakout_trigger_filter_enabled( env, prefix="BREAKOUT", kind="ORDERBOOK", global_key="ORDERBOOK_FILTER_ENABLED", ), "pg_filter_enabled": _breakout_trigger_filter_enabled( env, prefix="BREAKOUT", kind="PROGRAM", global_key="PROGRAM_FILTER_ENABLED", ), "max_spread_pct": pick( ("BREAKOUT_ORDERBOOK_MAX_SPREAD_PCT",), 0.45, float, ), } 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 breakout_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("BREAKOUT") def breakout_backtest_candle_warmup_bars() -> int: """백테 지표·lookback warm-up — 실매 WS 전일봉 버퍼와 동일하게 전일 봉 선행.""" from kis_trader.utils.env import get_env_int return max(0, int(get_env_int("BREAKOUT_BACKTEST_CANDLE_WARMUP_BARS", 50))) def prepend_breakout_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봉(1분)을 종목별로 prepend. lookback/vol_window 판별용 — 루프 시각(all_times)에는 기간일만 포함. """ wb = ( breakout_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]: """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, exit_debounce_sec=breakout_universe_exit_debounce_sec(), ) 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) # ── 전일 봉 웜업 (실매 WS 버퍼 정합) ───────────────────────────────── warmup_prepended = 0 _sk_w = str((meta_out or {}).get("start_key") or "") if meta_out is not None and _sk_w: engine_params["_backtest_period_start_key"] = str(_sk_w)[:12] _db_w = meta_out.get("db") if _db_w is not None: warmup_prepended = prepend_breakout_candle_warmup( _db_w, candles_by_code, str(_sk_w)[:12], ) # ── 초단위 유니버스 타임라인 (실매 get_universe_at 정합) ────────────── # 1분 슬롯(strict lag)의 "편입 +최대 1분 지연" 을 제거. 봉 마감(HH:MM:59) 직전 # 최신 조건검색 스냅샷을 그대로 조회해 실매와 동일 시점 유니버스로 매수 판정. # EXIT 디바운스 = CONDITION_EXIT_GRACE_SEC (실매 sticky/grace 정합). 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 _deb = breakout_universe_exit_debounce_sec() _tl = build_universe_timeline( strategy_id=BREAKOUT_STRATEGY_ID, start_ymd=_sk[:8], end_ymd=_ek[:8], debounce_sec=_deb, 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 meta_out["universe_exit_debounce_sec"] = _deb 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"] from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params attach_backtest_env_timeline_to_params(engine_params, meta_out, BREAKOUT_STRATEGY_ID) 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) if warmup_prepended > 0 or breakout_backtest_candle_warmup_bars() > 0: meta_out["skip_stats"]["candle_warmup_bars"] = breakout_backtest_candle_warmup_bars() meta_out["skip_stats"]["candle_warmup_prepended"] = warmup_prepended meta_out["engine_params"] = engine_params if 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 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")