#!/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: # SCALP 와 동일 — RAM TTL. 매 루프 full snapshot 금지(장중 DB 락 팽창). try: from kis_trader.utils.env import get_strategy_env_dict r = get_strategy_env_dict("MOMENTUM") or {} except Exception: r = {} if not r: 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) # V4 TRIGGER — 웹백테/CLI가 이 dict 만 보면 DB와 동일 (누락 시 HTML·코드 기본값으로 어긋남) use_ema_filter = momentum_env_bool(r, "MOMENTUM_USE_EMA_FILTER", False) use_rsi_max_filter = momentum_env_bool(r, "MOMENTUM_USE_RSI_MAX_FILTER", False) ema_fast_period = momentum_env_int(r, "MOMENTUM_EMA_FAST_PERIOD", 9) ema_slow_period = momentum_env_int(r, "MOMENTUM_EMA_SLOW_PERIOD", 21) pattern_breakout = momentum_env_bool(r, "MOMENTUM_PATTERN_BREAKOUT", True) pattern_pullback = momentum_env_bool(r, "MOMENTUM_PATTERN_PULLBACK", True) chase_lookback_min = momentum_env_int(r, "MOMENTUM_CHASE_LOOKBACK_MIN", 10) pullback_lookback_min = momentum_env_int(r, "MOMENTUM_PULLBACK_LOOKBACK_MIN", 15) pullback_min_pct = momentum_env_float(r, "MOMENTUM_PULLBACK_MIN_PCT", 0.3) pullback_max_pct = momentum_env_float(r, "MOMENTUM_PULLBACK_MAX_PCT", 3.0) setup_vol_max_mult = momentum_env_float(r, "MOMENTUM_SETUP_VOL_MAX_MULT", 0.8) setup_bear_bars_min = momentum_env_int(r, "MOMENTUM_SETUP_BEAR_BARS_MIN", 1) 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 # 수익구간 호가매도 (기본 OFF) exit_ob_enabled = momentum_env_bool(r, "MOMENTUM_EXIT_OB_ENABLED", False) exit_ob_ratio_min = momentum_env_float(r, "MOMENTUM_EXIT_OB_RATIO_MIN", 0.4) exit_ob_ma_window = momentum_env_int(r, "MOMENTUM_EXIT_OB_MA_WINDOW", 5) exit_ob_min_profit_pct = momentum_env_float(r, "MOMENTUM_EXIT_OB_MIN_PROFIT_PCT", 0.005) exit_ob_min_hold_bars = momentum_env_int(r, "MOMENTUM_EXIT_OB_MIN_HOLD_BARS", 3) # 손절호가 (기본 OFF) stop_ob_enabled = momentum_env_bool(r, "MOMENTUM_STOP_OB_ENABLED", False) stop_ob_ratio_min = momentum_env_float(r, "MOMENTUM_STOP_OB_RATIO_MIN", 0.4) stop_ob_ma_window = momentum_env_int(r, "MOMENTUM_STOP_OB_MA_WINDOW", 5) stop_ob_min_loss_pct = momentum_env_float(r, "MOMENTUM_STOP_OB_MIN_LOSS_PCT", 0.003) stop_ob_min_hold_bars = momentum_env_int(r, "MOMENTUM_STOP_OB_MIN_HOLD_BARS", 2) 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 use_ema_filter, use_rsi_max_filter = False, False ema_fast_period, ema_slow_period = 9, 21 pattern_breakout, pattern_pullback = True, True chase_lookback_min, pullback_lookback_min = 10, 15 pullback_min_pct, pullback_max_pct = 0.3, 3.0 setup_vol_max_mult, setup_bear_bars_min = 0.8, 1 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" exit_ob_enabled, exit_ob_ratio_min, exit_ob_ma_window = False, 0.4, 5 exit_ob_min_profit_pct, exit_ob_min_hold_bars = 0.005, 3 stop_ob_enabled, stop_ob_ratio_min, stop_ob_ma_window = False, 0.4, 5 stop_ob_min_loss_pct, stop_ob_min_hold_bars = 0.003, 2 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, "use_ema_filter": use_ema_filter, "use_rsi_max_filter": use_rsi_max_filter, "ema_fast_period": ema_fast_period, "ema_slow_period": ema_slow_period, "pattern_breakout": pattern_breakout, "pattern_pullback": pattern_pullback, "chase_lookback_min": chase_lookback_min, "pullback_lookback_min": pullback_lookback_min, "pullback_min_pct": pullback_min_pct, "pullback_max_pct": pullback_max_pct, "setup_vol_max_mult": setup_vol_max_mult, "setup_bear_bars_min": setup_bear_bars_min, **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, # 수익구간 호가매도 "exit_ob_enabled": exit_ob_enabled, "exit_ob_ratio_min": exit_ob_ratio_min, "exit_ob_ma_window": exit_ob_ma_window, "exit_ob_min_profit_pct": exit_ob_min_profit_pct, "exit_ob_min_hold_bars": exit_ob_min_hold_bars, # 손절호가 "stop_ob_enabled": stop_ob_enabled, "stop_ob_ratio_min": stop_ob_ratio_min, "stop_ob_ma_window": stop_ob_ma_window, "stop_ob_min_loss_pct": stop_ob_min_loss_pct, "stop_ob_min_hold_bars": stop_ob_min_hold_bars, } 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