#!/usr/bin/env python3 """ kis_trader/backtest/param_search_range_break.py — 박스권 돌파 파라미터 Grid Search ==================================================================================== 실행: cd /home/hoon/kis_bot python3 -m kis_trader.backtest.param_search_range_break --start 2026-06-01 --end 2026-06-16 --mode fast """ from __future__ import annotations import argparse import heapq import json import logging import os import sys import time from datetime import datetime, timedelta from itertools import product from typing import Any, Dict, List, Optional, Tuple HERE = os.path.dirname(os.path.abspath(__file__)) ROOT = os.path.dirname(os.path.dirname(HERE)) if ROOT not in sys.path: sys.path.insert(0, ROOT) logging.getLogger("TradeDB").setLevel(logging.WARNING) from database import TradeDB from kis_trader.backtest import range_break_backtest_common as rbc from kis_trader.backtest.backtest_portfolio_common import ( portfolio_env_patch, session_env_patch, ) from kis_trader.backtest.param_search_cli_common import ( add_portfolio_cli_args, add_search_filter_cli_args, apply_session_to_fixed, combo_passes_search_filters, format_session_hm, search_json_meta, ) from kis_trader.backtest.param_search_pool import cap_combos_uniform from kis_trader.engine.range_break_engine import range_break_ui_to_engine_params from kis_trader.strategies.breakout import ( breakout_invest_amount_krw, normalize_breakout_max_loss_krw, ) from kis_trader.utils.env import get_env_from_db def _latest_json(prefix: str) -> Optional[str]: out_dir = os.path.join(ROOT, "kis_trader", "backtest", "results") if not os.path.isdir(out_dir): return None files = [f for f in os.listdir(out_dir) if f.startswith(prefix) and f.endswith(".json")] if not files: return None files.sort(key=lambda x: os.path.getmtime(os.path.join(out_dir, x)), reverse=True) return os.path.join(out_dir, files[0]) def _grid(mode: str) -> Dict[str, List[Any]]: if mode == "coarse": return { "box_lookback_min": [20, 30, 45], "box_max_width_pct": [2.0, 2.5, 3.5], "setup_vol_max_mult": [0.6, 0.8, 1.0], "vol_mult": [1.5, 2.0, 3.0], "vol_window": [5, 7, 10], "sl_pct": [2.0, 3.0], "tp_pct": [8.0, 10.0, 12.0], "trail_pct": [1.0, 1.5], "time_end_hm": [1500, 1520], } if mode == "fine": return { "box_lookback_min": [25, 30, 35], "box_max_width_pct": [2.0, 2.5, 3.0], "setup_vol_max_mult": [0.7, 0.8, 0.9], "vol_mult": [1.8, 2.0, 2.5], "vol_window": [7], "sl_pct": [2.5, 3.0], "tp_pct": [9.0, 10.0], "trail_pct": [1.2, 1.5], "time_end_hm": [1520], } # fast (default) return { "box_lookback_min": [20, 30, 40], "box_max_width_pct": [2.0, 2.5, 3.0], "setup_vol_max_mult": [0.7, 0.8], "vol_mult": [1.5, 2.0, 2.5], "vol_window": [7], "sl_pct": [2.5, 3.0], "tp_pct": [8.0, 10.0], "trail_pct": [1.5], "time_end_hm": [1520], } def _score(stats: Dict[str, Any], min_trades: int) -> float: total = int(stats.get("total_trades") or 0) if total < min_trades: return -1e18 pnl = float(stats.get("total_pnl") or 0) wr = float(stats.get("win_rate") or 0) # 손익 우선, 승률 보조 return pnl + wr * 100.0 def _run_one( candles_by_code: Dict[str, List[Dict]], universe_by_slot: Optional[Dict[str, List[str]]], ui: Dict[str, Any], fee_rate: float, sell_tax: float, portfolio: Dict[str, Any], ) -> Tuple[float, Dict[str, Any]]: engine = range_break_ui_to_engine_params(ui) engine.update(portfolio) trades = rbc.run_range_break_backtest_web_aligned( candles_by_code, engine, universe_by_slot=universe_by_slot, slot_money=float(portfolio["slot_money"]), fee_rate=fee_rate, sell_tax=sell_tax, max_stocks=int(portfolio["max_stocks"]), total_budget_krw=float(portfolio["total_budget_krw"]), ) stats = rbc.summarize_range_break_trades( trades, total_budget_krw=float(portfolio["total_budget_krw"]), period_days=max(1, int(ui.get("_period_days") or 1)), ) sc = _score(stats, int(ui.get("_min_trades") or 1)) return sc, { "params": dict(ui), "stats": stats, "merged_params": engine, } def main() -> int: parser = argparse.ArgumentParser(description="박스권 돌파 백테스트 파라미터 Grid Search") parser.add_argument("--start", default=(datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")) parser.add_argument("--end", default=datetime.now().strftime("%Y-%m-%d")) parser.add_argument("--mode", default="fast", choices=["fast", "coarse", "fine"]) parser.add_argument( "--max-combos", type=int, default=None, dest="max_combos", help="백테 조합 상한 (fast 기본 env RANGE_BREAK_FAST_MAX_COMBOS 또는 PARAM_SEARCH_FAST_MAX_COMBOS=200, 0=무제한)", ) parser.add_argument("--top", type=int, default=50) parser.add_argument("--min-trades", type=int, default=1) add_search_filter_cli_args(parser) parser.add_argument("--fallback-universe", action="store_true") add_portfolio_cli_args(parser) args = parser.parse_args() start_key, end_key, start_ymd, end_ymd = rbc.date_keys(args.start, args.end) period_days = max(1, (datetime.strptime(args.end, "%Y-%m-%d") - datetime.strptime(args.start, "%Y-%m-%d")).days + 1) db = TradeDB() try: env_row = dict((db.get_latest_env() or {}).get("snapshot") or {}) fee_rate, sell_tax, slot_from_env = rbc.fee_and_slot_from_env(env_row) portfolio = rbc.resolve_range_break_portfolio_params( env_row, None, slot_money=args.slot_money or slot_from_env, max_stocks=args.max_stocks, total_budget_krw=args.total_budget, ) candles_by_code, _ = rbc.load_range_break_candles_by_code(db, start_key, end_key, {}) if not candles_by_code: print("❌ 캔들 데이터 없음") return 1 use_hist = not args.fallback_universe universe_by_slot, univ_src, _, _ = rbc.resolve_range_break_universe( start_ymd, end_ymd, use_saved_history=use_hist, ) print(f"📊 종목 {len(candles_by_code)} · 유니버스={univ_src}") fixed: Dict[str, Any] = { "time_start_hm": int(get_env_from_db("RANGE_BREAK_TIME_START", 1030) or 1030), "setup_bear_bars_min": int(get_env_from_db("RANGE_BREAK_SETUP_BEAR_BARS_MIN", 1) or 1), "box_min_width_pct": float(get_env_from_db("RANGE_BREAK_BOX_MIN_WIDTH_PCT", 0.3) or 0.3), "max_daily": int(get_env_from_db("RANGE_BREAK_MAX_DAILY", 1) or 1), "cooldown_min": float(get_env_from_db("RANGE_BREAK_COOLDOWN_SEC", 1800) or 1800) / 60.0, "max_daily_chg": float(get_env_from_db("RANGE_BREAK_MAX_DAILY_CHG", 25) or 25), "min_price": float(get_env_from_db("RANGE_BREAK_MIN_PRICE", 1000) or 1000), "max_loss_krw": normalize_breakout_max_loss_krw( get_env_from_db("RANGE_BREAK_MAX_LOSS_PER_TRADE_KRW", 200_000), ), "slot_money": int(portfolio["slot_money"]), "use_high_chase_filter": True, "high_chase_thr": float(get_env_from_db("RANGE_BREAK_HIGH_CHASE_THR", 0.96) or 0.96), "_period_days": period_days, "_min_trades": args.min_trades, } apply_session_to_fixed( fixed, time_start_hm=args.time_start, time_end_hm=args.time_end, ) sl_ui = float(fixed.get("sl_pct") or 3.0) fixed["slot_money"] = int( breakout_invest_amount_krw(fixed["max_loss_krw"], sl_ui, float(portfolio["slot_money"])) ) grid = _grid(args.mode) keys = list(grid.keys()) combos = list(product(*(grid[k] for k in keys))) dict_combos = [dict(zip(keys, vals)) for vals in combos] dict_combos, total_grid, max_combos_cap, dropped_by_cap = cap_combos_uniform( dict_combos, args.mode, strategy_env_prefix="RANGE_BREAK", default_fast=200, max_combos_override=args.max_combos, ) print( f"🔍 {args.mode} 그리드 {total_grid:,} → 백테 {len(dict_combos):,} · " f"{format_session_hm(fixed)}" ) if args.mode == "fast": print(f"📌 [fast] {total_grid:,}→{max_combos_cap}균등샘플") if dropped_by_cap: print(f" (max-combos={max_combos_cap} 균등 샘플, 제외 {dropped_by_cap:,}개)") heap: List[Tuple[float, int, Dict[str, Any]]] = [] t0 = time.time() for i, combo in enumerate(dict_combos, 1): ui = dict(fixed) ui.update(combo) sc, item = _run_one( candles_by_code, universe_by_slot, ui, fee_rate, sell_tax, portfolio, ) wr = float(item["stats"].get("win_rate") or 0) pf = float(item["stats"].get("pf") or 0) if sc > -1e17 and combo_passes_search_filters( win_rate=wr, pf=pf, min_win_rate=args.min_win_rate, min_pf=args.min_pf, ): heapq.heappush(heap, (sc, i, item)) if len(heap) > args.top: heapq.heappop(heap) if i % 50 == 0 or i == len(dict_combos): print(f" … {i}/{len(dict_combos)} ({time.time()-t0:.1f}s)") top = [heapq.heappop(heap) for _ in range(len(heap))] top.reverse() out_dir = os.path.join(ROOT, "kis_trader", "backtest", "results") os.makedirs(out_dir, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") out_path = os.path.join(out_dir, f"search_range_break_{ts}.json") payload = { **search_json_meta(portfolio, fixed), "strategy": "RANGE_BREAK", "start": args.start, "end": args.end, "mode": args.mode, "grid_combos": total_grid, "max_combos_cap": max_combos_cap, "universe": univ_src, "slot_money": portfolio["slot_money"], "max_stocks": portfolio["max_stocks"], "total_budget_krw": portfolio["total_budget_krw"], "top": [t[2] for t in top], } with open(out_path, "w", encoding="utf-8") as f: json.dump(payload, f, ensure_ascii=False, indent=2) print(f"✅ 저장: {out_path}") for rank, (sc, _, item) in enumerate(top[:10], 1): st = item["stats"] p = item["params"] print( f"#{rank} PnL={int(st.get('total_pnl',0)):,} " f"WR={st.get('win_rate',0):.1f}% " f"N={st.get('total_trades',0)} " f"box={p.get('box_lookback_min')} " f"w={p.get('box_max_width_pct')} " f"volX={p.get('vol_mult')}" ) return 0 finally: db.close() if __name__ == "__main__": raise SystemExit(main())