변경 사항 ---- - _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>
460 lines
19 KiB
Python
460 lines
19 KiB
Python
#!/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,
|
||
)
|