Files
kis_bot/kis_trader/backtest/trigger_snapshot_loader.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _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>
2026-07-06 01:27:00 +09:00

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)