Changes: - Introduced the `e_min_chg_pct` parameter to define the minimum price change percentage compared to the previous day's close, enhancing the momentum trading strategy. - Updated various functions and classes to incorporate this new parameter, ensuring it is utilized in both backtesting and live trading scenarios. - Improved documentation and comments to clarify the purpose and usage of the new parameter across the codebase. Impact: - This addition allows for more precise control over trading conditions, potentially increasing the effectiveness of the momentum strategy while maintaining system integrity and performance.
409 lines
15 KiB
Python
409 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
스캘핑·모멘텀 백테스트 공통 로더 — backtest_web / param_search 가
|
|
동일한 캔들·유니버스·손익 계산을 쓰도록 단일 진입점.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
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,
|
|
min_invest_ratio_of_slot,
|
|
resolve_portfolio_params,
|
|
summarize_trades,
|
|
)
|
|
from kis_trader.backtest.breakout_tick_loader import (
|
|
load_breakout_ticks_by_code,
|
|
tick_coverage_stats,
|
|
)
|
|
from kis_trader.engine import scalping_engine as se
|
|
from kis_trader.engine.tick_exit_common import strategy_use_tick_exit
|
|
from kis_trader.utils.env import get_env_bool
|
|
|
|
SCALP_STRATEGY_ID = "SCALP"
|
|
MOMENTUM_STRATEGY_ID = "MOMENTUM"
|
|
|
|
|
|
def date_keys(start: str, end: str) -> Tuple[str, str, str, str]:
|
|
"""YYYY-MM-DD → candle_time 키 및 ymd."""
|
|
start_key = start.replace("-", "") + "0000"
|
|
end_key = end.replace("-", "") + "2359"
|
|
return start_key, end_key, start_key[:8], end_key[:8]
|
|
|
|
|
|
def resolve_scalp_universe(
|
|
start_ymd: str,
|
|
end_ymd: str,
|
|
*,
|
|
use_saved_history: bool,
|
|
strategy_id: str = SCALP_STRATEGY_ID,
|
|
history_source: str = "kiwoom",
|
|
) -> Tuple[Optional[Dict[str, List[str]]], str, int, int]:
|
|
"""
|
|
backtest_web 유니버스 해석과 동일.
|
|
|
|
Returns:
|
|
(universe_by_slot, source_label, history_slot_count, scan_interval_min)
|
|
"""
|
|
if use_saved_history and strategy_id:
|
|
try:
|
|
from kis_trader.database.db_manager import get_db as _get_ext_db
|
|
from kis_trader.backtest.universe_history_source import (
|
|
history_source_label,
|
|
resolve_backtest_universe_history_source,
|
|
)
|
|
|
|
debounce_sec = scalp_universe_exit_debounce_sec()
|
|
hs = resolve_backtest_universe_history_source(history_source)
|
|
history = _get_ext_db().get_universe_by_candle_time(
|
|
strategy_id=strategy_id,
|
|
start_ymd=start_ymd,
|
|
end_ymd=end_ymd,
|
|
exit_debounce_sec=debounce_sec,
|
|
history_source=hs,
|
|
)
|
|
if history:
|
|
return history, history_source_label(hs), len(history), 1
|
|
except Exception:
|
|
pass
|
|
return None, "all", 0, 1
|
|
|
|
|
|
def scalp_universe_exit_debounce_sec() -> int:
|
|
"""실매 ``CONDITION_EXIT_GRACE_SEC`` 정합 — 스냅샷 축소 시 N초 유지."""
|
|
from kis_trader.backtest.universe_timeline import universe_exit_debounce_sec_for_strategy
|
|
|
|
return universe_exit_debounce_sec_for_strategy("SCALP")
|
|
|
|
|
|
def scalp_backtest_candle_warmup_bars() -> int:
|
|
"""백테 RSI 등 warm-up — 실매 봉 버퍼와 같이 기간 시작 전 N봉 prepend."""
|
|
from kis_trader.utils.env import get_env_int
|
|
|
|
return max(0, int(get_env_int("SCALP_BACKTEST_CANDLE_WARMUP_BARS", 50)))
|
|
|
|
|
|
def prepend_scalp_candle_warmup(
|
|
db,
|
|
candles_by_code: Dict[str, List[Dict]],
|
|
period_start_key: str,
|
|
*,
|
|
warmup_bars: Optional[int] = None,
|
|
history_source: str = "kiwoom",
|
|
) -> int:
|
|
"""
|
|
``period_start_key``(YYYYMMDDHHMM) 이전 N봉을 종목별로 prepend.
|
|
RSI 판별용 — 포트폴리오 all_times 는 ``_backtest_period_start_key`` 로 기간만 사용.
|
|
"""
|
|
wb = (
|
|
scalp_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
|
|
hs = str(history_source or "kiwoom").strip().lower()
|
|
if hs in ("ls", "ls_condition", "ls_ws"):
|
|
from kis_trader.backtest.ls_history_loaders import prepend_ls_candle_warmup
|
|
|
|
return prepend_ls_candle_warmup(
|
|
db, candles_by_code, str(period_start_key)[:12], wb,
|
|
)
|
|
ps = str(period_start_key)[:12]
|
|
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
|
|
# 이미 기간 전 봉이 있으면 skip (idempotent)
|
|
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(
|
|
"SELECT candle_time, open, high, low, close, volume "
|
|
"FROM ws_candles WHERE timeframe=1 AND code=%s "
|
|
"AND candle_time < %s AND is_confirmed=1 "
|
|
"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)
|
|
return total_prepended
|
|
|
|
|
|
def load_scalp_candles_by_code(
|
|
db,
|
|
start_key: str,
|
|
end_key: str,
|
|
rsi_period: int = 3,
|
|
*,
|
|
history_source: str = "kiwoom",
|
|
) -> Tuple[Dict[str, List[Dict]], int]:
|
|
"""1분봉 전 종목 로드 (+ 웜업). history_source=ls → ls_ws_candles.
|
|
|
|
반환은 ``(candles_by_code, total_rows)`` 만 — 호출부에 ``codes`` 리스트 없음.
|
|
웹 ``codes_analyzed`` 는 ``_codes_from_candles_map`` 으로 바인딩할 것.
|
|
"""
|
|
min_bars = int(rsi_period) + 5
|
|
hs = str(history_source or "kiwoom").strip().lower()
|
|
if hs in ("ls", "ls_condition", "ls_ws"):
|
|
from kis_trader.backtest.ls_history_loaders import load_ls_candles_by_code
|
|
|
|
candles_by_code, total_candles = load_ls_candles_by_code(
|
|
db, start_key, end_key, min_bars=min_bars,
|
|
)
|
|
prepend_scalp_candle_warmup(
|
|
db, candles_by_code, str(start_key)[:12], history_source="ls",
|
|
)
|
|
return candles_by_code, total_candles
|
|
|
|
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]
|
|
|
|
candles_by_code: Dict[str, List[Dict]] = {}
|
|
total_candles = 0
|
|
|
|
for code in codes:
|
|
rows = db.conn.execute(
|
|
"SELECT candle_time, open, high, low, close, volume "
|
|
"FROM ws_candles WHERE timeframe=1 AND code=%s "
|
|
"AND candle_time >= %s AND candle_time <= %s AND is_confirmed=1 "
|
|
"ORDER BY candle_time ASC",
|
|
[code, start_key, end_key],
|
|
).fetchall()
|
|
if len(rows) < min_bars:
|
|
continue
|
|
candles_by_code[code] = [dict(r) for r in rows]
|
|
total_candles += len(rows)
|
|
|
|
prepend_scalp_candle_warmup(db, candles_by_code, str(start_key)[:12])
|
|
return candles_by_code, total_candles
|
|
|
|
|
|
def _scalp_backtest_wants_ticks(params: Optional[Dict[str, Any]] = None) -> bool:
|
|
"""청산·진입 틱 재생이 필요한지 (기본 ON)."""
|
|
if strategy_use_tick_exit(params, "SCALP_BACKTEST_USE_TICK_EXIT", default=True):
|
|
return True
|
|
if params is not None and params.get("backtest_use_tick_entry") is not None:
|
|
return se._to_bool(params.get("backtest_use_tick_entry"), True)
|
|
return get_env_bool("SCALP_BACKTEST_USE_TICK_ENTRY", True)
|
|
|
|
|
|
def run_scalping_backtest_web_aligned(
|
|
candles_by_code: Dict[str, List[Dict]],
|
|
params: Dict[str, Any],
|
|
universe_by_slot: Optional[Dict[str, List[str]]],
|
|
*,
|
|
slot_money: float,
|
|
fee_rate: float,
|
|
sell_tax: float,
|
|
max_stocks: Optional[int] = None,
|
|
total_budget_krw: Optional[float] = None,
|
|
meta_out: Optional[Dict[str, Any]] = None,
|
|
mode: str = "reversal",
|
|
ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = None,
|
|
) -> List[Dict]:
|
|
"""엔진 1회 + 웹과 동일 손익 부착 (reversal / momentum)."""
|
|
engine_params = dict(params)
|
|
engine_params["slot_money"] = float(slot_money)
|
|
if max_stocks is not None:
|
|
engine_params["max_stocks"] = int(max_stocks)
|
|
if total_budget_krw is not None:
|
|
tb = float(total_budget_krw)
|
|
engine_params["total_budget_krw"] = tb if tb > 0 else float(
|
|
int(engine_params.get("max_stocks") or 3) * slot_money
|
|
)
|
|
if universe_by_slot is not None:
|
|
engine_params.setdefault("scan_interval_min", 1)
|
|
engine_params.setdefault("portfolio_mode", True)
|
|
|
|
# 기간 시작 키 — 웜업 봉이 all_times 에 섞이지 않도록 (돌파·모멘텀과 동일)
|
|
_sk_w = str((meta_out or {}).get("start_key") or "")[:12]
|
|
if _sk_w:
|
|
engine_params["_backtest_period_start_key"] = _sk_w
|
|
_db_w = (meta_out or {}).get("db")
|
|
if _db_w is not None and str(mode).strip().lower() != "momentum":
|
|
_hs_w = str(
|
|
engine_params.get("_universe_history_source")
|
|
or engine_params.get("universe_history_source")
|
|
or (meta_out or {}).get("universe_history_source")
|
|
or "kiwoom"
|
|
).strip().lower()
|
|
prepend_scalp_candle_warmup(
|
|
_db_w, candles_by_code, _sk_w, history_source=_hs_w,
|
|
)
|
|
|
|
from kis_trader.backtest.backtest_env_timeline import attach_backtest_env_timeline_to_params
|
|
attach_backtest_env_timeline_to_params(engine_params, meta_out, "SCALP")
|
|
|
|
if str(mode).strip().lower() == "momentum":
|
|
from kis_trader.backtest import momentum_backtest_common as mbc
|
|
trades = mbc.run_momentum_backtest_web_aligned(
|
|
candles_by_code, engine_params, universe_by_slot,
|
|
slot_money=float(slot_money),
|
|
fee_rate=float(fee_rate),
|
|
sell_tax=float(sell_tax),
|
|
max_stocks=int(engine_params.get("max_stocks") or max_stocks or 3),
|
|
total_budget_krw=float(engine_params.get("total_budget_krw") or 0),
|
|
meta_out=meta_out,
|
|
)
|
|
else:
|
|
loaded_ticks: Dict[str, Dict[str, List[Dict]]] = dict(ticks_by_code or {})
|
|
tick_meta: Dict[str, Any] = {}
|
|
if _scalp_backtest_wants_ticks(engine_params):
|
|
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 is None and start_key and end_key:
|
|
from kis_trader.backtest.backtest_portfolio_common import ensure_meta_db
|
|
|
|
db = ensure_meta_db(meta_out)
|
|
if db and start_key and end_key:
|
|
_hs_tick = str(
|
|
engine_params.get("_universe_history_source")
|
|
or engine_params.get("universe_history_source")
|
|
or (meta_out or {}).get("universe_history_source")
|
|
or "kiwoom"
|
|
).strip().lower()
|
|
if _hs_tick in ("ls", "ls_condition", "ls_ws"):
|
|
from kis_trader.backtest.ls_history_loaders import (
|
|
load_ls_ticks_by_code,
|
|
)
|
|
|
|
loaded_ticks, tick_rows = load_ls_ticks_by_code(
|
|
db, start_key, end_key, set(candles_by_code.keys()),
|
|
)
|
|
_tick_tbl = "ls_ws_ticks"
|
|
else:
|
|
loaded_ticks, tick_rows = load_breakout_ticks_by_code(
|
|
db, start_key, end_key, set(candles_by_code.keys()),
|
|
)
|
|
_tick_tbl = "ws_ticks"
|
|
tick_meta = tick_coverage_stats(candles_by_code, loaded_ticks)
|
|
tick_meta["ws_tick_rows_loaded"] = tick_rows
|
|
tick_meta["tick_table"] = _tick_tbl
|
|
if tick_rows <= 0:
|
|
from kis_trader.utils.logger import get_logger as _get_logger
|
|
|
|
_get_logger("kis_trader.scalping_backtest").warning(
|
|
"⚠️ %s 데이터 없음 — SCALP 틱 청산/진입 스킵 "
|
|
"(틱 수집 후 재백테 권장, FALLBACK_OHLC=1 시 OHLC)",
|
|
_tick_tbl,
|
|
)
|
|
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()
|
|
)
|
|
|
|
trades = se.run_scalping_backtest(
|
|
candles_by_code, engine_params, universe_by_slot=universe_by_slot,
|
|
ticks_by_code=loaded_ticks or None,
|
|
)
|
|
if meta_out is not None and tick_meta:
|
|
from kis_trader.backtest.breakout_tick_loader import enrich_tick_meta_with_traded_codes
|
|
tick_meta = enrich_tick_meta_with_traded_codes(
|
|
tick_meta, candles_by_code, loaded_ticks, trades,
|
|
)
|
|
meta_out["tick_backtest"] = tick_meta
|
|
if tick_meta.get("ws_tick_rows_loaded", 0) > 0:
|
|
meta_out["backtest_buy_source"] = "ws_ticks"
|
|
elif _scalp_backtest_wants_ticks(engine_params):
|
|
meta_out["backtest_buy_source"] = "ohlc_fallback"
|
|
else:
|
|
meta_out["backtest_buy_source"] = "align"
|
|
if meta_out is not None:
|
|
meta_out["universe_exit_debounce_sec"] = scalp_universe_exit_debounce_sec()
|
|
meta_out["candle_warmup_bars"] = scalp_backtest_candle_warmup_bars()
|
|
|
|
attach_scalp_trade_pnl(
|
|
trades, fee_rate=fee_rate, sell_tax=sell_tax,
|
|
slip_pct=backtest_slip_pct(engine_params),
|
|
)
|
|
if meta_out is not None and str(mode).strip().lower() != "momentum":
|
|
skip_stats = engine_params.get("_portfolio_skip_stats") or {}
|
|
meta_out["skip_stats"] = dict(skip_stats)
|
|
meta_out["engine_params"] = engine_params
|
|
return trades
|
|
|
|
|
|
def resolve_scalp_portfolio_params(
|
|
env_row: Optional[Dict[str, Any]],
|
|
base_defaults: Optional[Dict[str, Any]] = None,
|
|
*,
|
|
strategy: str = "SCALP",
|
|
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_defaults,
|
|
strategy=strategy,
|
|
slot_money=slot_money,
|
|
max_stocks=max_stocks,
|
|
total_budget_krw=total_budget_krw,
|
|
)
|
|
|
|
|
|
def merge_scalp_portfolio_into_params(
|
|
params: Dict[str, Any],
|
|
portfolio: Dict[str, Any],
|
|
) -> Dict[str, Any]:
|
|
return merge_portfolio_into_params(params, portfolio)
|
|
|
|
|
|
def build_scalp_budget_warning(
|
|
portfolio: Dict[str, Any],
|
|
skip_stats: Optional[Dict[str, Any]] = None,
|
|
*,
|
|
strategy: str = "SCALP",
|
|
) -> Optional[str]:
|
|
ratio = min_invest_ratio_of_slot({}, strategy=strategy)
|
|
return build_budget_warning(portfolio, skip_stats, min_invest_ratio=ratio)
|
|
|
|
|
|
def summarize_scalp_trades(
|
|
trades: List[Dict],
|
|
*,
|
|
total_budget_krw: float,
|
|
period_days: int = 1,
|
|
) -> Dict[str, Any]:
|
|
return summarize_trades(
|
|
trades,
|
|
total_budget_krw=total_budget_krw,
|
|
period_days=period_days,
|
|
)
|
|
|
|
|
|
def fee_and_slot_from_env(
|
|
row: Optional[Dict[str, Any]],
|
|
*,
|
|
strategy: str = "SCALP",
|
|
) -> Tuple[float, float, float]:
|
|
return fee_and_slot_from_env_row(row, strategy=strategy)
|