Files
kis_bot/kis_trader/engine/momentum_engine.py
Hwang 61c72a8a4c feat(tests): 신규 키움 웹소켓 조건검색 및 실시간 조건검색 테스트 추가
변경 사항
----
- _test_kiwoom_condition_list.py: 키움 웹소켓 조건검색 '목록조회' 기능을 단독으로 테스트하는 스크립트 추가
- _test_kiwoom_condition_realtime.py: 'momentum' 조건식을 실시간으로 등록하고 초기 매칭 종목 리스트 및 실시간 편입/이탈을 수신하는 테스트 스크립트 추가
- _verify_columnar_bitid.py, _verify_shared_e2e_breakout.py, _verify_shared_e2e.py: 공유 메모리 및 dict 간의 데이터 일관성을 검증하는 테스트 추가

영향
----
- 신규 테스트 스크립트 추가로 키움 웹소켓 API의 기능 검증 및 안정성을 높임
- 기존 기능에 대한 영향 없음

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 01:27:00 +09:00

945 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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.utils.env import get_env_from_db, 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)
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
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,
"force_eod_exit": force_eod,
"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))
force_eod_exit = _to_bool(params.get("force_eod_exit"), False)
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_raw = (i == len(candles) - 1) or (candles[i + 1]["candle_time"][:8] != day)
is_eod = is_eod_raw and force_eod_exit
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