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.
674 lines
28 KiB
Python
674 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
kis_trader/engine/momentum_engine.py — 모멘텀(HTS momentum 조건식) 전용 엔진
|
|
============================================================================
|
|
스캘핑 reversal(SCALP)과 완전 분리. 백테스트·파라서치·MomentumStrategy 공통.
|
|
|
|
[전략 컨셉 — SCAN vs TRIGGER vs 청산]
|
|
- SCAN: 키움 ``momentum`` 조건검색 (E∧F∧H∧I) → target_candidates_history
|
|
- TRIGGER: ``momentum_hts_logic`` — 전일시가 돌파 유지(E) + 양봉·거래량 펄스
|
|
※ 구 1분 돌파/눌림재돌파·RSI50·EMA 패턴 TRIGGER 는 폐기
|
|
- 청산: ``momentum_hts_logic`` — 래칫·어깨·트레일·손절·시간컷
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
|
|
from kis_trader.engine.momentum_hts_logic import (
|
|
check_sell_signal_momentum_hts_live,
|
|
eval_momentum_hts_buy_at_index,
|
|
hts_trigger_defaults_from_row,
|
|
resolve_momentum_skip_hts_scan_dupes,
|
|
)
|
|
from kis_trader.engine.momentum_env_keys import (
|
|
momentum_env_bool,
|
|
momentum_env_float,
|
|
momentum_env_int,
|
|
_legacy_float,
|
|
)
|
|
from kis_trader.engine.strategy_eod import is_strategy_eod_bar
|
|
from kis_trader.utils.env import get_env_from_db, get_env_int
|
|
from kis_trader.utils.trade_time import parse_trade_datetime as _t2dt
|
|
|
|
MOMENTUM_STRATEGY_ID = "MOMENTUM"
|
|
|
|
|
|
def _to_bool(v: Any, default: bool = True) -> bool:
|
|
if v is None:
|
|
return default
|
|
if isinstance(v, bool):
|
|
return v
|
|
s = str(v).strip().lower()
|
|
if s in ("1", "true", "t", "y", "yes", "on"):
|
|
return True
|
|
if s in ("0", "false", "f", "n", "no", "off", ""):
|
|
return False
|
|
return default
|
|
|
|
|
|
def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str:
|
|
date = candle_time[:8]
|
|
hm = int(candle_time[8:12])
|
|
total_min = (hm // 100) * 60 + (hm % 100)
|
|
slot_min = (total_min // scan_interval_min) * scan_interval_min
|
|
slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
|
|
return date + str(slot_hm).zfill(4)
|
|
|
|
|
|
def compute_rsi_series(closes: list, period: int = 3) -> list:
|
|
"""RSI 시리즈 (Wilder)."""
|
|
rsi_list = [None] * len(closes)
|
|
if len(closes) < period + 1:
|
|
return rsi_list
|
|
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
|
|
gains = [max(d, 0) for d in deltas]
|
|
losses = [max(-d, 0) for d in deltas]
|
|
avg_gain = sum(gains[:period]) / period
|
|
avg_loss = sum(losses[:period]) / period
|
|
for i in range(period, len(closes)):
|
|
idx = i - 1
|
|
if i > period:
|
|
avg_gain = (avg_gain * (period - 1) + gains[idx]) / period
|
|
avg_loss = (avg_loss * (period - 1) + losses[idx]) / period
|
|
rs = avg_gain / avg_loss if avg_loss > 0 else float("inf")
|
|
rsi_val = 100 - (100 / (1 + rs)) if avg_loss > 0 else 100.0
|
|
rsi_list[i] = rsi_val
|
|
return rsi_list
|
|
|
|
|
|
def resolve_effective_tp_pct(tp_pct: float, tp_max_pct: float) -> float:
|
|
tp = abs(float(tp_pct))
|
|
cap = abs(float(tp_max_pct))
|
|
if cap > 0:
|
|
return min(tp, cap)
|
|
return tp
|
|
|
|
|
|
def effective_tp_pct_from_params(params: Dict[str, Any]) -> float:
|
|
return resolve_effective_tp_pct(
|
|
params.get("tp_pct", 0.015),
|
|
params.get("tp_max_pct", 0.02),
|
|
)
|
|
|
|
|
|
def get_momentum_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
"""env_config + config_momentum 병합 → 엔진 params dict."""
|
|
own_db = None
|
|
r: Dict[str, Any] = {}
|
|
try:
|
|
if env_row is not None:
|
|
r = dict(env_row)
|
|
else:
|
|
if db is None:
|
|
from database import TradeDB
|
|
own_db = TradeDB()
|
|
db = own_db
|
|
if hasattr(db, "get_merged_env_snapshot"):
|
|
r = db.get_merged_env_snapshot()
|
|
elif hasattr(db, "get_latest_env"):
|
|
latest = db.get_latest_env()
|
|
r = dict((latest or {}).get("snapshot") or {})
|
|
else:
|
|
row = db.conn.execute(
|
|
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
|
|
).fetchone()
|
|
r = dict(row) if row else {}
|
|
|
|
rsi_period = momentum_env_int(r, "MOMENTUM_RSI_PERIOD", 3)
|
|
mom_rsi_min = momentum_env_float(r, "MOMENTUM_RSI_MIN", 50.0)
|
|
mom_rsi_max = momentum_env_float(r, "MOMENTUM_RSI_MAX", 80.0)
|
|
mom_vol_mult = momentum_env_float(r, "MOMENTUM_VOL_MULT", 1.05)
|
|
mom_vol_win = momentum_env_int(r, "MOMENTUM_VOL_WIN", 5)
|
|
mom_time_end = momentum_env_int(r, "MOMENTUM_TIME_END_HM", 1530)
|
|
# HTS K: 전일 종가 대비 최소 등락률(%). 0=단순 종가 초과만. HTS 기본 0.2.
|
|
e_min_chg_pct = momentum_env_float(r, "MOMENTUM_E_MIN_CHG_PCT", 0.2)
|
|
sl_pct = abs(_legacy_float(
|
|
r, "MOMENTUM_STOP_LOSS_PCT", ("SCALP_STOP_LOSS_PCT",), 0.03,
|
|
))
|
|
tp_pct = _legacy_float(r, "MOMENTUM_TAKE_PROFIT_PCT", ("SCALP_TAKE_PROFIT_PCT",), 0.08)
|
|
tp_max = _legacy_float(r, "MOMENTUM_TP_MAX_PCT", ("SCALP_TP_MAX_PCT",), 0.10)
|
|
shoulder_high = _legacy_float(
|
|
r, "MOMENTUM_SHOULDER_MIN_HIGH_PCT",
|
|
("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"), 0.005,
|
|
)
|
|
shoulder_cut = _legacy_float(
|
|
r, "MOMENTUM_SHOULDER_CUT_PCT",
|
|
("SCALP_SHOULDER_CUT_PCT", "SHOULDER_CUT_PCT"), 0.003,
|
|
)
|
|
ratchet_tiers = str(
|
|
r.get("MOMENTUM_RATCHET_TIERS")
|
|
or get_env_from_db("MOMENTUM_RATCHET_TIERS", "")
|
|
or ""
|
|
).strip()
|
|
trail_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_PCT", 0.02))
|
|
trail_arm_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_ARM_PCT", 0.01))
|
|
max_hold_bars = momentum_env_int(r, "MOMENTUM_MAX_HOLD_BARS", 0)
|
|
max_daily = momentum_env_int(r, "MOMENTUM_MAX_DAILY", 5)
|
|
min_price = momentum_env_float(r, "MOMENTUM_MIN_PRICE", 1000.0)
|
|
max_daily_chg = momentum_env_float(r, "MOMENTUM_MAX_DAILY_CHG", 20.0)
|
|
high_chase = momentum_env_float(r, "MOMENTUM_HIGH_CHASE_THR", 0.96)
|
|
mom_max_open = momentum_env_float(r, "MOMENTUM_MAX_FROM_OPEN_PCT", 999.0)
|
|
mom_min_open = momentum_env_float(r, "MOMENTUM_MIN_FROM_OPEN_PCT", -999.0)
|
|
max_loss_krw = momentum_env_int(r, "MOMENTUM_MAX_LOSS_PER_TRADE_KRW", 200_000)
|
|
min_drop_loss = r.get("MOMENTUM_MIN_DROP_PCT_FOR_LOSS_CUT") or r.get("SCALP_MIN_DROP_PCT_FOR_LOSS_CUT")
|
|
min_drop_pct_for_loss_cut = 0.015
|
|
if min_drop_loss not in (None, "", "None"):
|
|
v = float(min_drop_loss)
|
|
min_drop_pct_for_loss_cut = v / 100.0 if v >= 1 else v
|
|
cooldown_sec = momentum_env_int(r, "MOMENTUM_COOLDOWN_SEC", 600)
|
|
time_start = momentum_env_int(r, "MOMENTUM_TIME_START", 830)
|
|
time_end = momentum_env_int(r, "MOMENTUM_TIME_END", mom_time_end)
|
|
skip_hts = resolve_momentum_skip_hts_scan_dupes(r)
|
|
use_defense = momentum_env_bool(r, "MOMENTUM_USE_DEFENSE_FILTERS", True)
|
|
use_high_chase_f = momentum_env_bool(r, "MOMENTUM_USE_HIGH_CHASE_FILTER", False)
|
|
use_daily_range_f = momentum_env_bool(r, "MOMENTUM_USE_DAILY_RANGE_FILTER", False)
|
|
use_rsi_filter = momentum_env_bool(r, "MOMENTUM_USE_RSI_FILTER", False)
|
|
hts_flags = hts_trigger_defaults_from_row(r)
|
|
slot_money = momentum_env_int(r, "MOMENTUM_SLOT_MONEY", 3_000_000)
|
|
max_stocks = momentum_env_int(r, "MOMENTUM_MAX_STOCKS", 3)
|
|
total_budget = momentum_env_int(r, "MOMENTUM_TOTAL_BUDGET_KRW", 0)
|
|
min_hold_sec = momentum_env_float(r, "MOMENTUM_MIN_HOLD_SEC", 30.0)
|
|
live_align = momentum_env_bool(r, "MOMENTUM_LIVE_BACKTEST_ALIGN", True)
|
|
lookback_bars = momentum_env_int(r, "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", 1)
|
|
# ALIGN 시 형성 중 봉(T)을 진입봉으로 — BT portfolio 와 동일 (확정봉만 쓰면 1봉 지연)
|
|
use_forming = momentum_env_bool(r, "MOMENTUM_LIVE_ALIGN_USE_FORMING_BAR", True)
|
|
# last_exit 분 floor — 신호봉 candle_time 과 시계 정합 (wall-clock 초 단위면 쿨다운 과다)
|
|
cd_floor = momentum_env_bool(r, "MOMENTUM_COOLDOWN_USE_CANDLE_FLOOR", True)
|
|
cd_engine_only = momentum_env_bool(r, "MOMENTUM_COOLDOWN_ENGINE_ONLY", True)
|
|
skip_pre_sub = momentum_env_bool(r, "MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE", False)
|
|
force_eod = momentum_env_bool(r, "MOMENTUM_FORCE_EOD_EXIT", False)
|
|
eod_enabled = momentum_env_bool(r, "MOMENTUM_EOD_ENABLED", True)
|
|
if r.get("MOMENTUM_EOD_ENABLED") in (None, "", "None") and r.get("MOMENTUM_FORCE_EOD_EXIT") not in (None, "", "None"):
|
|
eod_enabled = force_eod
|
|
eod_hm = str(r.get("MOMENTUM_EOD_HM") or "15:20").strip() or "15:20"
|
|
portfolio_mode = True
|
|
except Exception:
|
|
rsi_period, mom_rsi_min, mom_rsi_max = 3, 50.0, 80.0
|
|
mom_vol_mult, mom_vol_win, mom_time_end = 1.2, 5, 1530
|
|
e_min_chg_pct = 0.2
|
|
sl_pct, tp_pct, tp_max = 0.03, 0.08, 0.10
|
|
shoulder_high, shoulder_cut = 0.005, 0.003
|
|
ratchet_tiers, trail_pct, trail_arm_pct = "", 0.02, 0.01
|
|
max_hold_bars, max_daily = 0, 5
|
|
min_price, max_daily_chg, high_chase = 1000.0, 20.0, 0.96
|
|
mom_max_open, mom_min_open = 999.0, -999.0
|
|
max_loss_krw, min_drop_pct_for_loss_cut = 200_000, 0.015
|
|
cooldown_sec, time_start, time_end = 600, 830, 1530
|
|
skip_hts, use_defense = True, True
|
|
use_high_chase_f, use_daily_range_f = False, False
|
|
use_rsi_filter = False
|
|
hts_flags = hts_trigger_defaults_from_row({})
|
|
slot_money, max_stocks, total_budget = 3_000_000, 3, 0
|
|
min_hold_sec, live_align, lookback_bars = 30.0, True, 1
|
|
use_forming, cd_floor, cd_engine_only = True, True, True
|
|
skip_pre_sub = False
|
|
force_eod, portfolio_mode = False, True
|
|
eod_enabled, eod_hm = True, "15:20"
|
|
finally:
|
|
if own_db is not None:
|
|
try:
|
|
own_db.close()
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"rsi_period": rsi_period,
|
|
"mom_rsi_min": mom_rsi_min,
|
|
"mom_rsi_max": mom_rsi_max,
|
|
"mom_vol_mult": mom_vol_mult,
|
|
"mom_vol_win": mom_vol_win,
|
|
"mom_time_end_hm": mom_time_end,
|
|
"e_min_chg_pct": e_min_chg_pct,
|
|
"mom_max_from_open_pct": mom_max_open,
|
|
"mom_min_from_open_pct": mom_min_open,
|
|
"sl_pct": sl_pct,
|
|
"tp_pct": tp_pct,
|
|
"tp_max_pct": tp_max,
|
|
"shoulder_min_high": shoulder_high,
|
|
"shoulder_cut_pct": shoulder_cut,
|
|
"ratchet_tiers": ratchet_tiers,
|
|
"trail_pct": trail_pct,
|
|
"trail_arm_pct": trail_arm_pct,
|
|
"max_hold_bars": max_hold_bars,
|
|
"max_daily": max_daily,
|
|
"min_price": min_price,
|
|
"max_daily_chg": max_daily_chg,
|
|
"high_chase_thr": high_chase,
|
|
"max_loss_krw": float(max_loss_krw),
|
|
"min_drop_pct_for_loss_cut": min_drop_pct_for_loss_cut,
|
|
"cooldown_min": cooldown_sec / 60.0,
|
|
"time_start_hm": time_start,
|
|
"time_end_hm": time_end,
|
|
"skip_hts_scan_dupes": skip_hts,
|
|
"use_defense_filters": use_defense,
|
|
"use_high_chase_filter": use_high_chase_f,
|
|
"use_daily_range_filter": use_daily_range_f,
|
|
"use_rsi_filter": use_rsi_filter,
|
|
**hts_flags,
|
|
"slot_money": float(slot_money),
|
|
"max_stocks": max_stocks,
|
|
"total_budget_krw": float(total_budget),
|
|
"min_hold_sec": min_hold_sec,
|
|
"live_backtest_align": live_align,
|
|
"live_signal_lookback_bars": lookback_bars,
|
|
"live_align_use_forming_bar": use_forming,
|
|
"cooldown_use_candle_floor": cd_floor,
|
|
"cooldown_engine_only": cd_engine_only,
|
|
"backtest_skip_pre_subscribe": skip_pre_sub,
|
|
"eod_enabled": eod_enabled,
|
|
"eod_hm": eod_hm,
|
|
"force_eod_exit": eod_enabled,
|
|
"portfolio_mode": portfolio_mode,
|
|
"scan_interval_min": 1,
|
|
}
|
|
|
|
|
|
def eval_momentum_buy_at_index(
|
|
candles: List[Dict],
|
|
i: int,
|
|
params: Dict[str, Any],
|
|
state: Dict[str, Any],
|
|
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
|
|
"""HTS momentum 조건식 정합 TRIGGER — ``momentum_hts_logic`` 위임."""
|
|
return eval_momentum_hts_buy_at_index(
|
|
candles, i, params, state,
|
|
compute_rsi_series_fn=compute_rsi_series,
|
|
)
|
|
|
|
|
|
def _confirmed_candles_only(candles: List[Dict]) -> List[Dict]:
|
|
out: List[Dict] = []
|
|
for c in candles:
|
|
if c.get("is_confirmed") in (0, False, "0", "false"):
|
|
continue
|
|
out.append(c)
|
|
if len(out) < len(candles) * 0.5:
|
|
return list(candles)
|
|
return out
|
|
|
|
|
|
def check_buy_signal_momentum_live(
|
|
candles: List[Dict],
|
|
params: Dict[str, Any],
|
|
state: Dict[str, Any],
|
|
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
|
|
"""실매·백테 공용 모멘텀 진입.
|
|
|
|
live_backtest_align=True:
|
|
- 신호봉 = 직전 확정봉 (T-1)
|
|
- 진입봉 = 형성 중 봉 T (``live_align_use_forming_bar`` 기본 True) 또는
|
|
forming 없으면 마지막 확정봉 (구버전 폴백)
|
|
"""
|
|
live_align = _to_bool(params.get("live_backtest_align", True), True)
|
|
lookback = max(1, int(params.get("live_signal_lookback_bars", 1)))
|
|
use_forming = _to_bool(params.get("live_align_use_forming_bar", True), True)
|
|
|
|
forming: Optional[Dict[str, Any]] = None
|
|
if live_align and use_forming and candles:
|
|
last = candles[-1]
|
|
if last.get("is_confirmed") in (0, False, "0", "false"):
|
|
forming = last
|
|
|
|
confirmed = _confirmed_candles_only(candles)
|
|
|
|
if len(confirmed) < 6:
|
|
return ("탈락-봉부족", "확정봉 6개 미만", None)
|
|
|
|
last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = (
|
|
None, None, None,
|
|
)
|
|
|
|
if live_align:
|
|
# forming 있으면: 신호=confirmed[-1], 진입=forming open (BT idx=T 와 동일)
|
|
# forming 없으면: 신호=confirmed[-2], 진입=confirmed[-1] open (구 폴백)
|
|
if forming is not None:
|
|
entry_bar = forming
|
|
signal_base_i = len(confirmed) - 1
|
|
else:
|
|
entry_bar = confirmed[-1]
|
|
signal_base_i = len(confirmed) - 2
|
|
for k in range(lookback):
|
|
signal_i = signal_base_i - k
|
|
if signal_i < 1:
|
|
break
|
|
reject, msg, sig = eval_momentum_buy_at_index(
|
|
confirmed, signal_i, params, state,
|
|
)
|
|
if reject:
|
|
if k == 0:
|
|
last_reject = (reject, msg, None)
|
|
continue
|
|
if sig:
|
|
entry_open = float(entry_bar.get("open", 0) or 0)
|
|
if entry_open <= 0:
|
|
entry_open = float(entry_bar.get("close", 0) or 0)
|
|
sig["entry_price"] = entry_open
|
|
sig["entry_bar_key"] = entry_bar.get("candle_time")
|
|
return (None, None, sig)
|
|
return last_reject
|
|
|
|
i = len(confirmed) - 1
|
|
return eval_momentum_buy_at_index(confirmed, i, params, state)
|
|
|
|
|
|
def check_sell_signal_momentum_live(
|
|
position: Dict[str, Any],
|
|
current_candle: Dict[str, Any],
|
|
params: Dict[str, Any],
|
|
is_eod: bool = False,
|
|
) -> Optional[Tuple[str, float]]:
|
|
"""HTS momentum 추세추격 청산 — ``momentum_hts_logic`` 위임 (어깨컷 폐기)."""
|
|
return check_sell_signal_momentum_hts_live(
|
|
position, current_candle, params, is_eod=is_eod,
|
|
)
|
|
|
|
|
|
def _intrabar_exit_prices(
|
|
open_: float, high: float, low: float, close: float, n_checks: int,
|
|
) -> List[float]:
|
|
n = max(2, int(n_checks))
|
|
anchors: List[float] = []
|
|
for px in (float(open_), float(high), float(low), float(close)):
|
|
if not anchors or px != anchors[-1]:
|
|
anchors.append(px)
|
|
if len(anchors) == 1:
|
|
return [anchors[0]] * n
|
|
if n <= len(anchors):
|
|
return anchors[:n]
|
|
seg_count = len(anchors) - 1
|
|
extras = n - len(anchors)
|
|
per_seg = extras // seg_count
|
|
rem = extras % seg_count
|
|
out = [anchors[0]]
|
|
for si in range(seg_count):
|
|
a, b = anchors[si], anchors[si + 1]
|
|
extras_here = per_seg + (1 if si < rem else 0)
|
|
for j in range(1, extras_here + 1):
|
|
t = j / (extras_here + 1)
|
|
out.append(a + (b - a) * t)
|
|
if out[-1] != b:
|
|
out.append(b)
|
|
return out[:n] if len(out) >= n else out + [out[-1]] * (n - len(out))
|
|
|
|
|
|
def check_sell_signal_momentum_backtest_bar(
|
|
position: Dict[str, Any],
|
|
candle: Dict[str, Any],
|
|
params: Dict[str, Any],
|
|
is_eod: bool = False,
|
|
) -> Optional[Tuple[str, float]]:
|
|
"""백테: 1분 OHLC를 N회 가격 체크로 분할 → ``check_sell_signal_momentum_live`` 호출.
|
|
|
|
실매 MomentumStrategy 와 동일 청산 함수·우선순위. intrabar 순서만 분봉 OHLC로 근사.
|
|
"""
|
|
n_checks = get_env_int("BACKTEST_EXIT_CHECKS_PER_BAR", 6)
|
|
if n_checks <= 1:
|
|
return check_sell_signal_momentum_live(position, candle, params, is_eod)
|
|
|
|
o = float(candle.get("open", candle["close"]))
|
|
h = float(candle.get("high", candle["close"]))
|
|
l = float(candle.get("low", candle["close"]))
|
|
c = float(candle["close"])
|
|
ct = candle.get("candle_time", "")
|
|
prices = _intrabar_exit_prices(o, h, l, c, n_checks)
|
|
result: Optional[Tuple[str, float]] = None
|
|
for idx, px in enumerate(prices):
|
|
mp = float(position.get("max_price", 0) or 0)
|
|
if px > mp:
|
|
position["max_price"] = px
|
|
mp = px
|
|
sim = {"open": o, "high": mp, "low": px, "close": px, "candle_time": ct}
|
|
eod_here = bool(is_eod and idx == len(prices) - 1)
|
|
result = check_sell_signal_momentum_live(position, sim, params, is_eod=eod_here)
|
|
if result:
|
|
return result
|
|
return result
|
|
|
|
|
|
def build_universe_simulation_momentum(
|
|
codes_candles: Dict[str, List[Dict]],
|
|
top_n: int = 20,
|
|
min_score: float = 4.0,
|
|
scan_interval_min: int = 5,
|
|
) -> Dict[str, List[str]]:
|
|
"""모멘텀 SCAN 유니버스 시뮬레이션 (HTS scalp 조건검색 근사)."""
|
|
slot_codes_scores: Dict[str, List[Tuple[str, float, float]]] = {}
|
|
|
|
for code, rows in codes_candles.items():
|
|
if len(rows) < 2:
|
|
continue
|
|
candles = [dict(r) for r in rows]
|
|
by_day: Dict[str, List[Dict]] = {}
|
|
for c in candles:
|
|
day = c["candle_time"][:8]
|
|
by_day.setdefault(day, []).append(c)
|
|
|
|
for day, day_candles in by_day.items():
|
|
day_candles.sort(key=lambda x: x["candle_time"])
|
|
market_open_min = 9 * 60
|
|
seen_slots = set()
|
|
for c in day_candles:
|
|
ct = c["candle_time"]
|
|
hm = int(ct[8:12])
|
|
total_min = (hm // 100) * 60 + (hm % 100)
|
|
slot_min = (total_min // scan_interval_min) * scan_interval_min
|
|
slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
|
|
slot_key = day + str(slot_hm).zfill(4)
|
|
if slot_key in seen_slots:
|
|
continue
|
|
seen_slots.add(slot_key)
|
|
|
|
slot_min_val = (slot_hm // 100) * 60 + (slot_hm % 100)
|
|
as_of_min = max(market_open_min, slot_min_val - 1)
|
|
as_of_hm = (as_of_min // 60) * 100 + (as_of_min % 60)
|
|
as_of_str = day + str(as_of_hm).zfill(4)
|
|
up_to = [x for x in day_candles if x["candle_time"] <= as_of_str]
|
|
if len(up_to) < 2:
|
|
continue
|
|
|
|
o = float(up_to[0]["open"])
|
|
hi = max(float(x["high"]) for x in up_to)
|
|
cl = float(up_to[-1]["close"])
|
|
if o <= 0 or hi <= 0 or cl <= 0:
|
|
continue
|
|
|
|
from_open_pct = (cl / o - 1) * 100
|
|
if from_open_pct <= 0.3:
|
|
momentum_score = 0.0
|
|
elif from_open_pct > 20.0:
|
|
momentum_score = 0.0
|
|
else:
|
|
momentum_score = from_open_pct * 1.5
|
|
|
|
chase_ratio = cl / hi
|
|
chase_score = max(0.0, (chase_ratio - 0.85) * 50)
|
|
up_count = sum(1 for x in up_to if float(x["close"]) > float(x["open"]))
|
|
uptrend_score = (up_count / len(up_to)) * 5.0
|
|
|
|
closes = [float(x["close"]) for x in up_to]
|
|
rsis = compute_rsi_series(closes, 3)
|
|
last_rsi = rsis[-1] if rsis and rsis[-1] is not None else 0.0
|
|
if 50 <= last_rsi <= 70:
|
|
rsi_score = (last_rsi - 50) / 4.0
|
|
elif 70 < last_rsi <= 80:
|
|
rsi_score = 5.0 - (last_rsi - 70) / 2.0
|
|
else:
|
|
rsi_score = 0.0
|
|
|
|
total_score = momentum_score + chase_score + uptrend_score + rsi_score
|
|
vol_sum = sum(float(x.get("volume", 0)) for x in up_to)
|
|
slot_codes_scores.setdefault(slot_key, []).append((code, total_score, vol_sum))
|
|
|
|
universe_by_slot: Dict[str, List[str]] = {}
|
|
for slot_key, lst in slot_codes_scores.items():
|
|
lst = [(c, s, v) for c, s, v in lst if s >= min_score]
|
|
lst.sort(key=lambda x: (-x[1], -x[2]))
|
|
universe_by_slot[slot_key] = [x[0] for x in lst[:top_n]]
|
|
return universe_by_slot
|
|
|
|
|
|
def run_momentum_backtest(
|
|
codes_candles: Dict[str, List[Dict]],
|
|
params: Dict[str, Any],
|
|
universe_by_slot: Optional[Dict[str, List[str]]] = None,
|
|
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,
|
|
) -> List[Dict]:
|
|
"""모멘텀 백테스트 — portfolio_mode 시 포트폴리오 모듈 위임."""
|
|
if _to_bool(params.get("portfolio_mode"), True):
|
|
from kis_trader.backtest.momentum_portfolio_backtest import run_momentum_backtest_portfolio
|
|
return run_momentum_backtest_portfolio(
|
|
codes_candles, params, universe_by_slot=universe_by_slot,
|
|
ticks_by_code=ticks_by_code,
|
|
orderbook_by_code=orderbook_by_code,
|
|
program_by_code=program_by_code,
|
|
)
|
|
|
|
rsi_period = int(params.get("rsi_period", 3))
|
|
sl_pct = abs(float(params.get("sl_pct", 0.015)))
|
|
tp_pct = effective_tp_pct_from_params(params)
|
|
slot_money = float(params.get("slot_money", 300_000))
|
|
fee_rate = float(params.get("fee_rate", 0.00015))
|
|
sell_tax = float(params.get("sell_tax", 0.0018))
|
|
time_start_hm = int(params.get("time_start_hm", 900))
|
|
time_end_hm = int(params.get("mom_time_end_hm", params.get("time_end_hm", 1430)))
|
|
max_loss_krw = float(params.get("max_loss_krw", 200_000.0))
|
|
|
|
all_trades: List[Dict] = []
|
|
|
|
for code, rows in codes_candles.items():
|
|
if len(rows) < max(rsi_period + 5, 6):
|
|
continue
|
|
candles = [dict(r) for r in rows]
|
|
|
|
position: Optional[Dict] = None
|
|
last_exit_dt: Dict[str, datetime] = {}
|
|
daily_cnt: Dict[str, int] = {}
|
|
cur_day = None
|
|
from kis_trader.engine.momentum_tick_replay import (
|
|
align_momentum_entry_from_ticks,
|
|
momentum_live_align_enabled,
|
|
resolve_momentum_sell_for_bar,
|
|
)
|
|
live_align = momentum_live_align_enabled(params)
|
|
|
|
for i in range(rsi_period + 1, len(candles)):
|
|
c = candles[i]
|
|
day = c["candle_time"][:8]
|
|
hm = int(c["candle_time"][8:12])
|
|
cl = float(c["close"])
|
|
|
|
if day != cur_day:
|
|
cur_day = day
|
|
|
|
is_eod = is_strategy_eod_bar(c["candle_time"], params, "MOMENTUM")
|
|
|
|
if position is not None:
|
|
if str(position["entry_time"])[:12] == str(c["candle_time"])[:12]:
|
|
continue
|
|
sell_res = resolve_momentum_sell_for_bar(
|
|
position, c, params,
|
|
is_eod=is_eod,
|
|
ticks_by_code=ticks_by_code,
|
|
code=code,
|
|
)
|
|
if sell_res:
|
|
reason, exit_price, sell_time_key, hold_min, _exit_src = sell_res
|
|
qty = position["qty"]
|
|
buy_amt = position["entry_price"] * qty
|
|
sell_amt = exit_price * qty
|
|
pnl = (
|
|
sell_amt - buy_amt
|
|
- buy_amt * fee_rate - sell_amt * fee_rate - sell_amt * sell_tax
|
|
)
|
|
all_trades.append({
|
|
"code": code,
|
|
"buy_time": position["entry_time"],
|
|
"sell_time": sell_time_key,
|
|
"buy_price": position["entry_price"],
|
|
"sell_price": round(exit_price, 2),
|
|
"qty": qty,
|
|
"pnl": round(pnl),
|
|
"profit_rate": round(
|
|
(exit_price - position["entry_price"]) / position["entry_price"] * 100, 2
|
|
),
|
|
"hold_min": hold_min,
|
|
"sell_reason": reason,
|
|
"rsi_entry": round(position.get("rsi", 0), 1),
|
|
"strategy": MOMENTUM_STRATEGY_ID,
|
|
})
|
|
last_exit_dt[day] = _t2dt(sell_time_key)
|
|
position = None
|
|
continue
|
|
|
|
if universe_by_slot is not None:
|
|
slot_key = _slot_key(c["candle_time"], int(params.get("scan_interval_min", 1)))
|
|
if code not in universe_by_slot.get(slot_key, []):
|
|
continue
|
|
# 해외 US: 자정 넘김 RTH (_session_wrap_midnight) — 국내 경로는 기존과 동일
|
|
from kis_trader.utils.session_hm import hm_in_trading_window
|
|
_wrap = bool(params.get("_session_wrap_midnight"))
|
|
if not hm_in_trading_window(hm, time_start_hm, time_end_hm, wrap_midnight=_wrap):
|
|
continue
|
|
if live_align:
|
|
if i < 6:
|
|
continue
|
|
signal_idx = i - 1
|
|
entry_bar_time = c["candle_time"]
|
|
entry_open = float(c["open"])
|
|
if entry_open <= 0:
|
|
continue
|
|
else:
|
|
if i < 5:
|
|
continue
|
|
signal_idx = i
|
|
if i + 1 >= len(candles):
|
|
continue
|
|
next_c = candles[i + 1]
|
|
if next_c["candle_time"][:8] != day:
|
|
continue
|
|
entry_bar_time = next_c["candle_time"]
|
|
entry_open = float(next_c["open"])
|
|
if entry_open <= 0:
|
|
continue
|
|
|
|
eval_params = dict(params)
|
|
if "skip_hts_scan_dupes" not in eval_params:
|
|
eval_params["skip_hts_scan_dupes"] = resolve_momentum_skip_hts_scan_dupes()
|
|
state = {
|
|
"daily_cnt": daily_cnt.get(day, 0),
|
|
"last_exit_dt": last_exit_dt.get(day),
|
|
}
|
|
reject, _msg, sig = eval_momentum_buy_at_index(
|
|
candles, signal_idx, eval_params, state,
|
|
)
|
|
if reject or not sig:
|
|
continue
|
|
rsi = float(sig.get("rsi") or 0)
|
|
|
|
entry_price, entry_time_key, _src = align_momentum_entry_from_ticks(
|
|
ticks_by_code, code, entry_bar_time, entry_open, params,
|
|
)
|
|
|
|
invest_amount = slot_money
|
|
if max_loss_krw > 0 and sl_pct > 0:
|
|
invest_amount = min(max_loss_krw / sl_pct, slot_money)
|
|
qty = int(invest_amount / entry_price)
|
|
if qty < 1:
|
|
continue
|
|
|
|
position = {
|
|
"entry_price": entry_price,
|
|
"entry_time": entry_time_key,
|
|
"qty": qty,
|
|
"stop": entry_price * (1 - sl_pct),
|
|
"target": entry_price * (1 + tp_pct),
|
|
"max_price": entry_price,
|
|
"rsi": rsi,
|
|
}
|
|
daily_cnt[day] = daily_cnt.get(day, 0) + 1
|
|
|
|
return all_trades
|