Files
kis_bot/kis_trader/engine/momentum_hts_logic.py

786 lines
29 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
"""
momentum_hts_logic.py — HTS momentum 조건식(E∧F∧H∧I) 정합 TRIGGER·청산
====================================================================
[역할 분담 — SCAN vs TRIGGER vs 청산]
- SCAN (HTS ``momentum``): F∧H∧I∧K 등 → 후보 풀
· F: 가격대 · H: 1분 거래량 펄스 · I: 거래량증감 상위(SCAN만)
· K: 전일 종가 대비 최소 등락(예 0.2%) — TRIGGER ``e_min_chg_pct`` 와 대응
- TRIGGER (본 모듈): SCAN 후 **진입 타이밍**만 검사
· ``MOMENTUM_SKIP_HTS_SCAN_DUPES=true``: K·양봉·분봉거래량 중복 생략
· false: 전일종가+e_min_chg · 양봉 · 거래량 펄스(선택)
- 청산 (본 모듈): 래칫·어깨·트레일·손절·시간컷 (A안 돌파 추격)
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Tuple
from kis_trader.engine.momentum_env_keys import momentum_env_bool, momentum_env_float, momentum_env_int
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.whipsaw_filter import whipsaw_reject_for_signal
from kis_trader.utils.env import get_env_from_db
from kis_trader.utils.trade_time import parse_trade_datetime as _t2dt
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 resolve_prev_trading_day_open(
candles: List[Dict],
i: int,
day: str,
) -> Optional[float]:
"""
전일(직전 거래일) 시가 — 레거시 참고용 (현재 E 조건은 종가 사용).
1분봉에서 전일 **장 시작 구간** 첫 봉 open = 일봉 시가.
전일 오후 봉만 있으면(웜업 부족) 오후 open을 시가로 오인하므로 None 반환.
"""
from kis_trader.utils.env import get_env_int
# 전일 시가로 인정할 최대 HHMM.
# 기본 1000: 저유동 종목이 09:14 첫체결만 있어도 시가로 인정.
open_hm_max = max(900, int(get_env_int("MOMENTUM_PREV_DAY_OPEN_HM_MAX", 1000)))
prev_day: Optional[str] = None
prev_open: Optional[float] = None
earliest_hm: Optional[int] = None
for j in range(i - 1, -1, -1):
ct = str(candles[j].get("candle_time", ""))
d = ct[:8]
if not d or d >= day:
continue
if prev_day is None:
prev_day = d
if d != prev_day:
break
op = float(candles[j].get("open", 0) or 0)
if op > 0:
prev_open = op
hm = None
if len(ct) >= 12:
try:
hm = int(ct[8:12])
except (TypeError, ValueError):
hm = None
if hm is not None:
if earliest_hm is None or hm < earliest_hm:
earliest_hm = hm
if prev_open is None or prev_open <= 0:
return None
if earliest_hm is None or earliest_hm > open_hm_max:
return None
return prev_open
def resolve_prev_trading_day_close(
candles: List[Dict],
i: int,
day: str,
) -> Optional[float]:
"""
전일(직전 거래일) 종가 — HTS momentum E 조건 ``close > prev_close`` 확인용.
1분봉 역스캔에서 전일(day 이전) 마지막 봉 close = 일봉 종가.
전일 봉이 1개도 없으면 None 반환.
"""
prev_day: Optional[str] = None
prev_close: Optional[float] = None
for j in range(i - 1, -1, -1):
ct = str(candles[j].get("candle_time", ""))
d = ct[:8]
if not d or d >= day:
continue
if prev_day is None:
prev_day = d
# 역스캔 첫 번째로 만난 전일 봉 = 전일 마지막(최신) 봉 → 종가
cl = float(candles[j].get("close", 0) or 0)
if cl > 0:
prev_close = cl
break
return prev_close if (prev_close is not None and prev_close > 0) else None
def candles_have_prev_session_open(
candles: List[Dict],
day: str,
) -> bool:
"""기간일 ``day`` 기준 직전 거래일 **장시작 시가** 봉이 있는지 (레거시 호환)."""
if not candles:
return False
d = str(day or "")[:8]
if len(d) < 8:
return True
return resolve_prev_trading_day_open(candles, len(candles) - 1, d) is not None
def candles_have_prev_session_close(
candles: List[Dict],
day: str,
) -> bool:
"""기간일 ``day`` 기준 직전 거래일 **종가** 봉이 있는지 (E 조건 해석 가능)."""
if not candles:
return False
d = str(day or "")[:8]
if len(d) < 8:
return True
return resolve_prev_trading_day_close(candles, len(candles) - 1, d) is not None
def _volume_pulse_ok(
candles: List[Dict],
i: int,
params: Dict[str, Any],
) -> Tuple[bool, str]:
"""H/I 정신: 당일 거래량 펄스 — 직전 N봉 평균 × 배수 이상."""
vol_mult = float(params.get("mom_vol_mult", 1.0))
vol_win = int(params.get("mom_vol_win", 5))
if vol_mult <= 0:
return True, ""
vol = float(candles[i].get("volume", 0) or 0)
win = max(1, min(vol_win, i))
vols = [float(candles[k].get("volume", 0) or 0) for k in range(i - win, i)]
if not vols or sum(vols) <= 0:
return False, "거래량창없음"
avg = sum(vols) / len(vols)
if avg <= 0 or vol < avg * vol_mult:
ratio = vol / avg if avg > 0 else 0.0
return False, "%.2fx < %.1fx" % (ratio, vol_mult)
return True, ""
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]:
"""어깨 발동·컷 비율 — params(비율) 또는 legacy 퍼센트."""
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 _or_ratio_from_qty(bid_qty: float, ask_qty: float) -> Optional[float]:
"""매수총잔량/매도총잔량. 매도 0이면 None (발동 금지)."""
try:
ask = float(ask_qty or 0)
bid = float(bid_qty or 0)
except (TypeError, ValueError):
return None
if ask <= 0:
return None
return bid / ask
def _or_from_orderbook_obj(ob: Any) -> Optional[float]:
"""OrderbookSnapshot / storage dict / 일부 WS dict → OR."""
if ob is None:
return None
if hasattr(ob, "total_bid_qty") and hasattr(ob, "total_ask_qty"):
return _or_ratio_from_qty(ob.total_bid_qty, ob.total_ask_qty)
if isinstance(ob, dict):
if "total_bid_qty" in ob or "total_ask_qty" in ob:
return _or_ratio_from_qty(ob.get("total_bid_qty"), ob.get("total_ask_qty"))
return None
def collect_exit_ob_or_history(
orderbook_by_code: Optional[Dict[str, Any]],
code: str,
*,
entry_time: str,
asof_time: str,
ma_window: int,
) -> List[float]:
"""
백테: 진입~asof 구간의 호가 스냅 OR 목록 (시간순).
스냅 없으면 빈 리스트 → L3 미발동.
"""
if not orderbook_by_code or not code:
return []
by_min = orderbook_by_code.get(str(code).strip())
if not by_min or not isinstance(by_min, dict):
return []
entry12 = str(entry_time or "").replace("-", "").replace(":", "").replace(" ", "")[:12]
asof12 = str(asof_time or "").replace("-", "").replace(":", "").replace(" ", "")[:12]
if len(asof12) < 12:
return []
if len(entry12) < 12:
entry12 = "000000000000"
out: List[float] = []
# minute key 는 보통 YYYYMMDDHHMM
for mk in sorted(by_min.keys()):
mk12 = str(mk)[:12]
if mk12 < entry12 or mk12 > asof12:
continue
snaps = by_min.get(mk) or []
for snap in snaps:
orv = _or_from_orderbook_obj(snap)
if orv is not None:
out.append(orv)
need = max(1, int(ma_window)) * 3
if len(out) > need:
out = out[-need:]
return out
def append_live_exit_ob_or(
history: List[Any],
ws: Any,
code: str,
*,
ma_window: int,
max_age_sec: float = 3.0,
) -> List[Any]:
"""실매: WS 호가 스냅 1장 OR append. 없으면 history 그대로."""
hist: List[Any] = list(history or [])
if ws is None or not code:
return hist
snap = None
try:
getter = getattr(ws, "get_orderbook_snapshot", None)
if callable(getter):
snap = getter(code, max_age_sec=max_age_sec)
except Exception:
snap = None
orv = _or_from_orderbook_obj(snap)
if orv is not None:
hist.append(orv)
need = max(1, int(ma_window)) * 3
if len(hist) > need:
hist = hist[-need:]
return hist
def _ob_or_ma_window_for_history(params: Dict[str, Any]) -> int:
"""수익구간·손절호가 중 켜진 쪽 MA창의 최댓값 (히스토리 수집용)."""
w = 1
if params.get("exit_ob_enabled", False):
w = max(w, max(1, int(params.get("exit_ob_ma_window", 5) or 5)))
if params.get("stop_ob_enabled", False):
w = max(w, max(1, int(params.get("stop_ob_ma_window", 5) or 5)))
return w
def need_ob_or_history(params: Dict[str, Any]) -> bool:
"""호가 OR 히스토리가 필요한지 (수익구간 또는 손절호가 ON)."""
return bool(params.get("exit_ob_enabled", False) or params.get("stop_ob_enabled", False))
def _check_exit_ob_l3(
params: Dict[str, Any],
ob_or_history: List[Optional[float]],
entry: float,
current_price: float,
hold_bars: int,
) -> bool:
"""
수익구간 호가매도 판정 (래칫·어깨 다음 · 손절 전).
발동 조건 (모두 충족 시만 True):
1) exit_ob_enabled
2) OR_MA < exit_ob_ratio_min
3) 현재가 >= 진입가 * (1 + exit_ob_min_profit_pct)
4) hold_bars >= exit_ob_min_hold_bars
5) OR 스냅 부족 시 발동 금지 (폴백 금지)
"""
if not params.get("exit_ob_enabled", False):
return False
ratio_min = float(params.get("exit_ob_ratio_min", 0.4))
ma_window = max(1, int(params.get("exit_ob_ma_window", 5)))
min_profit = float(params.get("exit_ob_min_profit_pct", 0.005))
min_hold = max(0, int(params.get("exit_ob_min_hold_bars", 3)))
if hold_bars < min_hold:
return False
if entry <= 0 or current_price < entry * (1.0 + min_profit):
return False
valid = [float(v) for v in ob_or_history if v is not None]
if len(valid) < ma_window:
return False
or_ma = sum(valid[-ma_window:]) / float(ma_window)
return or_ma < ratio_min
def _check_stop_ob(
params: Dict[str, Any],
ob_or_history: List[Optional[float]],
entry: float,
current_price: float,
hold_bars: int,
) -> bool:
"""
손실구간 손절호가 판정 (수익구간 호가매도 다음 · 하드 손절 직전).
발동 조건 (모두 충족 시만 True):
1) stop_ob_enabled
2) OR_MA < stop_ob_ratio_min
3) 현재가 <= 진입가 * (1 - stop_ob_min_loss_pct) # 이미 손해
4) hold_bars >= stop_ob_min_hold_bars
5) OR 스냅 부족 시 발동 금지 (폴백 금지)
"""
if not params.get("stop_ob_enabled", False):
return False
ratio_min = float(params.get("stop_ob_ratio_min", 0.4))
ma_window = max(1, int(params.get("stop_ob_ma_window", 5)))
min_loss = abs(float(params.get("stop_ob_min_loss_pct", 0.003)))
min_hold = max(0, int(params.get("stop_ob_min_hold_bars", 2)))
if hold_bars < min_hold:
return False
if entry <= 0 or current_price > entry * (1.0 - min_loss):
return False
valid = [float(v) for v in ob_or_history if v is not None]
if len(valid) < ma_window:
return False
or_ma = sum(valid[-ma_window:]) / float(ma_window)
return or_ma < ratio_min
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 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.05),
params.get("tp_max_pct", 0.08),
)
def resolve_momentum_skip_hts_scan_dupes(r: Optional[Dict[str, Any]] = None) -> bool:
"""
HTS momentum SCAN(kiwoom_condition) 사용 시 TRIGGER 중복 필터 생략 여부.
- ``MOMENTUM_SKIP_HTS_SCAN_DUPES`` 명시 → 그대로
- 미설정 → ``MOMENTUM_UNIVERSE_SOURCE`` 가 condition/kiwoom_condition 이면 True
"""
if r is None:
try:
from kis_trader.utils.env import get_strategy_env_dict
r = get_strategy_env_dict("MOMENTUM") or {}
except Exception:
r = {}
raw = r.get("MOMENTUM_SKIP_HTS_SCAN_DUPES")
if raw is not None and str(raw).strip() != "":
return _to_bool(raw, True)
# 엔진 defaults 에 이미 해석된 bool 이 있으면 universe fallback 금지
if "skip_hts_scan_dupes" in r and r.get("skip_hts_scan_dupes") is not None:
return _to_bool(r.get("skip_hts_scan_dupes"), False)
src = str(r.get("MOMENTUM_UNIVERSE_SOURCE") or "condition").strip().lower()
return src in ("kiwoom_condition", "condition")
def hts_trigger_defaults_from_row(r: Dict[str, Any]) -> Dict[str, Any]:
"""env 행에서 HTS TRIGGER 전용 플래그."""
return {
"trigger_e_confirm": momentum_env_bool(r, "MOMENTUM_TRIGGER_E_CONFIRM", True),
"trigger_require_bull_bar": momentum_env_bool(r, "MOMENTUM_TRIGGER_REQUIRE_BULL_BAR", True),
"use_vol_trigger": momentum_env_bool(r, "MOMENTUM_USE_VOL_TRIGGER", True),
"use_rsi_filter": momentum_env_bool(r, "MOMENTUM_USE_RSI_FILTER", False),
}
def eval_momentum_hts_buy_at_index(
candles: List[Dict],
i: int,
params: Dict[str, Any],
state: Dict[str, Any],
*,
compute_rsi_series_fn=None,
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""
HTS momentum 조건식 정합 TRIGGER.
SCAN(E∧F∧H∧I)은 이미 통과한 종목만 후보 — 여기서는 진입 타이밍만 검사.
"""
if i < 1 or i >= len(candles):
return ("탈락-봉부족", "인덱스 부적절 (i=%d)" % i, None)
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", 1530)))
cooldown_min = float(params.get("cooldown_min", 10))
max_daily = int(params.get("max_daily", 5))
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)
high_chase_thr = float(params.get("high_chase_thr", 0.96))
trigger_e_confirm = _to_bool(params.get("trigger_e_confirm"), True)
trigger_bull_bar = _to_bool(params.get("trigger_require_bull_bar"), True)
use_vol_trigger = _to_bool(params.get("use_vol_trigger"), True)
use_rsi_filter = _to_bool(params.get("use_rsi_filter"), False)
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))
c = candles[i]
day = c["candle_time"][:8]
hm = int(c["candle_time"][8:12])
op = float(c.get("open", 0) or 0)
cl = float(c.get("close", 0) or 0)
hi = float(c.get("high", cl) or cl)
# 해외 US: params._session_wrap_midnight 로 자정 넘김 RTH 허용 (국내는 기존과 동일)
from kis_trader.utils.session_hm import hm_in_trading_window
_wrap = bool(params.get("_session_wrap_midnight"))
if not hm_in_trading_window(hm, time_start_hm, time_end_hm, wrap_midnight=_wrap):
# 국내는 장중 루프에서 흔한 silent skip. 해외는 세션 키 오설정 디버깅용으로 탈락 표기.
if str(params.get("market") or "").strip().upper() == "US":
return (
"탈락-시간외",
"hm=%04d not in %d~%d (wrap=%s)"
% (hm, time_start_hm, time_end_hm, _wrap),
None,
)
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)
skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False)
prev_close = resolve_prev_trading_day_close(candles, i, day)
# kiwoom momentum SCAN — E∧F∧H∧I 이미 통과, TRIGGER 는 타이밍·호가·휩쏘만
# ※ 해외(US)는 HTS SCAN 이 없으므로 skip_hts 숏컷으로 매 봉 시그널 내면 안 됨
# → V4 추격 패턴(돌파 OR 눌림재돌파) + RSI 하한.
if skip_hts:
is_us = str(params.get("market") or "").strip().upper() == "US"
if is_us:
from kis_trader.engine.momentum_chase_patterns import eval_momentum_chase_pattern
ok_pat, pat_name, metrics = eval_momentum_chase_pattern(candles, i, params)
if not ok_pat:
return (
"탈락-패턴",
"추격패턴 미충족 (%s) close=%.4f" % (pat_name or "?", cl),
None,
)
rsi_val: Optional[float] = None
if compute_rsi_series_fn is not 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_val = ic.rsi_at(i, rsi_period)
else:
rsis = compute_rsi_series_fn(closes, rsi_period)
rsi_val = rsis[i] if i < len(rsis) else None
if rsi_val is None:
return ("탈락-RSI없음", "RSI 미계산", None)
if rsi_val < rsi_min:
return ("탈락-RSI약함", "RSI=%.1f < %.0f" % (rsi_val, rsi_min), None)
use_rsi_max = _to_bool(params.get("use_rsi_max_filter"), False)
if use_rsi_max and rsi_val > rsi_max:
return ("탈락-RSI과열", "RSI=%.1f > %.0f" % (rsi_val, rsi_max), None)
sig_hts: Dict[str, Any] = {
"signal": True,
"mode": "us_momentum_chase",
"pattern": pat_name or "chase",
"signal_candle_time": c.get("candle_time"),
"prev_day_close": prev_close,
}
if metrics:
sig_hts.update(metrics)
if rsi_val is not None:
sig_hts["rsi"] = rsi_val
return (None, None, sig_hts)
sig_hts = {
"signal": True,
"mode": "momentum_hts",
"pattern": "momentum_hts_scan",
"signal_candle_time": c.get("candle_time"),
"prev_day_close": prev_close,
}
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_hts)
# ── E: 전일 종가 대비 최소 등락률 이상 (HTS K 조건 대응)
# e_min_chg_pct > 0 이면 "전일 종가 + X%" 이상이어야 진입 (HTS 0.2% = 0.2 입력).
# 0.0 = 전일 종가 초과만 확인 (기존 동작).
e_min_chg_pct = float(params.get("e_min_chg_pct", 0.0))
if trigger_e_confirm:
if prev_close is None or prev_close <= 0:
return ("탈락-전일종가없음", "전일 종가 미확인", None)
threshold = prev_close * (1.0 + e_min_chg_pct / 100.0)
if cl < threshold:
chg_pct = (cl - prev_close) / prev_close * 100.0
return (
"탈락-E미충족",
"등락 %.2f%% < %.1f%% (종가%.0f 전일종가%.0f)" % (chg_pct, e_min_chg_pct, cl, prev_close),
None,
)
# ── 양봉: 당일 매수세 확인 (선택) ─────────────────────────────────
if trigger_bull_bar and op > 0 and cl <= op:
return ("탈락-음봉", "양봉 미충족", None)
# ── 거래량 펄스: H/I 정신 (선택) ───────────────────────────────────
if use_vol_trigger:
vol_ok, vol_msg = _volume_pulse_ok(candles, i, params)
if not vol_ok:
return ("탈락-거래량", vol_msg, None)
# ── 고점추격 방지 (선택, 기본 OFF) ───────────────────────────────
if use_high_chase_f:
running_high = hi
for j in range(i, -1, -1):
if candles[j]["candle_time"][:8] != day:
break
running_high = max(running_high, float(candles[j].get("high", 0) or 0))
if running_high > 0 and cl >= running_high * high_chase_thr:
return (
"탈락-고점추격",
"현재가 %.0f ≥ 고가 %.0f × %.2f" % (cl, running_high, high_chase_thr),
None,
)
# ── RSI 필터 (선택, 기본 OFF — SCAN 이후 보조만) ───────────────────
rsi_val: Optional[float] = None
if use_rsi_filter and compute_rsi_series_fn is not 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_val = ic.rsi_at(i, rsi_period)
else:
rsis = compute_rsi_series_fn(closes, rsi_period)
rsi_val = rsis[i] if i < len(rsis) else None
if rsi_val is None:
return ("탈락-RSI없음", "RSI 미계산", None)
if rsi_val < rsi_min:
return ("탈락-RSI약함", "RSI=%.1f < %.0f" % (rsi_val, rsi_min), None)
if rsi_val > rsi_max:
return ("탈락-RSI과열", "RSI=%.1f > %.0f" % (rsi_val, rsi_max), None)
sig: Dict[str, Any] = {
"signal": True,
"mode": "momentum_hts",
"pattern": "hts_e_confirm",
"signal_candle_time": c.get("candle_time"),
"prev_day_close": prev_close,
}
if rsi_val is not None:
sig["rsi"] = rsi_val
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 check_sell_signal_momentum_hts_live(
position: Dict[str, Any],
current_candle: Dict[str, Any],
params: Dict[str, Any],
is_eod: bool = False,
) -> Optional[Tuple[str, float]]:
"""
HTS momentum 추세추격 청산 — 어깨·래칫·트레일 병행.
[청산 우선순위]
1순위 래칫컷 (설정 시)
2순위 어깨컷 (고점 대비 되돌림)
3순위 수익구간 호가매도 (exit_ob_enabled, 기본 OFF)
4순위 손절호가 (stop_ob_enabled, 기본 OFF · 손실+OR붕괴)
5순위 손절 (하드 스탑 · 가격 하나)
6순위 트레일컷 (추세 이익 보호)
7순위 시간컷
8순위 금액손실컷 (어깨·래칫 미발동 시)
9순위 익절 (tp_max 상한, 하드 캡)
10순위 장마감청산
"""
sl_pct = -abs(float(params.get("sl_pct", params.get("stop_loss_pct", 0.03))))
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)
# 1순위 래칫
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)
# 2순위 어깨
shoulder_armed = entry > 0 and max_price >= entry * (1.0 + shoulder_min_high)
if shoulder_armed and shoulder_cut_pct > 0:
shoulder_line = max_price * (1.0 - shoulder_cut_pct)
if lo <= shoulder_line:
return ("어깨컷", shoulder_line)
# 3순위 수익구간 호가매도 (OFF 기본) — hold_bars = 보유 분(1분봉≈1봉)
ob_or_history: List[Optional[float]] = list(params.get("_ob_or_history", []))
held_min = _minutes_held(position, current_candle)
hold_bars_now = int(held_min) if held_min is not None else int(position.get("hold_bars", 0) or 0)
if _check_exit_ob_l3(params, ob_or_history, entry, cl, hold_bars_now):
return ("호가컷", cl)
# 4순위 손절호가 (OFF 기본) — 손실+OR붕괴 · 하드 손절보다 앞
if _check_stop_ob(params, ob_or_history, entry, cl, hold_bars_now):
return ("손절호가", cl)
# 5순위 손절
if lo <= sl_line:
return ("손절", sl_line)
# 6순위 트레일
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)
# 7순위 시간컷
if max_hold_bars > 0:
held = held_min if held_min is not None else _minutes_held(position, current_candle)
if held is not None and held >= max_hold_bars:
return ("시간컷", cl)
# 8순위 금액손실컷
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)
# 9순위 익절 (하드 캡)
if hi >= tp_line:
return ("익절", tp_line)
# 9순위 장마감
if is_eod:
return ("장마감청산", cl)
return None