""" kis_trader/backtest/trigger_snapshot_loader.py — ws_orderbook / ws_program 백테 로더 ================================================================================== ``ws_ticks`` 와 동일하게 분봉(YYYYMMDDHHMM) 키로 인덱싱 → TRIGGER 직전 params 주입. """ from __future__ import annotations import logging from collections import defaultdict from typing import Any, Dict, List, Optional, Set, Tuple from kis_trader.engine.feed_fallback import ( live_feed_fallback_max_age_sec, orderbook_row_lag_seconds, ) from kis_trader.utils.env import get_env_bool, get_env_from_db from kis_trader.ws.orderbook_cache import OrderbookSnapshot, orderbook_snapshot_from_storage from kis_trader.ws.program_cache import ProgramSnapshot, program_snapshot_from_storage from kis_trader.ws.trigger_eval_recorder import FILTER_EVAL_SOURCE logger = logging.getLogger("trigger_snapshot_loader") LOG_BACKFILL_SOURCE = "log_backfill" # 키움 실시간 호가 주기 스냅샷(본체 levels_json 보유) — 스프레드 등 재계산용. # log_backfill(판정만)·filter_eval(판정시점만)과 달리 종목·시각을 폭넓게 커버한다. KIWOOM_BODY_SOURCE = "kiwoom_0d" # LS UH1 등 — history_source=ls 일 때 ls_ws_orderbook 본체 LS_BODY_SOURCES = ("ls_uh1", "ls_h1", "ls_ha", "ls_nh1") def resolve_orderbook_history_source( engine_params: Optional[Dict[str, Any]] = None, ) -> str: """봉/틱과 동일 축 — universe history_source (ls|kiwoom).""" try: from kis_trader.backtest.universe_history_source import ( normalize_universe_history_source, ) except Exception: def normalize_universe_history_source(raw): # type: ignore s = str(raw or "").strip().lower() return "ls" if s in ("ls", "ls_condition", "ls_afr", "ls_ws") else "kiwoom" if not engine_params: return "kiwoom" raw = ( engine_params.get("_orderbook_history_source") or engine_params.get("_universe_history_source") or engine_params.get("universe_history_source") or engine_params.get("history_source") ) return normalize_universe_history_source(raw) def backtest_use_kiwoom_body_snapshot( params: Optional[Dict[str, Any]] = None, *, strategy: str = "", ) -> bool: """백테에서 kiwoom_0d 본체로 호가필터를 **재계산**할지 여부 (기본 false). 기본 false: 기존처럼 log_backfill/filter_eval 판정 재생(실매 정합). true 일 때만 본체를 적재해 스프레드 등 임계값을 바꿔 다시 계산한다. 웹 스프레드 입력칸·파람서치 스프레드 축이 켜질 때 params 로 true 주입. """ if params is not None and params.get("backtest_use_kiwoom_body_snapshot") is not None: return bool(params.get("backtest_use_kiwoom_body_snapshot")) if params is not None and params.get("_backtest_use_kiwoom_body") is not None: return bool(params.get("_backtest_use_kiwoom_body")) pfx = (strategy or "").strip().upper() if pfx == "MOMENTUM": sk = "MOMENTUM_BACKTEST_USE_KIWOOM_BODY_SNAPSHOT" elif pfx == "BREAKOUT": sk = "BREAKOUT_BACKTEST_USE_KIWOOM_BODY_SNAPSHOT" elif pfx in ("TAIL", "SHORT"): sk = "TAIL_BACKTEST_USE_KIWOOM_BODY_SNAPSHOT" else: sk = "" if sk and str(get_env_from_db(sk, "")).strip(): return get_env_bool(sk, False) return get_env_bool("BACKTEST_USE_KIWOOM_BODY_SNAPSHOT", False) def backtest_needs_trigger_snapshot_load( params: Optional[Dict[str, Any]] = None, *, strategy: str = "", ) -> bool: """호가/프로그램 스냅샷 DB 로딩이 필요한지 — 필터·본체 재계산 모두 OFF면 스킵. 필터 OFF 백테에서 ws_orderbook/ws_program/log_backfill 을 통째로 읽으면 시간만 잡아먹고 손익에는 영향 없다(필터 함수가 조기 return). """ if params is not None and params.get("backtest_use_trigger_snapshot_db") is False: return False if backtest_use_kiwoom_body_snapshot(params, strategy=strategy): return True from kis_trader.engine.orderbook_filter import _orderbook_filter_enabled_for_entry from kis_trader.engine.program_filter import _program_filter_enabled_for_entry p = params or {} ob_on = _orderbook_filter_enabled_for_entry(p, strategy) pg_on = _program_filter_enabled_for_entry(p, strategy) # 수익구간·손절호가 ON 이면 진입필터 OFF여도 OR 본체가 필요 (모멘텀 키움 / 돌파 LS) try: from kis_trader.engine.momentum_hts_logic import need_ob_or_history exit_ob_on = need_ob_or_history(p) except Exception: exit_ob_on = bool( p.get("exit_ob_enabled", False) or p.get("stop_ob_enabled", False) ) return bool(ob_on or pg_on or exit_ob_on) def backtest_use_trigger_snapshot_db( params: Optional[Dict[str, Any]] = None, *, strategy: str = "", ) -> bool: """백테·파람서치에서 DB 스냅샷 재생 여부 (기본 true, 필터 OFF면 자동 false).""" if params is not None and params.get("backtest_use_trigger_snapshot_db") is not None: return bool(params.get("backtest_use_trigger_snapshot_db")) if not backtest_needs_trigger_snapshot_load(params, strategy=strategy): return False pfx = (strategy or "").strip().upper() if pfx == "MOMENTUM": sk = "MOMENTUM_BACKTEST_USE_TRIGGER_SNAPSHOT_DB" elif pfx == "BREAKOUT": sk = "BREAKOUT_BACKTEST_USE_TRIGGER_SNAPSHOT_DB" elif pfx in ("TAIL", "SHORT"): sk = "TAIL_BACKTEST_USE_TRIGGER_SNAPSHOT_DB" else: sk = "" if sk and str(get_env_from_db(sk, "")).strip(): return get_env_bool(sk, True) return get_env_bool("BACKTEST_USE_TRIGGER_SNAPSHOT_DB", True) def backtest_use_trigger_eval_snapshot( params: Optional[Dict[str, Any]] = None, *, strategy: str = "", ) -> bool: """백테에서 filter_eval(판정 시점) 스냅만 사용 (기본 true).""" if params is not None and params.get("backtest_use_trigger_eval_snapshot") is not None: return bool(params.get("backtest_use_trigger_eval_snapshot")) pfx = (strategy or "").strip().upper() if pfx == "MOMENTUM": sk = "MOMENTUM_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT" elif pfx == "BREAKOUT": sk = "BREAKOUT_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT" elif pfx in ("TAIL", "SHORT"): sk = "TAIL_BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT" else: sk = "" if sk and str(get_env_from_db(sk, "")).strip(): return get_env_bool(sk, True) return get_env_bool("BACKTEST_USE_TRIGGER_EVAL_SNAPSHOT", True) def backtest_use_log_backfill_snapshot( params: Optional[Dict[str, Any]] = None, *, strategy: str = "", ) -> bool: """백테에서 log_backfill(판정 재생) 사용 여부 (기본 true).""" if params is not None and params.get("backtest_use_log_backfill_snapshot") is not None: return bool(params.get("backtest_use_log_backfill_snapshot")) pfx = (strategy or "").strip().upper() if pfx == "MOMENTUM": sk = "MOMENTUM_BACKTEST_USE_LOG_BACKFILL_SNAPSHOT" elif pfx == "BREAKOUT": sk = "BREAKOUT_BACKTEST_USE_LOG_BACKFILL_SNAPSHOT" elif pfx in ("TAIL", "SHORT"): sk = "TAIL_BACKTEST_USE_LOG_BACKFILL_SNAPSHOT" else: sk = "" if sk and str(get_env_from_db(sk, "")).strip(): return get_env_bool(sk, True) return get_env_bool("BACKTEST_USE_LOG_BACKFILL_SNAPSHOT", True) def _strategy_db_values(strategy: str) -> Tuple[str, ...]: """DB strategy 컬럼 필터 (꼬리=SHORT 로그 별칭 포함).""" pfx = (strategy or "").strip().upper() if pfx == "MOMENTUM": return ("MOMENTUM",) if pfx == "BREAKOUT": return ("BREAKOUT",) if pfx in ("TAIL", "SHORT"): return ("TAIL", "SHORT") return tuple() def _normalize_pick_time(bar_candle_time: str, prefer_time: str = "") -> str: raw = (prefer_time or bar_candle_time or "").strip() digits = raw.replace(":", "").replace("-", "").replace(" ", "") if len(digits) >= 14: return digits[:14] if len(digits) == 12 and digits.isdigit(): return digits + "59" return digits def _snap_time_key(snap: Any) -> str: if isinstance(snap, dict): st = snap.get("snap_time") or "" else: st = getattr(snap, "snap_time", None) or "" return str(st).strip()[:14] def _pick_latest_le(bucket: List[Any], target_time: str) -> Any: """target_time 이하 중 가장 최근 스냅샷 (판정 시점 재생).""" if not bucket: return None target = _normalize_pick_time(target_time, target_time) best = None best_key = "" for snap in bucket: sk = _snap_time_key(snap) if not sk: continue if sk <= target and sk >= best_key: best = snap best_key = sk return best or bucket[-1] def _minute_key(snap_time: str) -> str: st = str(snap_time or "").strip() return st[:12] if len(st) >= 12 else st def _load_orderbook_rows( db, start_key: str, end_key: str, codes: Optional[Set[str]], *, market: str, sources: Tuple[str, ...], strategy: str = "", table: str = "ws_orderbook", use_strategy_col: bool = True, ) -> List[Dict[str, Any]]: """호가 스냅 행 조회 (ws_orderbook | ls_ws_orderbook).""" tt_start = (start_key or "")[:14] if len(tt_start) == 12: tt_start += "00" tt_end = (end_key or "")[:14] if len(tt_end) == 12: tt_end += "59" tbl = "ls_ws_orderbook" if str(table).strip().lower() == "ls_ws_orderbook" else "ws_orderbook" import os ob_source = os.environ.get("OB_SOURCE", "").strip() if ob_source == "kis": tbl = "kis_ws_orderbook" sources = () use_strategy_col = False elif ob_source == "kiwoom_0d": tbl = "ws_orderbook" sources = ("kiwoom_0d",) code_filter = "" params: List[Any] = [market, tt_start, tt_end] if codes: placeholders = ",".join(["%s"] * len(codes)) code_filter = f" AND code IN ({placeholders})" params.extend(sorted(codes)) source_filter = "" if sources: source_filter = f" AND source IN ({','.join(['%s'] * len(sources))})" params.extend(sources) strat_filter = "" if use_strategy_col and tbl == "ws_orderbook": strat_vals = _strategy_db_values(strategy) if strat_vals: strat_filter = ( f" AND (strategy IS NULL OR strategy IN ({','.join(['%s'] * len(strat_vals))}))" ) params.extend(strat_vals) # ls 테이블에는 strategy/reject 컬럼 없음 — 본체만 if tbl == "ls_ws_orderbook": cols = ( "code, snap_time, best_bid, best_ask, total_bid_qty, total_ask_qty, " "bid_qty_l3, ask_qty_l3, levels_json, source, recv_ts" ) else: cols = ( "code, snap_time, best_bid, best_ask, total_bid_qty, total_ask_qty, " "bid_qty_l3, ask_qty_l3, levels_json, source, recv_ts, " "strategy, reject_code, reject_msg" ) sql = f""" SELECT {cols} FROM {tbl} WHERE market = %s AND snap_time >= %s AND snap_time <= %s {code_filter} {source_filter} {strat_filter} ORDER BY code, snap_time """ try: rows = db.conn.execute(sql, tuple(params)).fetchall() return [dict(r) for r in rows] except Exception as e: logger.warning("%s 조회 실패 — TRIGGER 호가 스킵: %s", tbl, e) return [] def _index_log_verdict_row( out: Dict[str, Dict[str, List[Dict[str, Any]]]], row: Dict[str, Any], ) -> None: """log_backfill 판정 1건 → code/minute 버킷.""" code = str(row.get("code") or "").strip() st = str(row.get("snap_time") or "").strip() minute_key = _minute_key(st) if not code or not minute_key: return bucket = out[code].setdefault(minute_key, []) bucket.append({ "snap_time": st[:14], "reject_code": row.get("reject_code"), "reject_msg": row.get("reject_msg") or "", "strategy": row.get("strategy"), }) def _merge_ls_orderbook_fallback( out: Dict[str, Dict[str, List[OrderbookSnapshot]]], db, start_key: str, end_key: str, codes: Optional[Set[str]], market: str, kiwoom_valid_minutes: Dict[str, Dict[str, str]], ) -> int: """메인 호가 없거나 2초 초과인 분에 ls_ws_orderbook 을 넣음. 빈 스냅=필터 통과 금지.""" added = 0 try: rows = _load_orderbook_rows( db, start_key, end_key, codes, market=market, sources=LS_BODY_SOURCES, strategy="", table="ls_ws_orderbook", use_strategy_col=False, ) if not rows: rows = _load_orderbook_rows( db, start_key, end_key, codes, market=market, sources=tuple(), strategy="", table="ls_ws_orderbook", use_strategy_col=False, ) except Exception as e: logger.debug("ls_ws_orderbook 폴백 로드 스킵: %s", e) return 0 age = live_feed_fallback_max_age_sec() ls_latest: Dict[str, Dict[str, Any]] = defaultdict(dict) for r in rows: code = str(r.get("code") or "").strip() st = str(r.get("snap_time") or "").strip() minute_key = _minute_key(st) if not code or not minute_key: continue lag = orderbook_row_lag_seconds(r) if age > 0 and lag is not None and lag > age: continue prev = ls_latest[code].get(minute_key) if prev is None or str(st) >= str(prev.get("snap_time") or ""): ls_latest[code][minute_key] = r for code, minutes in ls_latest.items(): valid = kiwoom_valid_minutes.get(code) or {} for mk, r in minutes.items(): if mk in valid: continue snap = orderbook_snapshot_from_storage(r) bucket = out.setdefault(code, {}).setdefault(mk, []) if not bucket: bucket.append(snap) added += 1 else: bucket[-1] = snap if added: logger.info("📌 호가 3차 LS 폴백: +%s분 (메인 없거나 2초 초과)", added) return added def load_orderbook_snapshots_by_code( db, start_key: str, end_key: str, codes: Optional[Set[str]] = None, *, market: Optional[str] = None, engine_params: Optional[Dict[str, Any]] = None, strategy: str = "", ) -> Tuple[Dict[str, Dict[str, List[OrderbookSnapshot]]], int, Dict[str, Dict[str, List[Dict[str, Any]]]]]: """기간 내 호가 → ``{code: {minute: [snap, ...]}}``. history_source=ls → ``ls_ws_orderbook`` 본체만 (키움 log_backfill/filter_eval 폴백 금지). """ mkt = (market or get_env_from_db("WS_TICK_DEFAULT_MARKET", "KR") or "KR").strip().upper() hist_src = resolve_orderbook_history_source(engine_params) # ── LS: 본체만, 판정 메모 폴백 없음 ── if hist_src == "ls": try: if hasattr(db, "ensure_ws_orderbook_table"): # ls 테이블은 TradeDB migrate 에서 생성됨 — no-op ensure 없으면 무시 pass except Exception: pass rows = _load_orderbook_rows( db, start_key, end_key, codes, market=mkt, sources=LS_BODY_SOURCES, strategy="", table="ls_ws_orderbook", use_strategy_col=False, ) # source 필터에 안 잡힌 구 데이터도 있으면 전체 재조회 (source 비어있는 행) if not rows: rows = _load_orderbook_rows( db, start_key, end_key, codes, market=mkt, sources=tuple(), strategy="", table="ls_ws_orderbook", use_strategy_col=False, ) out: Dict[str, Dict[str, List[OrderbookSnapshot]]] = defaultdict(dict) total = 0 for r in rows: code = str(r.get("code") or "").strip() st = str(r.get("snap_time") or "").strip() minute_key = _minute_key(st) if not code or not minute_key: continue snap = orderbook_snapshot_from_storage(r) bucket = out[code].setdefault(minute_key, []) bucket.append(snap) total += 1 if engine_params is not None: engine_params["_orderbook_history_source"] = "ls" engine_params["_backtest_disable_log_orderbook_verdict"] = True return dict(out), total, {} try: if hasattr(db, "ensure_ws_orderbook_table"): db.ensure_ws_orderbook_table() except Exception: pass use_eval = backtest_use_trigger_eval_snapshot(engine_params, strategy=strategy) use_log_bf = backtest_use_log_backfill_snapshot(engine_params, strategy=strategy) use_kiwoom = backtest_use_kiwoom_body_snapshot(engine_params, strategy=strategy) # 수익구간·손절호가: 보유 중 OR_MA 용 본체 스냅 필요. # 진입필터 재계산 플래그(use_kiwoom)와 분리 — 본체만 추가 적재. need_exit_ob = False try: from kis_trader.engine.momentum_hts_logic import need_ob_or_history need_exit_ob = need_ob_or_history(engine_params or {}) except Exception: _p = engine_params or {} need_exit_ob = bool( _p.get("exit_ob_enabled", False) or _p.get("stop_ob_enabled", False) ) sources: List[str] = [] if use_eval: sources.append(FILTER_EVAL_SOURCE) if use_log_bf: sources.append(LOG_BACKFILL_SOURCE) if use_kiwoom or need_exit_ob: sources.append(KIWOOM_BODY_SOURCE) if not sources: return {}, 0, {} rows = _load_orderbook_rows( db, start_key, end_key, codes, market=mkt, sources=tuple(sources), strategy=strategy, table="ws_orderbook", use_strategy_col=True, ) out = defaultdict(dict) log_verdict: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(dict) seen_eval: Set[Tuple[str, str]] = set() # 본체(kiwoom_0d) 다운샘플: (code, minute) 당 최신 1건만 유지 (분단위 백테·메모리 절약) kiwoom_latest: Dict[str, Dict[str, str]] = defaultdict(dict) total = 0 for r in rows: code = str(r.get("code") or "").strip() src = str(r.get("source") or "").strip() st = str(r.get("snap_time") or "").strip() minute_key = _minute_key(st) if not code or not minute_key: continue if src == FILTER_EVAL_SOURCE: snap = orderbook_snapshot_from_storage(r) bucket = out[code].setdefault(minute_key, []) bucket.append(snap) seen_eval.add((code, st[:14])) total += 1 elif src == KIWOOM_BODY_SOURCE: lag = orderbook_row_lag_seconds(r) age = live_feed_fallback_max_age_sec() if age > 0 and lag is not None and lag > age: continue # 분당 최신 1건만 (rows 는 snap_time ASC 정렬 → 뒤가 최신) prev = kiwoom_latest[code].get(minute_key) snap = orderbook_snapshot_from_storage(r) bucket = out[code].setdefault(minute_key, []) if prev is None: bucket.append(snap) total += 1 else: bucket[-1] = snap # 같은 분 더 늦은 스냅으로 교체 kiwoom_latest[code][minute_key] = st[:14] elif src == LOG_BACKFILL_SOURCE: _index_log_verdict_row(log_verdict, r) # 본체 재계산 모드면 log_backfill 빈본체는 out 에 넣지 않는다 # (kiwoom 본체가 body source 가 되도록). 판정(verdict)은 폴백용으로 보존. if use_kiwoom: continue if (code, st[:14]) in seen_eval: continue snap = orderbook_snapshot_from_storage(r) bucket = out[code].setdefault(minute_key, []) bucket.append(snap) total += 1 total += _merge_ls_orderbook_fallback( out, db, start_key, end_key, codes, mkt, kiwoom_latest, ) return dict(out), total, dict(log_verdict) def load_program_snapshots_by_code( db, start_key: str, end_key: str, codes: Optional[Set[str]] = None, *, market: Optional[str] = None, engine_params: Optional[Dict[str, Any]] = None, strategy: str = "", ) -> Tuple[Dict[str, Dict[str, List[ProgramSnapshot]]], int]: """기간 내 ``ws_program`` → ``{code: {minute: [snap, ...]}}``.""" mkt = (market or get_env_from_db("WS_TICK_DEFAULT_MARKET", "KR") or "KR").strip().upper() tt_start = (start_key or "")[:14] if len(tt_start) == 12: tt_start += "00" tt_end = (end_key or "")[:14] if len(tt_end) == 12: tt_end += "59" try: if hasattr(db, "ensure_ws_program_table"): db.ensure_ws_program_table() except Exception: pass code_filter = "" params: List[Any] = [mkt, tt_start, tt_end] if codes: placeholders = ",".join(["%s"] * len(codes)) code_filter = f" AND code IN ({placeholders})" params.extend(sorted(codes)) source_filter = "" if backtest_use_trigger_eval_snapshot(engine_params, strategy=strategy): source_filter = " AND source = %s" params.append(FILTER_EVAL_SOURCE) sql = f""" SELECT code, snap_time, buy_qty, sell_qty, net_qty, buy_amt, sell_amt, net_amt, source, recv_ts FROM ws_program WHERE market = %s AND snap_time >= %s AND snap_time <= %s {code_filter} {source_filter} ORDER BY code, snap_time """ try: rows = db.conn.execute(sql, tuple(params)).fetchall() except Exception as e: logger.warning("ws_program 조회 실패 — TRIGGER 프로그램 스킵: %s", e) return {}, 0 out: Dict[str, Dict[str, List[ProgramSnapshot]]] = defaultdict(dict) total = 0 for r in rows: code = str(r["code"]).strip() minute_key = _minute_key(str(r["snap_time"])) if not minute_key: continue snap = program_snapshot_from_storage(dict(r)) bucket = out[code].setdefault(minute_key, []) bucket.append(snap) total += 1 return dict(out), total def snapshot_coverage_stats( orderbook_by_code: Dict[str, Dict[str, List[OrderbookSnapshot]]], program_by_code: Dict[str, Dict[str, List[ProgramSnapshot]]], ) -> Dict[str, Any]: ob_codes = len(orderbook_by_code) pg_codes = len(program_by_code) ob_minutes = sum(len(v) for v in orderbook_by_code.values()) pg_minutes = sum(len(v) for v in program_by_code.values()) return { "orderbook_codes_with_data": ob_codes, "program_codes_with_data": pg_codes, "orderbook_minute_buckets": ob_minutes, "program_minute_buckets": pg_minutes, } def load_trigger_snapshots_by_code( db, start_key: str, end_key: str, codes: Optional[Set[str]] = None, *, market: Optional[str] = None, engine_params: Optional[Dict[str, Any]] = None, strategy: str = "", ) -> Tuple[ Dict[str, Dict[str, List[OrderbookSnapshot]]], Dict[str, Dict[str, List[ProgramSnapshot]]], Dict[str, Any], ]: """호가·프로그램 스냅샷 일괄 로드 + 커버리지 메타.""" ob_by_code, ob_rows, log_verdict = load_orderbook_snapshots_by_code( db, start_key, end_key, codes, market=market, engine_params=engine_params, strategy=strategy, ) pg_by_code, pg_rows = load_program_snapshots_by_code( db, start_key, end_key, codes, market=market, engine_params=engine_params, strategy=strategy, ) meta = snapshot_coverage_stats(ob_by_code, pg_by_code) meta["ws_orderbook_rows_loaded"] = ob_rows meta["orderbook_history_source"] = resolve_orderbook_history_source(engine_params) meta["ws_program_rows_loaded"] = pg_rows meta["trigger_eval_only"] = backtest_use_trigger_eval_snapshot( engine_params, strategy=strategy, ) meta["log_backfill_enabled"] = ( False if meta["orderbook_history_source"] == "ls" else backtest_use_log_backfill_snapshot(engine_params, strategy=strategy) ) meta["log_verdict_by_code"] = log_verdict meta["log_verdict_rows"] = sum( len(lst) for cm in log_verdict.values() for lst in cm.values() ) # 옵투나/백테: 호가 스냅 벤더 비율 (kiwoom_0d / ls_uh1 / log_backfill …) try: from kis_trader.backtest.optuna_feed_trace import log_bt_orderbook_feed_trace log_bt_orderbook_feed_trace(ob_by_code, meta, context="호가로드") except Exception as e: logger.debug("호가 피드추적 로그 스킵: %s", e) return ob_by_code, pg_by_code, meta def pick_snapshot_for_bar( by_code: Optional[Dict[str, Dict[str, Any]]], code: str, bar_candle_time: str, *, prefer_time: str = "", ) -> Any: """신호/진입 시각에 맞는 최신 스냅샷 1건 (판정 시점 ≤ prefer_time).""" if not by_code: return None target = _normalize_pick_time(bar_candle_time, prefer_time) minute_key = target[:12] bucket = (by_code.get(code) or {}).get(minute_key) or [] if bucket: return _pick_latest_le(bucket, target) code_map = by_code.get(code) or {} prior = [k for k in code_map.keys() if k <= minute_key] if not prior: return None prev_key = max(prior) prev_bucket = code_map.get(prev_key) or [] return _pick_latest_le(prev_bucket, target) if prev_bucket else None def pick_verdict_for_bar( by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]], code: str, bar_candle_time: str, *, prefer_time: str = "", ) -> Optional[Dict[str, Any]]: """log_backfill 판정 1건 (판정 시점 ≤ prefer_time).""" if not by_code: return None target = _normalize_pick_time(bar_candle_time, prefer_time) minute_key = target[:12] bucket = (by_code.get(code) or {}).get(minute_key) or [] if bucket: best = _pick_latest_le(bucket, target) return best if isinstance(best, dict) else None code_map = by_code.get(code) or {} prior = [k for k in code_map.keys() if k <= minute_key] if not prior: return None prev_key = max(prior) prev_bucket = code_map.get(prev_key) or [] best = _pick_latest_le(prev_bucket, target) return best if isinstance(best, dict) else None def inject_trigger_snapshots_into_params( params: Dict[str, Any], *, orderbook_by_code: Optional[Dict[str, Dict[str, List[OrderbookSnapshot]]]] = None, program_by_code: Optional[Dict[str, Dict[str, List[ProgramSnapshot]]]] = None, log_verdict_by_code: Optional[Dict[str, Dict[str, List[Dict[str, Any]]]]] = None, code: str, bar_candle_time: str, prefer_time: str = "", ) -> None: """백테 포트폴리오 — eval 직전 params 에 호가·프로그램 스냅샷·log 판정 주입.""" pick_t = prefer_time or bar_candle_time ob = pick_snapshot_for_bar(orderbook_by_code, code, bar_candle_time, prefer_time=pick_t) if ob is not None: params["_backtest_orderbook_snapshot"] = ob else: params.pop("_backtest_orderbook_snapshot", None) pg = pick_snapshot_for_bar(program_by_code, code, bar_candle_time, prefer_time=pick_t) if pg is not None: params["_backtest_program_snapshot"] = pg else: params.pop("_backtest_program_snapshot", None) lv_src = log_verdict_by_code if lv_src is None: lv_src = params.get("_backtest_log_verdict_by_code") verdict = pick_verdict_for_bar(lv_src, code, bar_candle_time, prefer_time=pick_t) if verdict is not None: rc = verdict.get("reject_code") rm = str(verdict.get("reject_msg") or "") params["_backtest_log_orderbook_verdict"] = (rc, rm) if rc else (None, None) else: params.pop("_backtest_log_orderbook_verdict", None)