#!/usr/bin/env python3 """ kis_trader/engine/momentum_engine.py — 모멘텀(1분봉 추세추격) 전용 엔진 ====================================================================== 스캘핑 reversal(SCALP)과 완전 분리. 백테스트·파라서치·MomentumStrategy 공통. [전략 컨셉 — SCAN vs TRIGGER] - SCAN: HTS/KIS ``scalp`` 조건검색 (F/G/H/J) → target_candidates_history - TRIGGER V2: 추세추격 패턴 OR (단순돌파 / 눌림재돌파) + EMA·거래량·RSI약세컷 ※ 구 V1(고점추격방지·RSI상한·끝물컷)은 추세추격과 상충 → 기본 OFF [청산 우선순위 — 추세추격 전용 (SCALP reversal·돌파와 다름, 실매 MomentumStrategy 동일)] 래칫(설정 시) → 어깨 → 트레일 → 손절 → 시간컷 → 금액손실컷 → 익절(tp_max 상한) → 장마감청산 ※ 래칫티어가 있어도 미발동 시 어깨로 폴백. 익절%는 마지막 하드 캡. """ from __future__ import annotations from datetime import datetime from typing import Any, Callable, Dict, List, Optional, Tuple from kis_trader.engine.ema_trend_filter import eval_ema_uptrend_reject from kis_trader.engine.whipsaw_filter import whipsaw_reject_for_signal from kis_trader.engine.orderbook_filter import orderbook_reject_for_entry from kis_trader.engine.program_filter import program_reject_for_entry from kis_trader.engine.momentum_chase_patterns import ( chase_pattern_defaults, eval_momentum_chase_pattern, ) 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_int 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 _t2dt(t: str) -> datetime: return datetime.strptime(str(t)[:12], "%Y%m%d%H%M") 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) -> Dict[str, Any]: """env_config + config_momentum 병합 → 엔진 params dict.""" own_db = None r: Dict[str, Any] = {} try: 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 = _legacy_float(r, "MOMENTUM_RSI_MIN", ("SCALP_MOM_RSI_MIN",), 50.0) mom_rsi_max = _legacy_float(r, "MOMENTUM_RSI_MAX", ("SCALP_MOM_RSI_MAX",), 80.0) mom_vol_mult = _legacy_float(r, "MOMENTUM_VOL_MULT", ("SCALP_MOM_VOL_MULT",), 1.5) mom_vol_win = momentum_env_int(r, "MOMENTUM_VOL_WIN", 5) if r.get("SCALP_MOM_VOL_WIN") not in (None, "", "None") and "MOMENTUM_VOL_WIN" not in r: try: mom_vol_win = int(float(r["SCALP_MOM_VOL_WIN"])) except (TypeError, ValueError): pass mom_time_end = momentum_env_int(r, "MOMENTUM_TIME_END_HM", 1430) if r.get("SCALP_MOM_TIME_END_HM") not in (None, "", "None") and "MOMENTUM_TIME_END_HM" not in r: try: mom_time_end = int(float(r["SCALP_MOM_TIME_END_HM"])) except (TypeError, ValueError): pass sl_pct = abs(_legacy_float( r, "MOMENTUM_STOP_LOSS_PCT", ("SCALP_STOP_LOSS_PCT",), 0.015, )) tp_pct = _legacy_float(r, "MOMENTUM_TAKE_PROFIT_PCT", ("SCALP_TAKE_PROFIT_PCT",), 0.025) tp_max = _legacy_float(r, "MOMENTUM_TP_MAX_PCT", ("SCALP_TP_MAX_PCT",), 0.02) 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.0)) trail_arm_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_ARM_PCT", 0.0)) 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", 900) time_end = momentum_env_int(r, "MOMENTUM_TIME_END", mom_time_end) skip_hts = momentum_env_bool(r, "MOMENTUM_SKIP_HTS_SCAN_DUPES", True) 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_ema_filter = momentum_env_bool(r, "MOMENTUM_USE_EMA_FILTER", True) use_rsi_max_filter = momentum_env_bool(r, "MOMENTUM_USE_RSI_MAX_FILTER", False) 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) ema_fast_period = momentum_env_int(r, "MOMENTUM_EMA_FAST_PERIOD", 9) ema_slow_period = momentum_env_int(r, "MOMENTUM_EMA_SLOW_PERIOD", 21) 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) 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:25").strip() or "15:25" 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.5, 5, 1430 sl_pct, tp_pct, tp_max = 0.015, 0.025, 0.02 shoulder_high, shoulder_cut = 0.005, 0.003 ratchet_tiers, trail_pct, trail_arm_pct = "", 0.0, 0.0 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, 900, 1430 skip_hts, use_defense = True, True use_high_chase_f, use_daily_range_f = False, False use_ema_filter = True use_rsi_max_filter = False 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 ema_fast_period, ema_slow_period = 9, 21 slot_money, max_stocks, total_budget = 3_000_000, 3, 0 min_hold_sec, live_align, lookback_bars = 30.0, True, 1 force_eod, portfolio_mode = False, True eod_enabled, eod_hm = True, "15:25" 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, "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_ema_filter": use_ema_filter, "use_rsi_max_filter": use_rsi_max_filter, "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, "ema_fast_period": ema_fast_period, "ema_slow_period": ema_slow_period, "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, "eod_enabled": eod_enabled, "eod_hm": eod_hm, "force_eod_exit": eod_enabled, "portfolio_mode": portfolio_mode, "scan_interval_min": 1, } def _parse_ratchet_tiers(params: Dict[str, Any]) -> List[Tuple[float, float]]: raw = params.get("ratchet_tiers") if raw is None: raw = get_env_from_db("MOMENTUM_RATCHET_TIERS", "") if isinstance(raw, (list, tuple)): pairs = list(raw) else: s = str(raw or "").strip() if not s: return [] pairs = [] for chunk in s.split(","): chunk = chunk.strip() if not chunk or ":" not in chunk: continue g, c = chunk.split(":", 1) pairs.append((g, c)) tiers: List[Tuple[float, float]] = [] for g, c in pairs: try: gain = abs(float(g)) / 100.0 cut = abs(float(c)) / 100.0 except (TypeError, ValueError): continue if gain <= 0 or cut <= 0: continue tiers.append((gain, cut)) tiers.sort(key=lambda x: x[0]) return tiers def _shoulder_ratios(params: Dict[str, Any]) -> Tuple[float, float]: smh = float(params.get("shoulder_min_high", 0.005)) sc = float(params.get("shoulder_cut_pct", 0.003)) return max(0.0, smh), max(0.0, sc) def _minutes_held(position: Dict[str, Any], candle: Dict[str, Any]) -> Optional[int]: try: e = _t2dt(position.get("entry_time") or position.get("buy_time", "")) n = _t2dt(candle.get("candle_time", "")) return max(0, int((n - e).total_seconds() / 60)) except Exception: return None def _day_running_high_low(candles: List[Dict], i: int, day: str) -> Tuple[float, float, float]: running_low = float(candles[i]["low"]) running_high = float(candles[i]["high"]) day_open = float(candles[i]["open"]) for j in range(i, -1, -1): if candles[j]["candle_time"][:8] != day: break running_low = min(running_low, float(candles[j]["low"])) running_high = max(running_high, float(candles[j]["high"])) day_open = float(candles[j]["open"]) return running_high, running_low, day_open 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]]]: """TRIGGER V2: 추세추격 패턴 OR + 공통 가드 (시간·쿨다운·EMA·RSI약세·거래량).""" if i < 1 or i >= len(candles): return ("탈락-봉부족", f"인덱스 부적절 (i={i})", None) rsi_period = int(params.get("rsi_period", 3)) rsi_min = float(params.get("mom_rsi_min", 50.0)) rsi_max = float(params.get("mom_rsi_max", 80.0)) 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))) cooldown_min = float(params.get("cooldown_min", 10)) max_daily = int(params.get("max_daily", 5)) max_daily_chg = float(params.get("max_daily_chg", 20.0)) min_price = float(params.get("min_price", 1000.0)) use_defense = _to_bool(params.get("use_defense_filters"), True) use_high_chase_f = _to_bool(params.get("use_high_chase_filter"), False) use_daily_range_f = _to_bool(params.get("use_daily_range_filter"), False) use_ema_filter = _to_bool(params.get("use_ema_filter"), True) use_rsi_max_filter = _to_bool(params.get("use_rsi_max_filter"), False) ema_fast_period = int(params.get("ema_fast_period", 9)) ema_slow_period = int(params.get("ema_slow_period", 21)) high_chase_thr = float(params.get("high_chase_thr", 0.96)) c = candles[i] day = c["candle_time"][:8] hm = int(c["candle_time"][8:12]) cl = float(c["close"]) if hm < time_start_hm or hm >= time_end_hm: return (None, None, None) if use_defense and cl < min_price: return ("탈락-최소가격", "%.0f < %.0f" % (cl, min_price), None) last_exit_dt = state.get("last_exit_dt") if last_exit_dt is not None: elapsed = (_t2dt(c["candle_time"]) - last_exit_dt).total_seconds() / 60 if elapsed < cooldown_min: return (None, None, None) if state.get("daily_cnt", 0) >= max_daily: return (None, None, None) closes = [float(x["close"]) for x in candles] ic = params.get("_indicator_cache") if ic is not None and hasattr(ic, "rsi_at"): rsi = ic.rsi_at(i, rsi_period) else: rsis = compute_rsi_series(closes, rsi_period) rsi = rsis[i] if i < len(rsis) else None if rsi is None: return ("탈락-RSI없음", "RSI 미계산 (봉 축적 중)", None) if rsi <= 0.0: return ("탈락-RSI무효", "RSI=0.0 (봉 부족)", None) if rsi < rsi_min: return ("탈락-모멘텀약함", "RSI=%.1f < %.0f" % (rsi, rsi_min), None) if use_rsi_max_filter and rsi > rsi_max: return ("탈락-과열끝물", "RSI=%.1f > %.0f" % (rsi, rsi_max), None) ema_fast_val = ic.ema_at(i, ema_fast_period) if ic is not None and hasattr(ic, "ema_at") else None ema_slow_val = ic.ema_at(i, ema_slow_period) if ic is not None and hasattr(ic, "ema_at") else None ema_rej, ema_msg = eval_ema_uptrend_reject( closes, i, cl, use_filter=use_ema_filter, fast_period=ema_fast_period, slow_period=ema_slow_period, ema_fast_val=ema_fast_val, ema_slow_val=ema_slow_val, ) if ema_rej: return (ema_rej, ema_msg, None) running_high, running_low, day_open = _day_running_high_low(candles, i, day) if use_daily_range_f and running_low > 0: daily_chg_pct = (running_high - running_low) / running_low * 100 if daily_chg_pct > max_daily_chg: return ("탈락-급등주", "일일변동 %.1f%% > %.0f%%" % (daily_chg_pct, max_daily_chg), None) if use_high_chase_f and running_high > 0 and cl >= running_high * high_chase_thr: return ( "탈락-고점추격", "현재가 %.0f ≥ 고가 %.0f × %.2f" % (cl, running_high, high_chase_thr), None, ) mom_max_from_open = float(params.get("mom_max_from_open_pct", 999.0)) mom_min_from_open = float(params.get("mom_min_from_open_pct", -999.0)) if day_open > 0 and mom_max_from_open < 900: from_open_pct = (cl / day_open - 1) * 100 if from_open_pct > mom_max_from_open: return ("탈락-끝물", "시가+%.1f%% > +%.0f%%" % (from_open_pct, mom_max_from_open), None) if from_open_pct < mom_min_from_open: return ("탈락-약세", "시가%+.1f%% < %+.0f%%" % (from_open_pct, mom_min_from_open), None) pat_ok, pat_name, pat_metrics = eval_momentum_chase_pattern(candles, i, params) if not pat_ok: return ( "탈락-패턴미충족", "추격패턴(%s) 미충족" % pat_name, None, ) sig: Dict[str, Any] = { "signal": True, "rsi": rsi, "mode": "momentum", "pattern": pat_name, "signal_candle_time": c.get("candle_time"), } if isinstance(pat_metrics, dict): sig.update(pat_metrics) ws_rej, ws_msg = whipsaw_reject_for_signal( params, "MOMENTUM", signal_bar=c, current_price=cl, ) if ws_rej: return (ws_rej, ws_msg, None) ob_rej, ob_msg = orderbook_reject_for_entry( params, "MOMENTUM", current_price=cl, ) if ob_rej: return (ob_rej, ob_msg, None) prog_rej, prog_msg = program_reject_for_entry( params, "MOMENTUM", current_price=cl, ) if prog_rej: return (prog_rej, prog_msg, None) return (None, None, sig) 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_align = _to_bool(params.get("live_backtest_align", True), True) lookback = max(1, int(params.get("live_signal_lookback_bars", 1))) 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: entry_i = len(confirmed) - 1 for k in range(lookback): signal_i = entry_i - 1 - 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: ent = confirmed[entry_i] entry_open = float(ent.get("open", 0) or 0) if entry_open <= 0: entry_open = float(ent.get("close", 0) or 0) sig["entry_price"] = entry_open sig["entry_bar_key"] = ent.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]]: """추세추격 전용 청산 — 실매·백테·파서치 공용 (SCALP reversal 과 분리). [청산 우선순위 — 스캘핑 V4 어깨 선행과 동일 계열] 1순위 래칫컷 / 어깨컷 — 고점 대비 되돌림 (상승 보유 → 하락 시 매도) 2순위 트레일컷 3순위 손절 4순위 시간컷 5순위 금액손실컷 (어깨·래칫 미발동 시) 6순위 익절 — tp_max 상한 (하드 캡) 7순위 장마감청산 """ sl_pct = -abs(float(params.get("sl_pct", params.get("stop_loss_pct", 0.015)))) tp_pct = effective_tp_pct_from_params(params) trail_pct = abs(float(params.get("trail_pct", 0.0) or 0.0)) trail_arm_pct = abs(float(params.get("trail_arm_pct", 0.0) or 0.0)) shoulder_min_high, shoulder_cut_pct = _shoulder_ratios(params) ratchet_tiers = _parse_ratchet_tiers(params) max_hold_bars = int(params.get("max_hold_bars", 0) or 0) max_loss_krw = float(params.get("max_loss_krw", 200_000.0)) min_hold_sec = float(params.get("min_hold_sec", 30.0)) min_drop_pct = float(params.get("min_drop_pct_for_loss_cut", 0.015)) try: hi = float(current_candle.get("high", current_candle["close"])) lo = float(current_candle.get("low", current_candle["close"])) cl = float(current_candle["close"]) except Exception: return None candle_time = current_candle.get("candle_time", "") if candle_time and position.get("entry_time"): try: if (_t2dt(candle_time) - _t2dt(position["entry_time"])).total_seconds() < min_hold_sec: return None except Exception: pass max_price = max(float(position.get("max_price", position["entry_price"])), hi) position["max_price"] = max_price entry = float(position["entry_price"]) qty = int(position.get("qty", 1) or 1) sl_line = entry * (1 + sl_pct) tp_line = entry * (1 + tp_pct) if ratchet_tiers and entry > 0: peak_gain = (max_price - entry) / entry cut_ratio = 0.0 for gain, cut in ratchet_tiers: if peak_gain >= gain: cut_ratio = cut if cut_ratio > 0.0: ratchet_line = max_price * (1.0 - cut_ratio) if lo <= ratchet_line: return ("래칫컷", ratchet_line) trail_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high) shoulder_line = max_price * (1.0 - shoulder_cut_pct) if trail_armed else 0.0 if trail_armed and lo <= shoulder_line: return ("어깨컷", shoulder_line) if lo <= sl_line: return ("손절", sl_line) if trail_pct > 0 and max_price > entry: trail_arm_line = entry * (1.0 + trail_arm_pct) if trail_arm_pct <= 0 or max_price >= trail_arm_line: trail_line = max_price * (1.0 - trail_pct) if lo <= trail_line: return ("트레일컷", trail_line) if max_hold_bars > 0: held = _minutes_held(position, current_candle) if held is not None and held >= max_hold_bars: return ("시간컷", cl) shoulder_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high) profit_val = (lo - entry) * qty drop_pct = (entry - lo) / entry if entry > 0 else 0.0 if ( not shoulder_armed and not ratchet_tiers and profit_val <= -max_loss_krw and drop_pct >= min_drop_pct ): exit_px = entry - (max_loss_krw / qty) if qty > 0 else lo return ("금액손실컷", exit_px) if hi >= tp_line: return ("익절", tp_line) if is_eod: return ("장마감청산", cl) return None 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 if hm < time_start_hm or hm >= time_end_hm: 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 universe_by_slot is not None: eval_params.setdefault("skip_hts_scan_dupes", True) else: eval_params.setdefault("skip_hts_scan_dupes", False) 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