변경 사항 (Changes): 구문 오류(Syntax error) 및 토큰 낭비를 방지하기 위해 에이전트 쉘(Agent shell)과 파이썬 코드 스니펫에 다수의 신규 안전 규칙(Safety rules)을 추가함. 스키마 검증 및 적절한 SQL 포맷팅을 보장하기 위해 임시(Ad-hoc) 데이터베이스 쿼리 작성 가이드라인을 도입함. 코드 수정 후 UI 기능이 정상 작동하는지 확인하기 위해, 백테스트 웹 서비스 재시작 및 브라우저 검증에 대한 새로운 규칙을 구현함. 시스템 전반의 무결성(Integrity)을 유지하기 위해 실전 매매(Live trading), 웹 백테스팅, 파라미터 탐색(Parameter searches) 간의 일관성 검사(Consistency checks) 체계를 확립함. 기대 효과 (Impact): 이러한 개선 사항들은 트레이딩 시스템의 견고성(Robustness)과 신뢰성을 향상시키며, 에러 발생을 최소화하고 다양한 시스템 컴포넌트 간의 원활한 상호작용을 보장함.
457 lines
17 KiB
Python
457 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
momentum_hts_logic.py — HTS momentum 조건식(E∧F∧H∧I) 정합 TRIGGER·청산
|
||
====================================================================
|
||
|
||
[역할 분담 — SCAN vs TRIGGER vs 청산]
|
||
- SCAN (키움 WS ``momentum`` 조건검색): E∧F∧H∧I 일봉 필터 → target_candidates_history
|
||
· E: 종가 > 전일 시가
|
||
· F: 2천~20만원
|
||
· H: 5일 평균거래량 105% 이상
|
||
· I: 거래량증감률 상위 400
|
||
- TRIGGER (본 모듈): SCAN 후 **진입 타이밍**만 검사
|
||
· ``MOMENTUM_SKIP_HTS_SCAN_DUPES=true`` (kiwoom 기본): E·양봉·분봉거래량·RSI 중복 생략
|
||
· false: E 유지·양봉·거래량 펄스(선택) — 당일 모멘텀 살아있음 확인
|
||
- 청산 (본 모듈): 래칫·어깨·트레일·손절·시간컷 (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]:
|
||
"""
|
||
전일(직전 거래일) 시가 — HTS momentum E 조건 ``close > prev_open`` 확인용.
|
||
|
||
1분봉에서 전일 첫 봉 open = 일봉 시가.
|
||
"""
|
||
prev_day: Optional[str] = None
|
||
prev_open: Optional[float] = None
|
||
for j in range(i - 1, -1, -1):
|
||
d = str(candles[j].get("candle_time", ""))[: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
|
||
return prev_open
|
||
|
||
|
||
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 _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)
|
||
|
||
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)
|
||
|
||
skip_hts = _to_bool(params.get("skip_hts_scan_dupes"), False)
|
||
prev_open = resolve_prev_trading_day_open(candles, i, day)
|
||
|
||
# kiwoom momentum SCAN — E∧F∧H∧I 이미 통과, TRIGGER 는 타이밍·호가·휩쏘만
|
||
if skip_hts:
|
||
sig_hts: Dict[str, Any] = {
|
||
"signal": True,
|
||
"mode": "momentum_hts",
|
||
"pattern": "momentum_hts_scan",
|
||
"signal_candle_time": c.get("candle_time"),
|
||
"prev_day_open": prev_open,
|
||
}
|
||
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 momentum 핵심) ─────────────────────
|
||
if trigger_e_confirm:
|
||
if prev_open is None or prev_open <= 0:
|
||
return ("탈락-전일시가없음", "전일 시가 미확인", None)
|
||
if cl <= prev_open:
|
||
return (
|
||
"탈락-E미충족",
|
||
"종가 %.0f ≤ 전일시가 %.0f" % (cl, prev_open),
|
||
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_open": prev_open,
|
||
}
|
||
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순위 손절 (하드 스탑)
|
||
4순위 트레일컷 (추세 이익 보호)
|
||
5순위 시간컷
|
||
6순위 금액손실컷 (어깨·래칫 미발동 시)
|
||
7순위 익절 (tp_max 상한, 하드 캡)
|
||
8순위 장마감청산
|
||
"""
|
||
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순위 손절
|
||
if lo <= sl_line:
|
||
return ("손절", sl_line)
|
||
|
||
# 4순위 트레일
|
||
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)
|
||
|
||
# 5순위 시간컷
|
||
if max_hold_bars > 0:
|
||
held = _minutes_held(position, current_candle)
|
||
if held is not None and held >= max_hold_bars:
|
||
return ("시간컷", cl)
|
||
|
||
# 6순위 금액손실컷
|
||
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)
|
||
|
||
# 7순위 익절 (하드 캡)
|
||
if hi >= tp_line:
|
||
return ("익절", tp_line)
|
||
|
||
# 8순위 장마감
|
||
if is_eod:
|
||
return ("장마감청산", cl)
|
||
|
||
return None
|