변경 사항 ---- - _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>
229 lines
7.4 KiB
Python
229 lines
7.4 KiB
Python
#!/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, {}
|