Files
kis_bot/kis_trader/backtest/trigger_snapshot_loader.py
Your Name 0780b2cdd0 feat: Enhance Optuna integration and logging for backtesting framework
Changes:
- Added new API endpoints for continuing and confirming Optuna jobs, allowing for better management of ongoing studies.
- Introduced detailed logging for tick feed tracking and order book processing, improving traceability of vendor performance during backtests.
- Updated database schema to include new fields for managing Optuna study results, enhancing the ability to track study progress and outcomes.
- Refactored existing functions to utilize the new logging and tracking features, ensuring consistency across the backtesting framework.

Impact:
- These enhancements improve the robustness and transparency of the Optuna backtesting process, facilitating better analysis and optimization of trading strategies.
2026-08-21 19:05:23 +09:00

758 lines
28 KiB
Python

"""
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)