변경 사항 ---- - _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>
377 lines
14 KiB
Python
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
|