feat: 새로운 안전 규칙 및 최적화 적용을 통한 트레이딩 시스템 개선

변경 사항 (Changes):

구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함.

스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함.

코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함.

시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함.

기대 효과 (Impact):

이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
This commit is contained in:
Your Name
2026-07-17 01:09:09 +09:00
parent a4626e0351
commit fc27e726f9
151 changed files with 20718 additions and 6450 deletions

View File

@@ -1,30 +1,25 @@
#!/usr/bin/env python3
"""
kis_trader/engine/momentum_engine.py — 모멘텀(1분봉 추세추격) 전용 엔진
======================================================================
kis_trader/engine/momentum_engine.py — 모멘텀(HTS momentum 조건식) 전용 엔진
============================================================================
스캘핑 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 상한) → 장마감청산
※ 래칫티어가 있어도 미발동 시 어깨로 폴백. 익절%는 마지막 하드 캡.
[전략 컨셉 — SCAN vs TRIGGER vs 청산]
- SCAN: 키움 ``momentum`` 조건검색 (E∧F∧H∧I) → target_candidates_history
- TRIGGER: ``momentum_hts_logic`` — 전일시가 돌파 유지(E) + 양봉·거래량 펄스
※ 구 1분 돌파/눌림재돌파·RSI50·EMA 패턴 TRIGGER 는 폐기
- 청산: ``momentum_hts_logic`` — 래칫·어깨·트레일·손절·시간컷
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional, Tuple
from kis_trader.engine.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_hts_logic import (
check_sell_signal_momentum_hts_live,
eval_momentum_hts_buy_at_index,
hts_trigger_defaults_from_row,
resolve_momentum_skip_hts_scan_dupes,
)
from kis_trader.engine.momentum_env_keys import (
momentum_env_bool,
@@ -34,6 +29,7 @@ from kis_trader.engine.momentum_env_keys import (
)
from kis_trader.engine.strategy_eod import is_strategy_eod_bar
from kis_trader.utils.env import get_env_from_db, get_env_int
from kis_trader.utils.trade_time import parse_trade_datetime as _t2dt
MOMENTUM_STRATEGY_ID = "MOMENTUM"
@@ -51,10 +47,6 @@ def _to_bool(v: Any, default: bool = True) -> bool:
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])
@@ -100,47 +92,40 @@ def effective_tp_pct_from_params(params: Dict[str, Any]) -> float:
)
def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]:
def get_momentum_defaults_from_db(db=None, *, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""env_config + config_momentum 병합 → 엔진 params dict."""
own_db = None
r: Dict[str, Any] = {}
try:
if 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 {})
if env_row is not None:
r = dict(env_row)
else:
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
r = dict(row) if row else {}
if db is None:
from database import TradeDB
own_db = TradeDB()
db = own_db
if hasattr(db, "get_merged_env_snapshot"):
r = db.get_merged_env_snapshot()
elif hasattr(db, "get_latest_env"):
latest = db.get_latest_env()
r = dict((latest or {}).get("snapshot") or {})
else:
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
r = dict(row) if row else {}
rsi_period = momentum_env_int(r, "MOMENTUM_RSI_PERIOD", 3)
mom_rsi_min = _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_rsi_min = momentum_env_float(r, "MOMENTUM_RSI_MIN", 50.0)
mom_rsi_max = momentum_env_float(r, "MOMENTUM_RSI_MAX", 80.0)
mom_vol_mult = momentum_env_float(r, "MOMENTUM_VOL_MULT", 1.05)
mom_vol_win = momentum_env_int(r, "MOMENTUM_VOL_WIN", 5)
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
mom_time_end = momentum_env_int(r, "MOMENTUM_TIME_END_HM", 1530)
sl_pct = abs(_legacy_float(
r, "MOMENTUM_STOP_LOSS_PCT", ("SCALP_STOP_LOSS_PCT",), 0.015,
r, "MOMENTUM_STOP_LOSS_PCT", ("SCALP_STOP_LOSS_PCT",), 0.03,
))
tp_pct = _legacy_float(r, "MOMENTUM_TAKE_PROFIT_PCT", ("SCALP_TAKE_PROFIT_PCT",), 0.025)
tp_max = _legacy_float(r, "MOMENTUM_TP_MAX_PCT", ("SCALP_TP_MAX_PCT",), 0.02)
tp_pct = _legacy_float(r, "MOMENTUM_TAKE_PROFIT_PCT", ("SCALP_TAKE_PROFIT_PCT",), 0.08)
tp_max = _legacy_float(r, "MOMENTUM_TP_MAX_PCT", ("SCALP_TP_MAX_PCT",), 0.10)
shoulder_high = _legacy_float(
r, "MOMENTUM_SHOULDER_MIN_HIGH_PCT",
("SCALP_SHOULDER_MIN_HIGH_PCT", "SHOULDER_MIN_HIGH_PCT"), 0.005,
@@ -154,8 +139,8 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]:
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))
trail_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_PCT", 0.02))
trail_arm_pct = abs(momentum_env_float(r, "MOMENTUM_TRAIL_ARM_PCT", 0.01))
max_hold_bars = momentum_env_int(r, "MOMENTUM_MAX_HOLD_BARS", 0)
max_daily = momentum_env_int(r, "MOMENTUM_MAX_DAILY", 5)
min_price = momentum_env_float(r, "MOMENTUM_MIN_PRICE", 1000.0)
@@ -170,60 +155,53 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]:
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_start = momentum_env_int(r, "MOMENTUM_TIME_START", 830)
time_end = momentum_env_int(r, "MOMENTUM_TIME_END", mom_time_end)
skip_hts = momentum_env_bool(r, "MOMENTUM_SKIP_HTS_SCAN_DUPES", True)
skip_hts = resolve_momentum_skip_hts_scan_dupes(r)
use_defense = momentum_env_bool(r, "MOMENTUM_USE_DEFENSE_FILTERS", True)
use_high_chase_f = momentum_env_bool(r, "MOMENTUM_USE_HIGH_CHASE_FILTER", False)
use_daily_range_f = momentum_env_bool(r, "MOMENTUM_USE_DAILY_RANGE_FILTER", False)
use_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)
use_rsi_filter = momentum_env_bool(r, "MOMENTUM_USE_RSI_FILTER", False)
hts_flags = hts_trigger_defaults_from_row(r)
slot_money = momentum_env_int(r, "MOMENTUM_SLOT_MONEY", 3_000_000)
max_stocks = momentum_env_int(r, "MOMENTUM_MAX_STOCKS", 3)
total_budget = momentum_env_int(r, "MOMENTUM_TOTAL_BUDGET_KRW", 0)
min_hold_sec = momentum_env_float(r, "MOMENTUM_MIN_HOLD_SEC", 30.0)
live_align = momentum_env_bool(r, "MOMENTUM_LIVE_BACKTEST_ALIGN", True)
lookback_bars = momentum_env_int(r, "MOMENTUM_LIVE_SIGNAL_LOOKBACK_BARS", 1)
# ALIGN 시 형성 중 봉(T)을 진입봉으로 — BT portfolio 와 동일 (확정봉만 쓰면 1봉 지연)
use_forming = momentum_env_bool(r, "MOMENTUM_LIVE_ALIGN_USE_FORMING_BAR", True)
# last_exit 분 floor — 신호봉 candle_time 과 시계 정합 (wall-clock 초 단위면 쿨다운 과다)
cd_floor = momentum_env_bool(r, "MOMENTUM_COOLDOWN_USE_CANDLE_FLOOR", True)
cd_engine_only = momentum_env_bool(r, "MOMENTUM_COOLDOWN_ENGINE_ONLY", True)
skip_pre_sub = momentum_env_bool(r, "MOMENTUM_BACKTEST_SKIP_PRE_SUBSCRIBE", False)
force_eod = momentum_env_bool(r, "MOMENTUM_FORCE_EOD_EXIT", False)
eod_enabled = momentum_env_bool(r, "MOMENTUM_EOD_ENABLED", True)
if r.get("MOMENTUM_EOD_ENABLED") in (None, "", "None") and r.get("MOMENTUM_FORCE_EOD_EXIT") not in (None, "", "None"):
eod_enabled = force_eod
eod_hm = str(r.get("MOMENTUM_EOD_HM") or "15:25").strip() or "15:25"
eod_hm = str(r.get("MOMENTUM_EOD_HM") or "15:20").strip() or "15:20"
portfolio_mode = True
except Exception:
rsi_period, mom_rsi_min, mom_rsi_max = 3, 50.0, 80.0
mom_vol_mult, mom_vol_win, mom_time_end = 1.5, 5, 1430
sl_pct, tp_pct, tp_max = 0.015, 0.025, 0.02
mom_vol_mult, mom_vol_win, mom_time_end = 1.2, 5, 1530
sl_pct, tp_pct, tp_max = 0.03, 0.08, 0.10
shoulder_high, shoulder_cut = 0.005, 0.003
ratchet_tiers, trail_pct, trail_arm_pct = "", 0.0, 0.0
ratchet_tiers, trail_pct, trail_arm_pct = "", 0.02, 0.01
max_hold_bars, max_daily = 0, 5
min_price, max_daily_chg, high_chase = 1000.0, 20.0, 0.96
mom_max_open, mom_min_open = 999.0, -999.0
max_loss_krw, min_drop_pct_for_loss_cut = 200_000, 0.015
cooldown_sec, time_start, time_end = 600, 900, 1430
cooldown_sec, time_start, time_end = 600, 830, 1530
skip_hts, use_defense = True, True
use_high_chase_f, use_daily_range_f = False, False
use_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
use_rsi_filter = False
hts_flags = hts_trigger_defaults_from_row({})
slot_money, max_stocks, total_budget = 3_000_000, 3, 0
min_hold_sec, live_align, lookback_bars = 30.0, True, 1
use_forming, cd_floor, cd_engine_only = True, True, True
skip_pre_sub = False
force_eod, portfolio_mode = False, True
eod_enabled, eod_hm = True, "15:25"
eod_enabled, eod_hm = True, "15:20"
finally:
if own_db is not None:
try:
@@ -262,24 +240,18 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]:
"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,
"use_rsi_filter": use_rsi_filter,
**hts_flags,
"slot_money": float(slot_money),
"max_stocks": max_stocks,
"total_budget_krw": float(total_budget),
"min_hold_sec": min_hold_sec,
"live_backtest_align": live_align,
"live_signal_lookback_bars": lookback_bars,
"live_align_use_forming_bar": use_forming,
"cooldown_use_candle_floor": cd_floor,
"cooldown_engine_only": cd_engine_only,
"backtest_skip_pre_subscribe": skip_pre_sub,
"eod_enabled": eod_enabled,
"eod_hm": eod_hm,
"force_eod_exit": eod_enabled,
@@ -288,200 +260,17 @@ def get_momentum_defaults_from_db(db=None) -> Dict[str, Any]:
}
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,
"""HTS momentum 조건식 정합 TRIGGER — ``momentum_hts_logic`` 위임."""
return eval_momentum_hts_buy_at_index(
candles, i, params, state,
compute_rsi_series_fn=compute_rsi_series,
)
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]:
@@ -500,9 +289,23 @@ def check_buy_signal_momentum_live(
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""실매·백테 공용 모멘텀 진입."""
"""실매·백테 공용 모멘텀 진입.
live_backtest_align=True:
- 신호봉 = 직전 확정봉 (T-1)
- 진입봉 = 형성 중 봉 T (``live_align_use_forming_bar`` 기본 True) 또는
forming 없으면 마지막 확정봉 (구버전 폴백)
"""
live_align = _to_bool(params.get("live_backtest_align", True), True)
lookback = max(1, int(params.get("live_signal_lookback_bars", 1)))
use_forming = _to_bool(params.get("live_align_use_forming_bar", True), True)
forming: Optional[Dict[str, Any]] = None
if live_align and use_forming and candles:
last = candles[-1]
if last.get("is_confirmed") in (0, False, "0", "false"):
forming = last
confirmed = _confirmed_candles_only(candles)
if len(confirmed) < 6:
@@ -513,9 +316,16 @@ def check_buy_signal_momentum_live(
)
if live_align:
entry_i = len(confirmed) - 1
# forming 있으면: 신호=confirmed[-1], 진입=forming open (BT idx=T 와 동일)
# forming 없으면: 신호=confirmed[-2], 진입=confirmed[-1] open (구 폴백)
if forming is not None:
entry_bar = forming
signal_base_i = len(confirmed) - 1
else:
entry_bar = confirmed[-1]
signal_base_i = len(confirmed) - 2
for k in range(lookback):
signal_i = entry_i - 1 - k
signal_i = signal_base_i - k
if signal_i < 1:
break
reject, msg, sig = eval_momentum_buy_at_index(
@@ -526,12 +336,11 @@ def check_buy_signal_momentum_live(
last_reject = (reject, msg, None)
continue
if sig:
ent = confirmed[entry_i]
entry_open = float(ent.get("open", 0) or 0)
entry_open = float(entry_bar.get("open", 0) or 0)
if entry_open <= 0:
entry_open = float(ent.get("close", 0) or 0)
entry_open = float(entry_bar.get("close", 0) or 0)
sig["entry_price"] = entry_open
sig["entry_bar_key"] = ent.get("candle_time")
sig["entry_bar_key"] = entry_bar.get("candle_time")
return (None, None, sig)
return last_reject
@@ -545,101 +354,10 @@ def check_sell_signal_momentum_live(
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
"""HTS momentum 추세추격 청산 — ``momentum_hts_logic`` 위임 (어깨컷 폐기)."""
return check_sell_signal_momentum_hts_live(
position, current_candle, params, is_eod=is_eod,
)
def _intrabar_exit_prices(
@@ -910,10 +628,8 @@ def run_momentum_backtest(
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)
if "skip_hts_scan_dupes" not in eval_params:
eval_params["skip_hts_scan_dupes"] = resolve_momentum_skip_hts_scan_dupes()
state = {
"daily_cnt": daily_cnt.get(day, 0),
"last_exit_dt": last_exit_dt.get(day),