Files
kis_bot/kis_trader/backtest/momentum_backtest_common.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

377 lines
14 KiB
Python

#!/usr/bin/env python3
"""
모멘텀 백테스트 공통 — backtest_web / param_search 단일 진입점.
실매 MomentumStrategy 와 동일:
- 진입: ``momentum_engine.check_buy_signal_momentum_live``
- 청산: ws_ticks 틱 리플레이(실매 체결가) 또는 ``check_sell_signal_momentum_backtest_bar`` 폴백
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from kis_trader.backtest.backtest_portfolio_common import (
attach_scalp_trade_pnl,
backtest_slip_pct,
build_budget_warning,
fee_and_slot_from_env_row,
merge_portfolio_into_params,
resolve_portfolio_params,
resolve_trigger_snapshots_for_backtest,
summarize_trades,
)
from kis_trader.engine.momentum_engine import (
MOMENTUM_STRATEGY_ID,
get_momentum_defaults_from_db,
run_momentum_backtest,
)
from kis_trader.engine.indicator_cache import (
materialize_ws_candles_batch,
ws_candles_select_indicator_cols,
)
MOMENTUM_STRATEGY = MOMENTUM_STRATEGY_ID
def momentum_backtest_universe_strict_enabled() -> bool:
"""모멘텀 백테: 종목별 첫 event_time 이후 분만 유니버스 (실매 정합)."""
from kis_trader.utils.env import get_env_bool
return get_env_bool("MOMENTUM_BACKTEST_UNIVERSE_STRICT", True)
def momentum_backtest_universe_strict_lag_min() -> int:
"""첫 편입 분 이후 추가 대기 분 (기본 1 → 09:42:25 편입은 09:43 분봉부터)."""
from kis_trader.utils.env import get_env_int
return max(0, int(get_env_int("MOMENTUM_BACKTEST_UNIVERSE_STRICT_LAG_MIN", 1)))
def momentum_universe_exit_debounce_sec() -> int:
"""
백테 EXIT 디바운스(초).
실매 ``CONDITION_EXIT_GRACE_SEC``(기본 120)가 이미 스냅샷에 반영되어 있으면
이중 디바운스를 쓰지 않음 — env ``MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC`` 로만 강제 가능.
"""
from kis_trader.utils.env import get_env_from_db, get_env_int
raw = get_env_from_db("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", None)
if raw not in (None, ""):
try:
return max(0, int(float(raw)))
except (TypeError, ValueError):
pass
grace = int(get_env_int("CONDITION_EXIT_GRACE_SEC", 0))
if grace > 0:
return 0
return max(0, int(get_env_int("MOMENTUM_UNIVERSE_EXIT_DEBOUNCE_SEC", 30)))
def momentum_backtest_candle_warmup_bars() -> int:
"""백테 지표 warm-up — 실매 ``get_candles(50)`` 과 동일하게 전일·당일 장전 봉 선행."""
from kis_trader.utils.env import get_env_int
return max(0, int(get_env_int("MOMENTUM_BACKTEST_CANDLE_WARMUP_BARS", 50)))
def prepend_momentum_candle_warmup(
db,
candles_by_code: Dict[str, List[Dict]],
period_start_key: str,
*,
warmup_bars: Optional[int] = None,
) -> int:
"""
``period_start_key``(YYYYMMDDHHMM) 이전 N봉을 종목별로 prepend.
RSI·EMA·패턴 판별용 — 루프 시각(all_times)에는 포함하지 않음.
"""
wb = (
momentum_backtest_candle_warmup_bars()
if warmup_bars is None
else max(0, int(warmup_bars))
)
if wb <= 0 or db is None or not period_start_key:
return 0
ps = str(period_start_key)[:12]
ind_cols = ws_candles_select_indicator_cols(db)
total_prepended = 0
for code, rows in list(candles_by_code.items()):
if not rows:
continue
first_period_idx = None
for i, r in enumerate(rows):
ct = str(r.get("candle_time") or "")
if ct >= ps:
first_period_idx = i
break
if first_period_idx is None:
continue
if first_period_idx > 0:
continue
first_ct = str(rows[first_period_idx].get("candle_time") or "")
if not first_ct:
continue
warm_rows = db.conn.execute(
f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} "
"FROM ws_candles WHERE timeframe=1 AND code=%s "
"AND candle_time < %s ORDER BY candle_time DESC LIMIT %s",
[code, first_ct, wb],
).fetchall()
if not warm_rows:
continue
prefix = [dict(r) for r in reversed(warm_rows)]
candles_by_code[code] = prefix + [dict(r) for r in rows]
total_prepended += len(prefix)
if total_prepended > 0:
materialize_ws_candles_batch(db, candles_by_code, 1)
return total_prepended
def date_keys(start: str, end: str) -> Tuple[str, str, str, str]:
start_key = start.replace("-", "") + "0000"
end_key = end.replace("-", "") + "2359"
return start_key, end_key, start_key[:8], end_key[:8]
def resolve_momentum_universe(
start_ymd: str,
end_ymd: str,
*,
use_saved_history: bool,
strategy_id: str = MOMENTUM_STRATEGY_ID,
) -> Tuple[Optional[Dict[str, List[str]]], str, int, int, str]:
"""
Returns:
universe_by_slot, source_label, slot_count, scan_interval_min, universe_timing
universe_timing: 'strict' | 'minute' | 'all'
"""
if use_saved_history and strategy_id:
try:
from kis_trader.database.db_manager import get_db as _get_ext_db
strict = momentum_backtest_universe_strict_enabled()
lag_min = momentum_backtest_universe_strict_lag_min()
debounce_sec = momentum_universe_exit_debounce_sec()
history = _get_ext_db().get_universe_by_candle_time(
strategy_id=strategy_id,
start_ymd=start_ymd,
end_ymd=end_ymd,
strict=strict,
strict_lag_minutes=lag_min,
exit_debounce_sec=debounce_sec,
)
if history:
timing = "strict" if strict else "minute"
label = "history_strict" if strict else "history"
return history, label, len(history), 1, timing
except Exception:
pass
return None, "all", 0, 1, "all"
def load_momentum_candles_by_code(
db,
start_key: str,
end_key: str,
*,
warmup_bars: Optional[int] = None,
) -> Tuple[Dict[str, List[Dict]], int]:
period_start = str(start_key)[:12]
codes_raw = db.conn.execute(
"SELECT DISTINCT code FROM ws_candles WHERE timeframe=1 "
"AND candle_time >= %s AND candle_time <= %s ORDER BY code",
[start_key, end_key],
).fetchall()
codes = [r["code"] for r in codes_raw]
ind_cols = ws_candles_select_indicator_cols(db)
candles_by_code: Dict[str, List[Dict]] = {}
total = 0
for code in codes:
rows = db.conn.execute(
f"SELECT candle_time, open, high, low, close, volume, is_confirmed{ind_cols} "
"FROM ws_candles WHERE timeframe=1 AND code=%s "
"AND candle_time >= %s AND candle_time <= %s "
"ORDER BY candle_time ASC",
[code, start_key, end_key],
).fetchall()
if len(rows) < 6:
continue
candles_by_code[code] = [dict(r) for r in rows]
total += len(rows)
prepend_momentum_candle_warmup(
db, candles_by_code, period_start, warmup_bars=warmup_bars,
)
materialize_ws_candles_batch(db, candles_by_code, 1)
return candles_by_code, total
def resolve_momentum_portfolio_params(
env_row: Dict[str, Any],
base_params: Dict[str, Any],
*,
slot_money: Optional[float] = None,
max_stocks: Optional[int] = None,
total_budget_krw: Optional[float] = None,
) -> Dict[str, Any]:
return resolve_portfolio_params(
env_row,
base_params,
strategy="MOMENTUM",
slot_money=slot_money,
max_stocks=max_stocks,
total_budget_krw=total_budget_krw,
)
def run_momentum_backtest_web_aligned(
candles_by_code: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]] = None,
*,
slot_money: float = 3_000_000.0,
fee_rate: float = 0.00015,
sell_tax: float = 0.0018,
max_stocks: int = 3,
total_budget_krw: float = 0.0,
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
orderbook_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
program_by_code: Optional[Dict[str, Dict[str, List[Any]]]] = None,
meta_out: Optional[Dict[str, Any]] = None,
) -> List[Dict]:
p = dict(params)
warmup_prepended = 0
period_start_key = ""
if meta_out is not None:
start_key_meta = str(meta_out.get("start_key") or "")
if len(start_key_meta) >= 12:
period_start_key = start_key_meta[:12]
p["_backtest_period_start_key"] = period_start_key
db_meta = meta_out.get("db")
if db_meta is not None and period_start_key:
warmup_prepended = prepend_momentum_candle_warmup(
db_meta, candles_by_code, period_start_key,
)
p["slot_money"] = float(slot_money)
p["fee_rate"] = float(fee_rate)
p["sell_tax"] = float(sell_tax)
p["max_stocks"] = int(max_stocks)
if total_budget_krw > 0:
p["total_budget_krw"] = float(total_budget_krw)
p.setdefault("portfolio_mode", True)
loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {})
tick_meta: Dict[str, Any] = {}
from kis_trader.engine.momentum_tick_replay import (
momentum_backtest_use_tick_entry,
momentum_backtest_use_tick_exit,
)
if momentum_backtest_use_tick_exit(p) or momentum_backtest_use_tick_entry(p):
from kis_trader.backtest.momentum_tick_loader import (
load_momentum_ticks_by_code,
tick_coverage_stats,
)
if not loaded_ticks and meta_out is not None:
start_key = str(meta_out.get("start_key") or "")
end_key = str(meta_out.get("end_key") or "")
db = meta_out.get("db")
if db and start_key and end_key:
loaded_ticks, tick_rows = load_momentum_ticks_by_code(
db, start_key, end_key, set(candles_by_code.keys()),
)
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
tick_meta["ws_tick_rows_loaded"] = tick_rows
if tick_rows <= 0:
from kis_trader.utils.logger import get_logger as _get_logger
_get_logger("kis_trader.momentum_backtest").warning(
"⚠️ ws_ticks 데이터 없음 — 1분봉 OHLC 청산 폴백 (틱 수집 후 재백테 권장)",
)
elif loaded_ticks:
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
tick_meta["ws_tick_rows_loaded"] = sum(
len(lst) for cm in loaded_ticks.values() for lst in cm.values()
)
# ── 틱 전무 종목 제외 (실매 정합) ──────────────────────────────
# 틱이 하나도 없는 종목은 개장 순간 반짝 후보(유니버스 2분 존속)라 실제
# 매매 불가. 남겨두면 OHLC 폴백으로 유령 거래를 만들어 순위를 오염시킨다.
from kis_trader.engine.momentum_tick_replay import (
momentum_backtest_tick_only_codes,
)
if loaded_ticks and momentum_backtest_tick_only_codes(p):
tick_codes = {c for c, m in loaded_ticks.items() if any(m.values())}
before_n = len(candles_by_code)
dropped = [c for c in candles_by_code if c not in tick_codes]
if dropped and len(tick_codes) > 0:
candles_by_code = {
c: v for c, v in candles_by_code.items() if c in tick_codes
}
tick_meta["tick_only_codes_dropped"] = len(dropped)
tick_meta["tick_only_codes_kept"] = len(candles_by_code)
from kis_trader.utils.logger import get_logger as _get_logger
_get_logger("kis_trader.momentum_backtest").info(
"🎯 틱 전무 종목 %d개 제외 (%d%d종목) — 틱 있는 종목만 백테",
len(dropped), before_n, len(candles_by_code),
)
ob_loaded, pg_loaded, snap_meta = resolve_trigger_snapshots_for_backtest(
candles_by_code, p, strategy="MOMENTUM", meta_out=meta_out,
orderbook_by_code=orderbook_by_code, program_by_code=program_by_code,
)
if snap_meta.get("log_verdict_by_code"):
p["_backtest_log_verdict_by_code"] = snap_meta["log_verdict_by_code"]
if meta_out is not None:
start_key = str(meta_out.get("start_key") or "")
end_key = str(meta_out.get("end_key") or "")
if len(start_key) >= 8 and len(end_key) >= 8:
from kis_trader.backtest.momentum_universe_timeline import (
attach_momentum_universe_timeline_to_params,
)
attach_momentum_universe_timeline_to_params(
p,
start_ymd=start_key[:8],
end_ymd=end_key[:8],
strategy_id=MOMENTUM_STRATEGY_ID,
use_saved_history=universe_by_slot is not None,
)
trades = run_momentum_backtest(
candles_by_code, p, universe_by_slot=universe_by_slot,
ticks_by_code=loaded_ticks or ticks_by_code,
orderbook_by_code=ob_loaded,
program_by_code=pg_loaded,
)
if not p.get("portfolio_mode"):
attach_scalp_trade_pnl(
trades, fee_rate=fee_rate, sell_tax=sell_tax,
slip_pct=backtest_slip_pct(p),
)
if meta_out is not None:
meta_out["skip_stats"] = p.get("_portfolio_skip_stats") or {}
if warmup_prepended > 0 or momentum_backtest_candle_warmup_bars() > 0:
meta_out["skip_stats"]["candle_warmup_bars"] = momentum_backtest_candle_warmup_bars()
meta_out["skip_stats"]["candle_warmup_prepended"] = warmup_prepended
meta_out["universe_timing"] = (
"strict" if momentum_backtest_universe_strict_enabled() else "minute"
)
if p.get("_universe_timeline_meta"):
meta_out["universe_timeline"] = p.get("_universe_timeline_meta")
if snap_meta:
meta_out["trigger_snapshot_backtest"] = snap_meta
if tick_meta:
meta_out["tick_backtest"] = tick_meta
return trades
def summarize_momentum_trades(
trades: List[Dict],
*,
total_budget_krw: float,
period_days: int,
) -> Dict[str, Any]:
return summarize_trades(trades, total_budget_krw=total_budget_krw, period_days=period_days)
def count_momentum_sell_reasons(trades: List[Dict]) -> Dict[str, int]:
out: Dict[str, int] = {}
for t in trades:
r = str(t.get("sell_reason") or "기타")
out[r] = out.get(r, 0) + 1
return out