변경 사항 ---- - _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가 - _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가 - _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가 영향 ---- - 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임 - 기존 기능에 대한 영향 없음 Co-authored-by: Cursor <cursoragent@cursor.com>
565 lines
20 KiB
Python
565 lines
20 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.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"
|
|
|
|
|
|
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)
|
|
return bool(ob_on or pg_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 = "",
|
|
) -> List[Dict[str, Any]]:
|
|
"""ws_orderbook 행 조회 (source·strategy 필터)."""
|
|
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"
|
|
|
|
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_vals = _strategy_db_values(strategy)
|
|
strat_filter = ""
|
|
if strat_vals:
|
|
strat_filter = (
|
|
f" AND (strategy IS NULL OR strategy IN ({','.join(['%s'] * len(strat_vals))}))"
|
|
)
|
|
params.extend(strat_vals)
|
|
|
|
sql = f"""
|
|
SELECT 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
|
|
FROM ws_orderbook
|
|
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("ws_orderbook 조회 실패 — TRIGGER 호가 스킵: %s", 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 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]]]]]:
|
|
"""기간 내 ``ws_orderbook`` → ``{code: {minute: [snap, ...]}}`` + log_backfill 판정."""
|
|
mkt = (market or get_env_from_db("WS_TICK_DEFAULT_MARKET", "KR") or "KR").strip().upper()
|
|
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)
|
|
|
|
sources: List[str] = []
|
|
if use_eval:
|
|
sources.append(FILTER_EVAL_SOURCE)
|
|
if use_log_bf:
|
|
sources.append(LOG_BACKFILL_SOURCE)
|
|
if use_kiwoom:
|
|
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,
|
|
)
|
|
|
|
out: Dict[str, Dict[str, List[OrderbookSnapshot]]] = 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:
|
|
# 분당 최신 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
|
|
|
|
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["ws_program_rows_loaded"] = pg_rows
|
|
meta["trigger_eval_only"] = backtest_use_trigger_eval_snapshot(
|
|
engine_params, strategy=strategy,
|
|
)
|
|
meta["log_backfill_enabled"] = 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()
|
|
)
|
|
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)
|