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>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,944 @@
#!/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