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.
758 lines
28 KiB
Python
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)
|