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,459 @@
#!/usr/bin/env python3
"""
kis_trader/engine/range_break_engine.py — 박스권 돌파(RANGE_BREAK) 공통 엔진
====================================================================
[SCAN] HTS momentum 조건(F·G·J) — 오늘 거래 활발 종목 풀
[TRIGGER] 횡보 박스 형성 → 거래량 폭발 양봉으로 박스 상단 돌파 시 진입
매도: 박스 실패(상단 재이탈) → 익절 → 어깨 → 손절 → 트레일 → EOD
(청산 본체는 breakout 과 동일 우선순위 — ``check_sell_signal_breakout_live`` 재사용)
"""
from __future__ import annotations
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from kis_trader.engine.range_break_env_keys import (
range_break_env_bool,
range_break_env_float,
range_break_env_int,
)
from kis_trader.strategies.breakout import (
check_sell_signal_breakout_live,
normalize_breakout_max_loss_krw,
)
RANGE_BREAK_STRATEGY_ID = "RANGE_BREAK"
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 _confirmed_candles_only(candles: List[Dict]) -> List[Dict]:
confirmed = [c for c in candles if _to_bool(c.get("is_confirmed", 1), True)]
return confirmed if confirmed else list(candles)
def _t2dt(t: str) -> datetime:
return datetime.strptime(str(t)[:12], "%Y%m%d%H%M")
def _slot_key(candle_time: str, scan_interval_min: int = 1) -> str:
date = candle_time[:8]
hm = int(candle_time[8:12])
total_min = (hm // 100) * 60 + (hm % 100)
slot_min = (total_min // scan_interval_min) * scan_interval_min
slot_hm = (slot_min // 60) * 100 + (slot_min % 60)
return date + str(slot_hm).zfill(4)
def _day_running_high_low(candles: List[Dict], i: int, day: str) -> Tuple[float, float, float]:
running_high = float(candles[i]["high"])
running_low = float(candles[i]["low"])
day_open = float(candles[i]["open"])
for j in range(i, -1, -1):
if candles[j]["candle_time"][:8] != day:
break
running_high = max(running_high, float(candles[j]["high"]))
running_low = min(running_low, float(candles[j]["low"]))
day_open = float(candles[j]["open"])
return running_high, running_low, day_open
def _box_window(candles: List[Dict], i: int, box_lookback: int) -> List[Dict]:
"""신호봉 i 직전 box_lookback 개 확정봉 = 박스 구간."""
start = max(0, i - box_lookback)
return candles[start:i]
def eval_range_break_buy_at_index(
candles: List[Dict],
i: int,
params: Dict[str, Any],
day_open: Optional[float] = None,
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""박스권 돌파 매수 TRIGGER — 인덱스 i 신호봉."""
box_lookback = int(params.get("box_lookback_min", 30))
box_max_w = float(params.get("box_max_width_pct", 2.5))
box_min_w = float(params.get("box_min_width_pct", 0.3))
setup_vol_mult = float(params.get("setup_vol_max_mult", 0.8))
setup_bear_min = int(params.get("setup_bear_bars_min", 1))
vol_mult = float(params.get("vol_mult", 2.0))
vol_win = int(params.get("vol_window", 7))
break_margin = float(params.get("break_margin_pct", 0.0) or 0.0)
body_min = float(params.get("body_min_pct", 0.0) or 0.0)
time_start = int(params.get("time_start_hm", 1030))
time_end = int(params.get("time_end_hm", 1520))
min_price = float(params.get("min_price", 1000.0))
max_daily_chg = float(params.get("max_daily_chg", 25.0))
use_high_chase = _to_bool(params.get("use_high_chase_filter"), True)
high_chase_thr = float(params.get("high_chase_thr", 0.96))
vol_baseline_win = int(params.get("vol_baseline_win", 30))
need = max(box_lookback, vol_win, vol_baseline_win) + 3
if i < need or i >= len(candles):
return ("탈락-봉부족", "need=%d i=%d" % (need, i), None)
c = candles[i]
ct = str(c.get("candle_time", ""))
if len(ct) < 12:
return ("탈락-시간없음", ct, None)
hm = int(ct[8:12])
if hm < time_start or hm >= time_end:
return (None, None, None)
try:
op = float(c["open"])
hi = float(c["high"])
lo = float(c["low"])
cl = float(c["close"])
vol = float(c.get("volume", 0) or 0)
except Exception as e:
return ("탈락-캔들파싱", str(e), None)
if cl < min_price:
return ("탈락-최소가격", "%.0f" % cl, None)
box_arr = _box_window(candles, i, box_lookback)
if len(box_arr) < max(3, box_lookback // 2):
return ("탈락-박스창없음", "len=%d" % len(box_arr), None)
box_high = max(float(x["high"]) for x in box_arr)
box_low = min(float(x["low"]) for x in box_arr)
if box_low <= 0 or box_high <= box_low:
return ("탈락-박스무효", "high=%.0f low=%.0f" % (box_high, box_low), None)
box_width_pct = (box_high - box_low) / box_low * 100.0
if box_width_pct > box_max_w:
return ("탈락-박스넓음", "%.2f%% > %.2f%%" % (box_width_pct, box_max_w), None)
if box_width_pct < box_min_w:
return ("탈락-박스좁음", "%.2f%% < %.2f%%" % (box_width_pct, box_min_w), None)
box_vols = [float(x.get("volume", 0) or 0) for x in box_arr]
box_vol_avg = sum(box_vols) / len(box_vols) if box_vols else 0.0
base_start = max(0, i - box_lookback - vol_baseline_win)
base_vols = [
float(candles[k].get("volume", 0) or 0)
for k in range(base_start, i - box_lookback)
]
base_avg = sum(base_vols) / len(base_vols) if base_vols else box_vol_avg
if base_avg > 0 and box_vol_avg > base_avg * setup_vol_mult:
return (
"탈락-박스거래량과다",
"%.0f > %.0f×%.2f" % (box_vol_avg, base_avg, setup_vol_mult),
None,
)
bear_cnt = sum(
1 for x in box_arr
if float(x.get("close", 0) or 0) < float(x.get("open", 0) or 0)
)
if bear_cnt < setup_bear_min:
return ("탈락-음봉부족", "%d < %d" % (bear_cnt, setup_bear_min), None)
if cl <= op:
return ("탈락-음봉", "close<=open", None)
need_price = box_high * (1.0 + break_margin / 100.0)
if cl < need_price:
return (
"탈락-박스미돌파",
"close=%.0f < need=%.0f" % (cl, need_price),
None,
)
if body_min > 0 and op > 0:
body_pct = (cl - op) / op * 100.0
if body_pct < body_min:
return ("탈락-몸통부족", "%.2f%%" % body_pct, None)
vol_ref = box_vol_avg if box_vol_avg > 0 else 0.0
if vol_ref <= 0:
win_arr = [float(candles[k].get("volume", 0) or 0) for k in range(i - vol_win, i)]
vol_ref = sum(win_arr) / len(win_arr) if win_arr else 0.0
if vol_ref <= 0 or vol < vol_ref * vol_mult:
ratio = vol / vol_ref if vol_ref > 0 else 0.0
return ("탈락-거래량부족", "%.2fx < %.1fx" % (ratio, vol_mult), None)
day = ct[:8]
running_high, running_low, d_open = _day_running_high_low(candles, i, day)
if day_open is None:
day_open = d_open
if day_open > 0:
daily_chg = (cl - day_open) / day_open * 100.0
if daily_chg > max_daily_chg:
return ("탈락-이격과열", "%.1f%%" % daily_chg, None)
if use_high_chase and running_high > 0 and cl >= running_high * high_chase_thr:
return ("탈락-고점추격", "%.0f" % cl, None)
vol_ratio = vol / vol_ref if vol_ref > 0 else 0.0
return (None, None, {
"box_high": box_high,
"box_low": box_low,
"box_width_pct": round(box_width_pct, 3),
"vol_ratio": vol_ratio,
"bear_bars": bear_cnt,
"signal_candle_time": ct,
})
def check_buy_signal_range_break_live(
candles: List[Dict],
params: Dict[str, Any],
state: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
"""실매·백테 공용 — align: 신호봉 확정 후 다음 봉 시가 진입."""
live_align = _to_bool(params.get("live_backtest_align", True), True)
lookback = max(1, int(params.get("live_signal_lookback_bars", 1)))
confirmed = _confirmed_candles_only(candles)
if len(confirmed) < 10:
return ("탈락-봉부족", "확정봉 부족", None)
cooldown_min = float(params.get("cooldown_min", 30))
max_daily = int(params.get("max_daily", 1))
last_exit_dt = state.get("last_exit_dt")
if last_exit_dt is not None:
elapsed = (_t2dt(confirmed[-1]["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)
last_reject: Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]] = (
None, None, None,
)
if live_align:
entry_i = len(confirmed) - 1
for k in range(lookback):
signal_i = entry_i - 1 - k
if signal_i < 1:
break
reject, msg, sig = eval_range_break_buy_at_index(
confirmed, signal_i, params, None,
)
if reject:
if k == 0:
last_reject = (reject, msg, None)
continue
if sig:
ent = confirmed[entry_i]
entry_open = float(ent.get("open", 0) or 0)
if entry_open <= 0:
entry_open = float(ent.get("close", 0) or 0)
sig["entry_price"] = entry_open
sig["entry_bar_key"] = ent.get("candle_time")
sig["box_stop_line"] = float(sig.get("box_high", 0) or 0)
return (None, None, sig)
return last_reject
i = len(confirmed) - 1
return eval_range_break_buy_at_index(confirmed, i, params, None)
def check_sell_signal_range_break_live(
position: Dict[str, Any],
current_candle: Dict[str, Any],
params: Dict[str, Any],
is_eod: bool = False,
) -> Optional[Tuple[str, float]]:
"""박스 상단 재이탈 우선 → breakout 청산 체인."""
try:
lo = float(current_candle.get("low", current_candle["close"]))
cl = float(current_candle["close"])
except Exception:
return None
box_line = float(position.get("box_stop_line", 0) or 0)
if box_line > 0 and lo < box_line:
return ("box_fail", min(cl, box_line))
return check_sell_signal_breakout_live(position, current_candle, params, is_eod=is_eod)
def get_range_break_defaults_from_db(db=None) -> Dict[str, Any]:
"""env_config + config_range_break 병합."""
own_db = None
r: Dict[str, Any] = {}
try:
if db is None:
from database import TradeDB
own_db = TradeDB()
db = own_db
if hasattr(db, "get_merged_env_snapshot"):
r = db.get_merged_env_snapshot()
elif hasattr(db, "get_latest_env"):
latest = db.get_latest_env()
r = dict((latest or {}).get("snapshot") or {})
else:
row = db.conn.execute(
"SELECT * FROM env_config ORDER BY id DESC LIMIT 1"
).fetchone()
r = dict(row) if row else {}
except Exception:
r = {}
finally:
if own_db is not None:
try:
own_db.close()
except Exception:
pass
sl = abs(range_break_env_float(r, "RANGE_BREAK_STOP_LOSS_PCT", 0.03))
tp = range_break_env_float(r, "RANGE_BREAK_TAKE_PROFIT_PCT", 0.10)
trail = range_break_env_float(r, "RANGE_BREAK_TRAIL_PCT", 0.015)
trail_arm = range_break_env_float(r, "RANGE_BREAK_TRAIL_ARM_PCT", 0.015)
shoulder_high = range_break_env_float(r, "RANGE_BREAK_SHOULDER_MIN_HIGH_PCT", 0.03)
shoulder_cut = range_break_env_float(r, "RANGE_BREAK_SHOULDER_CUT_PCT", 0.005)
cooldown_sec = range_break_env_int(r, "RANGE_BREAK_COOLDOWN_SEC", 1800)
return {
"box_lookback_min": range_break_env_int(r, "RANGE_BREAK_BOX_LOOKBACK_MIN", 30),
"box_max_width_pct": range_break_env_float(r, "RANGE_BREAK_BOX_MAX_WIDTH_PCT", 2.5),
"box_min_width_pct": range_break_env_float(r, "RANGE_BREAK_BOX_MIN_WIDTH_PCT", 0.3),
"setup_vol_max_mult": range_break_env_float(r, "RANGE_BREAK_SETUP_VOL_MAX_MULT", 0.8),
"setup_bear_bars_min": range_break_env_int(r, "RANGE_BREAK_SETUP_BEAR_BARS_MIN", 1),
"vol_mult": range_break_env_float(r, "RANGE_BREAK_VOL_MULT", 2.0),
"vol_window": range_break_env_int(r, "RANGE_BREAK_VOL_WIN", 7),
"break_margin_pct": range_break_env_float(r, "RANGE_BREAK_BREAK_MARGIN_PCT", 0.0),
"body_min_pct": range_break_env_float(r, "RANGE_BREAK_BODY_MIN_PCT", 0.0),
"time_start_hm": range_break_env_int(r, "RANGE_BREAK_TIME_START", 1030),
"time_end_hm": range_break_env_int(r, "RANGE_BREAK_TIME_END_HM", 1520),
"sl_pct": sl,
"tp_pct": tp,
"trail_pct": trail,
"trail_arm_pct": trail_arm,
"shoulder_min_high": shoulder_high,
"shoulder_cut_pct": shoulder_cut,
"max_hold_bars": range_break_env_int(r, "RANGE_BREAK_MAX_HOLD_BARS", 0),
"max_daily": range_break_env_int(r, "RANGE_BREAK_MAX_DAILY", 1),
"cooldown_min": cooldown_sec / 60.0,
"max_daily_chg": range_break_env_float(r, "RANGE_BREAK_MAX_DAILY_CHG", 25.0),
"min_price": range_break_env_float(r, "RANGE_BREAK_MIN_PRICE", 1000.0),
"high_chase_thr": range_break_env_float(r, "RANGE_BREAK_HIGH_CHASE_THR", 0.96),
"use_high_chase_filter": range_break_env_bool(r, "RANGE_BREAK_USE_HIGH_CHASE_FILTER", True),
"max_loss_krw": float(range_break_env_int(r, "RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", 200_000)),
"slot_money": float(range_break_env_int(r, "RANGE_BREAK_SLOT_MONEY", 200_000)),
"max_stocks": range_break_env_int(r, "RANGE_BREAK_MAX_STOCKS", 20),
"total_budget_krw": range_break_env_int(r, "RANGE_BREAK_TOTAL_BUDGET_KRW", 4_000_000),
"live_backtest_align": range_break_env_bool(r, "RANGE_BREAK_LIVE_BACKTEST_ALIGN", True),
"live_signal_lookback_bars": range_break_env_int(r, "RANGE_BREAK_LIVE_SIGNAL_LOOKBACK_BARS", 1),
"force_eod_exit": range_break_env_bool(r, "RANGE_BREAK_FORCE_EOD_EXIT", False),
"vol_baseline_win": range_break_env_int(r, "RANGE_BREAK_VOL_BASELINE_WIN", 30),
}
def range_break_ui_to_engine_params(ui: Dict[str, Any]) -> Dict[str, Any]:
"""웹 UI(%) → 엔진 params."""
sl_ui = float(ui.get("sl_pct", 3.0))
max_loss = normalize_breakout_max_loss_krw(ui.get("max_loss_krw", 200_000))
slot_cap = float(ui.get("slot_money", 200_000))
from kis_trader.strategies.breakout import breakout_invest_amount_krw
slot = breakout_invest_amount_krw(max_loss, sl_ui, slot_cap)
def _ui_bool(key: str, default: bool = False) -> bool:
raw = ui.get(key)
if raw is None or raw == "":
return default
if isinstance(raw, bool):
return raw
return str(raw).strip().lower() in ("1", "true", "t", "y", "yes", "on")
return {
"box_lookback_min": int(ui.get("box_lookback_min", 30)),
"box_max_width_pct": float(ui.get("box_max_width_pct", 2.5)),
"box_min_width_pct": float(ui.get("box_min_width_pct", 0.3)),
"setup_vol_max_mult": float(ui.get("setup_vol_max_mult", 0.8)),
"setup_bear_bars_min": int(ui.get("setup_bear_bars_min", 1)),
"vol_mult": float(ui.get("vol_mult", 2.0)),
"vol_window": int(ui.get("vol_window", 7)),
"vol_baseline_win": int(ui.get("vol_baseline_win", 30)),
"break_margin_pct": float(ui.get("break_margin_pct", 0.0) or 0.0),
"body_min_pct": float(ui.get("body_min_pct", 0.0) or 0.0),
"time_start_hm": int(ui.get("time_start_hm", 1030)),
"time_end_hm": int(ui.get("time_end_hm", 1520)),
"cooldown_min": float(ui.get("cooldown_min", 30)),
"max_daily": int(ui.get("max_daily", 1)),
"max_daily_chg": float(ui.get("max_daily_chg", 25.0)),
"min_price": float(ui.get("min_price", 1000.0)),
"high_chase_thr": float(ui.get("high_chase_thr", 0.96)),
"use_high_chase_filter": _ui_bool("use_high_chase_filter", True),
"max_loss_krw": max_loss,
"slot_money": slot,
"stop_loss_pct": -abs(sl_ui) / 100.0,
"take_profit_pct": abs(float(ui.get("tp_pct", 10.0))) / 100.0,
"trail_pct": abs(float(ui.get("trail_pct", 1.5))) / 100.0,
"trail_arm_pct": abs(float(ui.get("trail_arm_pct", 1.5) or 0.0)) / 100.0,
"shoulder_min_high": abs(float(ui.get("shoulder_min_high_pct", 3.0))) / 100.0,
"shoulder_cut_pct": abs(float(ui.get("shoulder_cut_pct", 0.5))) / 100.0,
"max_hold_bars": int(float(ui.get("max_hold_bars", 0) or 0)),
"fee_rate": float(ui.get("fee_rate_pct", 0.015) or 0.015) / 100.0,
"sell_tax": float(ui.get("sell_tax_pct", 0.18) or 0.18) / 100.0,
"live_backtest_align": _ui_bool("live_backtest_align", True),
"live_signal_lookback_bars": int(ui.get("live_signal_lookback_bars", 1)),
"force_eod_exit": _ui_bool("force_eod_exit", False),
"portfolio_mode": True,
}
def range_break_min_bars_required(params: Dict[str, Any]) -> int:
"""백테 최소 봉 수 — 박스 lookback + 거래량 기준창."""
box_lb = int(params.get("box_lookback_min", 30))
vol_win = int(params.get("vol_window", 7))
vol_base = int(params.get("vol_baseline_win", 30))
align_extra = 1 if _to_bool(params.get("live_backtest_align", True), True) else 0
return max(box_lb, vol_win, vol_base) + 3 + align_extra
def range_break_scan_buy_at_bar(
candles: List[Dict],
bar_index: int,
params: Dict[str, Any],
state: Optional[Dict[str, Any]] = None,
day_open: Optional[float] = None,
) -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]], float, str]:
"""
백테 단일 봉 매수 스캔 — align: 신호봉 확정 후 다음 봉 시가 진입.
Returns:
reason, msg, signal, entry_price, entry_time(candle_time)
"""
i = bar_index
st = state or {}
sub = candles[: i + 1]
reason, msg, sig = check_buy_signal_range_break_live(sub, params, st)
if not sig:
return reason, msg, None, 0.0, ""
if i + 1 >= len(candles):
return ("탈락-진입봉없음", "다음 봉 없음", None, 0.0, "")
next_c = candles[i + 1]
if next_c["candle_time"][:8] != sub[-1]["candle_time"][:8]:
return ("탈락-일자넘김", "다음봉 일자 불일치", None, 0.0, "")
ep = float(next_c.get("open") or 0)
if ep <= 0:
ep = float(sig.get("entry_price") or sig.get("close") or 0)
sig["box_stop_line"] = float(sig.get("box_high", 0) or 0)
return None, None, sig, ep, str(next_c.get("candle_time") or "")
def run_range_break_backtest(
codes_candles: Dict[str, List[Dict]],
params: Dict[str, Any],
universe_by_slot: Optional[Dict[str, List[str]]] = None,
) -> List[Dict]:
from kis_trader.backtest.range_break_portfolio_backtest import (
run_range_break_backtest_portfolio,
)
return run_range_break_backtest_portfolio(
codes_candles, params, universe_by_slot=universe_by_slot,
)