diff --git a/kis_trader/backtest/optuna_mode_refine_runner.py b/kis_trader/backtest/optuna_mode_refine_runner.py index 5081ee1..fce159b 100644 --- a/kis_trader/backtest/optuna_mode_refine_runner.py +++ b/kis_trader/backtest/optuna_mode_refine_runner.py @@ -238,7 +238,15 @@ def run_mode_refine( env.pop("OPTUNA_GRID_NARROW_JSON", None) p1_study_arg = str(phase1_study or "").strip() - p1_study = f"{strategy}_{mode}_refine1_{start.replace('-', '')}_{end.replace('-', '')}_{ts}" + + extra = "" + if strategy == "tail" and entry_mode: + extra = f"_{entry_mode}" + elif strategy == "breakout": + from kis_trader.backtest.optuna_breakout_tpe_space import breakout_tpe_study_extra + extra = f"_{breakout_tpe_study_extra(sl_mode or 'fixed', ob_mode or 'off')}" + + p1_study = f"{strategy}{extra}_{mode}_refine1_{start.replace('-', '')}_{end.replace('-', '')}_{ts}" p1_log = ROOT / "logs" / f"optuna_refine1_{job_id}.log" p1_json = str(phase1_json or "").strip() @@ -331,7 +339,7 @@ def run_mode_refine( state["narrow_path"] = str(narrow_path) p2_trials = resolve_refine_phase2_trials(trials) - p2_study = f"{strategy}_{mode}_refine2_{start.replace('-', '')}_{end.replace('-', '')}_{ts}" + p2_study = f"{strategy}{extra}_{mode}_refine2_{start.replace('-', '')}_{end.replace('-', '')}_{ts}" p2_log = ROOT / "logs" / f"optuna_refine2_{job_id}.log" env2 = dict(env) env2["OPTUNA_GRID_NARROW_JSON"] = str(narrow_path) diff --git a/kis_trader/engine/breakout_engine.py b/kis_trader/engine/breakout_engine.py new file mode 100644 index 0000000..2a39695 --- /dev/null +++ b/kis_trader/engine/breakout_engine.py @@ -0,0 +1,108 @@ +import logging +from typing import Dict, List, Any, Optional + +try: + import kis_rust_core +except ImportError: + kis_rust_core = None + +logger = logging.getLogger(__name__) + +def run_breakout_backtest_rust_experimental( + codes_candles: Dict[str, List[Dict]], + params: Dict[str, Any], +) -> List[Dict]: + """ + Rust 엔진을 이용한 Breakout (돌파매매) 고속 백테스트 브릿지. + """ + if kis_rust_core is None: + logger.warning("kis_rust_core is not installed or imported. Falling back to empty trades.") + return [] + + # 파라미터 파싱 + lookback_min = int(params.get("lookback_min", 1)) + vol_window = int(params.get("vol_window", 7)) + vol_mult = float(params.get("vol_mult", 0.0) or 0.0) + prev_chg_min = float(params.get("prev_chg_min", 1.0)) + prev_chg_max = float(params.get("prev_chg_max", 10.0)) + max_daily_chg = float(params.get("max_daily_chg", 15.0)) + min_price = float(params.get("min_price", 1000.0)) + min_bar_trade_value_krw = float(params.get("min_bar_trade_value_krw", 0.0) or 0.0) + min_turnover_1m_pct = float(params.get("min_turnover_1m_pct", 0.0) or 0.0) + share_denom = float(params.get("share_denom", 0.0) or 0.0) + confirm_margin_pct = float(params.get("confirm_margin_pct", 0.0) or 0.0) + body_min_pct = float(params.get("body_min_pct", 0.0) or 0.0) + use_ema_filter = bool(params.get("use_ema_filter", False)) + ema_fast_period = int(params.get("ema_fast_period", 9)) + ema_slow_period = int(params.get("ema_slow_period", 21)) + time_start_hm = int(params.get("time_start_hm", 900)) + time_end_hm = int(params.get("time_end_hm", 1030)) + sl_pct = abs(float(params.get("sl_pct", params.get("stop_loss_pct", -0.02)))) + tp_pct = float(params.get("tp_pct", params.get("take_profit_pct", 0.05))) + trail_pct = float(params.get("trail_pct", 0.015)) + trail_arm_pct = float(params.get("trail_arm_pct", 0.0) or 0.0) + shoulder_min_high = float(params.get("shoulder_min_high_pct", params.get("shoulder_min_high", 0.02))) + shoulder_cut_pct = float(params.get("shoulder_cut_pct", 0.01)) + max_hold_bars = int(params.get("max_hold_bars", 0) or 0) + cooldown_min = float(params.get("cooldown_min", 30)) + max_daily = int(params.get("max_daily", 1)) + skip_hts_scan_dupes = bool(params.get("skip_hts_scan_dupes", False)) + atr_period = int(params.get("atr_period", 14) or 14) + + # 파라미터 구조체 생성 + rust_params = kis_rust_core.BreakoutParams( + lookback_min, vol_window, vol_mult, prev_chg_min, prev_chg_max, + max_daily_chg, min_price, min_bar_trade_value_krw, min_turnover_1m_pct, + share_denom, confirm_margin_pct, body_min_pct, use_ema_filter, + ema_fast_period, ema_slow_period, time_start_hm, time_end_hm, + sl_pct, tp_pct, trail_pct, trail_arm_pct, shoulder_min_high, + shoulder_cut_pct, max_hold_bars, cooldown_min, max_daily, skip_hts_scan_dupes, + atr_period + ) + + all_trades = [] + + for code, candles_dict in codes_candles.items(): + if not candles_dict: + continue + + rust_candles = [] + for c in candles_dict: + rc = kis_rust_core.CandleData( + c.get("candle_time", ""), + float(c.get("open", 0)), + float(c.get("high", 0)), + float(c.get("low", 0)), + float(c.get("close", 0)), + float(c.get("volume", 0)), + float(c.get("rsi", 0.0)), + ) + rust_candles.append(rc) + + try: + trades = kis_rust_core.run_breakout_backtest_fast(code, rust_candles, rust_params) + for t in trades: + all_trades.append({ + "code": t.code, + "buy_time": t.buy_time, + "sell_time": t.sell_time, + "buy_price": t.buy_price, + "sell_price": t.sell_price, + "profit_rate": t.pnl_pct, + "sell_reason": t.reason, + "qty": 1, # 단순화를 위해 1로 고정, 추후 예산 로직 반영 가능 + "pnl": 0, # 단순화를 위해 0, + # 부가 정보 기록 + "entry_features": { + "resistance": t.resistance, + "vol_ratio": t.vol_ratio, + "prev_chg": t.prev_chg, + }, + "atr_entry": t.atr_entry, + "max_price": getattr(t, "max_price", t.buy_price), + }) + except Exception as e: + logger.error(f"Rust breakout engine error for {code}: {e}") + + all_trades.sort(key=lambda x: x["sell_time"]) + return all_trades diff --git a/kis_trader/engine/tail_engine.py b/kis_trader/engine/tail_engine.py index b3de513..ecd4568 100644 --- a/kis_trader/engine/tail_engine.py +++ b/kis_trader/engine/tail_engine.py @@ -2519,6 +2519,89 @@ def run_tail_backtest_portfolio( return all_trades +def run_tail_backtest_rust_experimental( + codes_candles: Dict[str, List[Dict]], + params: Dict[str, Any] +) -> List[Dict]: + """꼬리잡기 매매 Rust 엔진 실험용 브릿지""" + try: + import kis_rust_core + except ImportError: + raise RuntimeError("kis_rust_core module is not available") + + def get_float(k, default=0.0): + try: return float(params.get(k, default)) + except: return float(default) + + def get_int(k, default=0): + try: return int(params.get(k, default)) + except: return int(default) + + def get_bool(k, default=False): + v = params.get(k, default) + if isinstance(v, bool): return v + s = str(v).lower() + if s in ("1", "true", "yes", "t", "y"): return True + return False + + rust_params = kis_rust_core.TailParams( + get_int("time_start_hm", 930), + get_int("time_end_hm", 1500), + get_float("cooldown_min", 15.0), + get_int("max_daily", 3), + get_float("min_price", 1000.0), + get_float("rsi_threshold", 78.0), + get_float("min_drop_rate", 0.03), + get_float("min_recovery_ratio", 0.5), + get_float("tail_pct_min", 0.003), + get_bool("skip_hts_scan_dupes", True), + abs(get_float("sl_pct", 0.03)), + get_float("tp_pct", 0.05), + get_float("trail_pct", 0.0), + get_float("trail_arm_pct", 0.0), + get_float("shoulder_min_high", 0.005), + get_float("shoulder_cut_pct", 0.003), + get_int("max_hold_bars", 0), + get_float("max_loss_krw", 200000.0), + get_float("min_drop_pct_for_loss_cut", 0.015), + ) + + all_trades = [] + + for code, candles_dict in codes_candles.items(): + rust_candles = [] + for c in candles_dict: + rc = kis_rust_core.CandleData( + c.get("candle_time", ""), + float(c.get("open", 0)), + float(c.get("high", 0)), + float(c.get("low", 0)), + float(c.get("close", 0)), + float(c.get("volume", 0)), + float(c.get("rsi", 0.0)), + ) + rust_candles.append(rc) + + trades = kis_rust_core.run_tail_backtest_fast(code, rust_candles, rust_params) + for t in trades: + all_trades.append({ + "code": t.code, + "buy_time": t.buy_time, + "sell_time": t.sell_time, + "buy_price": t.buy_price, + "sell_price": t.sell_price, + "profit_rate": t.pnl_pct, + "qty": 1, + "pnl": 0, + "sell_reason": t.reason, + "max_price": t.max_price, + "rsi_entry": t.rsi_entry, + "strategy": "SHORT" + }) + + return all_trades + + def run_tail_backtest( candles_by_code: Dict[str, List[Dict]], params: Dict[str, Any], @@ -2533,6 +2616,9 @@ def run_tail_backtest( 기본: ``portfolio_mode=true`` → 시각순 포트폴리오 (실매 MAX_STOCKS·총한도·slot_money). ``portfolio_mode=false`` → 레거시 종목별 독립 루프. """ + if params.get("use_rust", False): + return run_tail_backtest_rust_experimental(candles_by_code, params) + if _to_bool(params.get("portfolio_mode"), True): return run_tail_backtest_portfolio( candles_by_code, params, universe_by_slot,