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>
This commit is contained in:
2026-07-06 01:27:00 +09:00
parent d8ba01afa4
commit 61c72a8a4c
171 changed files with 176914 additions and 7329 deletions

View File

@@ -0,0 +1,228 @@
#!/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, {}