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

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

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

229 lines
7.4 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_chase_patterns.py — 모멘텀 V3 추세추격 TRIGGER 패턴 (OR)
꼬리잡기 ``eval_tail_reversal_pattern`` 과 동일 패턴:
- env ``MOMENTUM_PATTERN_*`` 로 ON/OFF
- 켜진 패턴 중 **하나라도** 충족 시 진입 후보
패턴:
- breakout : N분 고가 돌파 + 양봉 + 거래량
- pullback_rebreak: 스윙 고점 → 저거래 눌림(음봉) → 재돌파 + 거래량 폭발
"""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Tuple
from kis_trader.utils.env import get_env_float, get_env_int
def _to_bool(v: Any, default: bool = False) -> 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 chase_pattern_defaults() -> Dict[str, Any]:
"""V2 패턴 파라미터 기본값 — get_env_* (하드코딩 금지)."""
return {
"pattern_breakout": True,
"pattern_pullback": True,
"chase_lookback_min": get_env_int("MOMENTUM_CHASE_LOOKBACK_MIN", 10),
"pullback_lookback_min": get_env_int("MOMENTUM_PULLBACK_LOOKBACK_MIN", 15),
"pullback_min_pct": get_env_float("MOMENTUM_PULLBACK_MIN_PCT", 0.3),
"pullback_max_pct": get_env_float("MOMENTUM_PULLBACK_MAX_PCT", 3.0),
# V3 눌림 setup — 저거래 횡보·음봉 (0=OFF)
"setup_vol_max_mult": get_env_float("MOMENTUM_SETUP_VOL_MAX_MULT", 0.8),
"setup_bear_bars_min": get_env_int("MOMENTUM_SETUP_BEAR_BARS_MIN", 1),
}
def _volume_spike_ok(candles: List[Dict], i: int, params: Dict[str, Any]) -> Tuple[bool, str]:
vol_mult = float(params.get("mom_vol_mult", 1.5))
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 _pullback_setup_ok(
candles: List[Dict],
swing_idx: int,
i: int,
params: Dict[str, Any],
) -> Tuple[bool, str]:
"""
V3 눌림 setup — 스윙 고점 이후~신호봉 직전 구간이 저거래·음봉인지 검사.
``setup_vol_max_mult`` 0 이하 또는 ``setup_bear_bars_min`` 0 이면 해당 축 OFF.
"""
setup_vol_mult = float(params.get("setup_vol_max_mult", 0) or 0)
setup_bear_min = int(params.get("setup_bear_bars_min", 0) or 0)
if setup_vol_mult <= 0 and setup_bear_min <= 0:
return True, ""
pull_zone = candles[swing_idx + 1 : i]
if not pull_zone:
return False, "눌림봉없음"
if setup_bear_min > 0:
bear_cnt = sum(
1 for x in pull_zone
if float(x.get("close", 0) or 0) < float(x.get("open", 0) or 0)
)
if bear_cnt < setup_bear_min:
return False, "음봉%d<%d" % (bear_cnt, setup_bear_min)
if setup_vol_mult > 0:
pull_vols = [float(x.get("volume", 0) or 0) for x in pull_zone]
pull_avg = sum(pull_vols) / len(pull_vols) if pull_vols else 0.0
vol_win = int(params.get("mom_vol_win", 7))
base_start = max(0, swing_idx - vol_win)
base_vols = [
float(candles[k].get("volume", 0) or 0)
for k in range(base_start, swing_idx)
]
base_avg = sum(base_vols) / len(base_vols) if base_vols else pull_avg
if base_avg > 0 and pull_avg > base_avg * setup_vol_mult:
return False, "눌림Vol %.0f>%.0f×%.2f" % (pull_avg, base_avg, setup_vol_mult)
return True, ""
def _detect_breakout_pattern(
candles: List[Dict],
i: int,
params: Dict[str, Any],
) -> Tuple[bool, Dict[str, Any]]:
"""단순 돌파 — 직전 lookback 봉 고가 돌파 + 양봉 + 거래량."""
lookback = int(params.get("chase_lookback_min", 10))
if i < max(lookback, 2):
return False, {}
c = candles[i]
cl = float(c.get("close", 0) or 0)
op = float(c.get("open", 0) or 0)
if cl <= 0 or cl <= op:
return False, {}
highs = [float(x.get("high", 0) or 0) for x in candles[i - lookback : i]]
if not highs:
return False, {}
resistance = max(highs)
if resistance <= 0 or cl <= resistance:
return False, {}
ok, vmsg = _volume_spike_ok(candles, i, params)
if not ok:
return False, {}
gap_pct = (cl - resistance) / resistance * 100.0
return True, {
"pattern": "breakout",
"resistance": resistance,
"break_gap_pct": round(gap_pct, 3),
"vol_ok": vmsg or "ok",
}
def _detect_pullback_rebreak_pattern(
candles: List[Dict],
i: int,
params: Dict[str, Any],
) -> Tuple[bool, Dict[str, Any]]:
"""눌림 후 재돌파 — 스윙 고점 대비 눌림 % 후 고점 재돌파."""
lookback = int(params.get("pullback_lookback_min", 15))
pb_min = float(params.get("pullback_min_pct", 0.3))
pb_max = float(params.get("pullback_max_pct", 3.0))
if i < lookback + 2:
return False, {}
start = max(0, i - lookback)
swing_idx = start
swing_high = float(candles[start].get("high", 0) or 0)
for k in range(start + 1, i):
h = float(candles[k].get("high", 0) or 0)
if h >= swing_high:
swing_high = h
swing_idx = k
if swing_high <= 0 or swing_idx >= i - 1:
return False, {}
pull_low = swing_high
for k in range(swing_idx + 1, i + 1):
lo = float(candles[k].get("low", 0) or 0)
if lo > 0:
pull_low = min(pull_low, lo)
if pull_low >= swing_high:
return False, {}
pullback_pct = (swing_high - pull_low) / swing_high * 100.0
if pullback_pct < pb_min or pullback_pct > pb_max:
return False, {}
setup_ok, setup_msg = _pullback_setup_ok(candles, swing_idx, i, params)
if not setup_ok:
return False, {}
c = candles[i]
cl = float(c.get("close", 0) or 0)
op = float(c.get("open", 0) or 0)
if cl <= swing_high or cl <= op:
return False, {}
ok, vmsg = _volume_spike_ok(candles, i, params)
if not ok:
return False, {}
return True, {
"pattern": "pullback_rebreak",
"swing_high": swing_high,
"pullback_pct": round(pullback_pct, 3),
"setup_ok": setup_msg or "ok",
"vol_ok": vmsg or "ok",
}
def eval_momentum_chase_pattern(
candles: List[Dict],
i: int,
params: Dict[str, Any],
) -> Tuple[bool, str, Dict[str, Any]]:
"""
추세추격 패턴 OR 평가.
``MOMENTUM_PATTERN_*`` 가 모두 OFF 이면 breakout 만 검사.
"""
checks: List[Tuple[str, Callable]] = []
if _to_bool(params.get("pattern_breakout"), True):
checks.append(("breakout", _detect_breakout_pattern))
if _to_bool(params.get("pattern_pullback"), True):
checks.append(("pullback_rebreak", _detect_pullback_rebreak_pattern))
if not checks:
checks.append(("breakout", _detect_breakout_pattern))
for name, fn in checks:
ok, metrics = fn(candles, i, params)
if ok:
return True, name, metrics
active = ",".join(n for n, _ in checks)
return False, active, {}