#!/usr/bin/env python3 """ 웹 백테스트 전용 env 타임라인 — 실매·파람서치 경로에는 부착하지 않는다. ``backtest_env_timeline=True`` (웹 bt_meta) 일 때만 ``_env_timeline`` 을 params 에 넣고, 포트폴리오/단일 루프에서 ``apply_env_timeline_at`` 으로 TRIGGER·포트폴리오 숫자를 시점별 갱신한다. """ from __future__ import annotations from typing import Any, Dict, Optional from kis_trader.backtest.env_timeline import EnvTimeline, build_env_timeline # params 에 남겨 두는 구조·메타 키 — env 스냅샷이 덮어쓰지 않음 _PRESERVE_PARAM_KEYS = frozenset({ "_env_timeline", "_env_timeline_evt_key", "_universe_timeline", "_momentum_universe_timeline", "_universe_timeline_meta", "backtest_env_timeline", "portfolio_mode", "scan_interval_min", "backtest_universe_scan_at", "backtest_vol_fill_cap_pct", "strict_fill_verify", "limit_fill_slip_pct", "fee_rate", "sell_tax", "slot_money", "max_stocks", "total_budget_krw", "_backtest_period_start_key", "_backtest_log_verdict_by_code", "_portfolio_skip_stats", "share_denom_by_code", }) def _truthy(val: Any) -> bool: if val is None: return False s = str(val).strip().lower() return s in ("1", "true", "t", "y", "yes", "on") def backtest_env_timeline_enabled(params: Optional[Dict[str, Any]] = None) -> bool: """웹 백테 env 타임라인 ON 여부 — 기본 False (파람서치·실매 영향 없음).""" if params is None: return False if params.get("backtest_env_timeline") is not None: return _truthy(params.get("backtest_env_timeline")) return params.get("_env_timeline") is not None def attach_backtest_env_timeline_to_params( engine_params: Dict[str, Any], meta_out: Optional[Dict[str, Any]], strategy_id: str, ) -> None: """웹 백테 구간 env 타임라인 부착 — meta_out['backtest_env_timeline']=True 일 때만.""" if not backtest_env_timeline_enabled(engine_params): if meta_out and _truthy(meta_out.get("backtest_env_timeline")): engine_params["backtest_env_timeline"] = True else: return db = (meta_out or {}).get("db") start_key = str((meta_out or {}).get("start_key") or "") end_key = str((meta_out or {}).get("end_key") or "") start_ymd = start_key[:8] if len(start_key) >= 8 else "" end_ymd = end_key[:8] if len(end_key) >= 8 else "" if not start_ymd or not end_ymd: return tl = build_env_timeline(db, start_ymd, end_ymd) if tl is None: return engine_params["_env_timeline"] = tl engine_params["_strategy_id_for_env_tl"] = str(strategy_id or "").strip().upper() if meta_out is not None: meta_out["backtest_env_timeline"] = True meta_out["env_timeline_snapshots"] = tl.snapshot_count def engine_defaults_from_env_row( strategy_id: str, env_row: Dict[str, Any], ) -> Dict[str, Any]: """전략별 get_*_defaults_from_db — env_row 스냅샷만 사용 (DB 재조회 없음). 반환값은 반드시 **엔진 비율**(0.02=2%) 이어야 한다. 웹이 ``ui% → breakout_ui_to_engine_params`` 한 뒤 타임라인이 UI% 를 다시 덮어쓰면 trail/shoulder 가 100배 커져 청산·슬롯이 붕괴한다 (BREAKOUT 전용 함정). """ sid = str(strategy_id or "").strip().upper() if sid in ("SHORT", "TAIL"): from kis_trader.engine.tail_engine import get_tail_defaults_from_db return get_tail_defaults_from_db(env_row=env_row) if sid == "MOMENTUM": from kis_trader.engine.momentum_engine import get_momentum_defaults_from_db return get_momentum_defaults_from_db(env_row=env_row) if sid == "SCALP": from kis_trader.engine.scalping_engine import get_scalping_defaults_from_db return get_scalping_defaults_from_db(env_row=env_row) if sid == "BREAKOUT": from kis_trader.backtest.breakout_backtest_common import get_breakout_defaults_from_env_row from kis_trader.strategies.breakout import breakout_ui_to_engine_params # get_breakout_defaults_from_env_row = 웹 폼용 UI% → 엔진 비율로 변환 후 적용 ui = get_breakout_defaults_from_env_row(env_row) eng = breakout_ui_to_engine_params(ui) # buy_params 동기화·invest_cap 이 읽는 별칭 (값은 엔진 비율) eng["sl_pct"] = float(eng.get("stop_loss_pct", 0.0) or 0.0) eng["tp_pct"] = float(eng.get("take_profit_pct", 0.0) or 0.0) if eng.get("shoulder_min_high") is not None: eng["shoulder_min_high_pct"] = float(eng["shoulder_min_high"]) # ui_to_engine 이 빼먹는 트리거 스냅샷 플래그 for k in ("ob_filter_enabled", "pg_filter_enabled", "max_spread_pct"): if k in ui: eng[k] = ui[k] return eng if sid == "RANGE_BREAK": from kis_trader.engine.range_break_engine import get_range_break_defaults_from_db return get_range_break_defaults_from_db(env_row=env_row) if sid == "DBBAND": from kis_trader.engine.dbband_engine import get_dbband_defaults_from_db return get_dbband_defaults_from_db(env_row=env_row) return {} def apply_env_timeline_at( params: Dict[str, Any], candle_time: str, strategy_id: Optional[str] = None, ) -> bool: """ 봉 시각에 맞는 env 스냅샷을 params 에 반영. 스냅샷 변경 시에만 True. 포트폴리오 루프 변수(max_stocks, slot_money 등)는 호출측에서 params 재읽기. """ tl: Optional[EnvTimeline] = params.get("_env_timeline") if tl is None: return False snap, evt_key = tl.snapshot_at(candle_time) if not snap or not evt_key: return False if evt_key == params.get("_env_timeline_evt_key"): return False sid = str( strategy_id or params.get("_strategy_id_for_env_tl") or "" ).strip().upper() defaults = engine_defaults_from_env_row(sid, snap) for k, v in defaults.items(): if k in _PRESERVE_PARAM_KEYS: continue params[k] = v params["_env_timeline_evt_key"] = evt_key return True