#!/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 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, ) from kis_trader.utils.trade_time import parse_trade_datetime as _t2dt 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 _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, *, env_row: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """env_config + config_range_break 병합.""" own_db = None r: Dict[str, Any] = {} try: if env_row is not None: r = dict(env_row) else: 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, ticks_by_code: Optional[Dict[str, Dict[str, List[Dict]]]] = 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, ticks_by_code=ticks_by_code, )