#!/usr/bin/env python3 """ kis_trader/backtest/dbband_param_search.py — DBBAND 종목별 Grid Search ====================================================================== 실행: python3 -m kis_trader.backtest.dbband_param_search --symbol QQQM --start 2026-05-01 --end 2026-05-29 --tf 15 python3 -m kis_trader.backtest.dbband_param_search --symbol 069500 --tf 15 """ from __future__ import annotations import argparse import json import os import sys from datetime import datetime, timedelta from itertools import product from typing import Any, Dict, List, Optional 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) import holding_bot as hb from database import TradeDB from kis_trader.backtest import dbband_backtest_common as bbc from kis_trader.engine import dbband_engine as bbe from kis_trader.engine.dbband_env_keys import params_to_dbband_env_patch from kis_trader.strategies import dbband_stock_cfg as dsc def _results_dir() -> str: d = os.path.join(HERE, "results") os.makedirs(d, exist_ok=True) return d def _grid_axes(snap: Dict[str, Any]) -> Dict[str, List[Any]]: def _f(k: str, default: str) -> List[float]: raw = str(snap.get(k) or default) return [float(x.strip()) for x in raw.split(",") if x.strip()] return { "bb_inner_std": _f("DBBAND_GRID_INNER_STD0", "2,2.5") or [2.0, 2.5], "bb_outer_std": _f("DBBAND_GRID_OUTER_STD0", "2.5,3,3.5") or [2.5, 3.0, 3.5], "trend_ma_period": [int(x) for x in _f("DBBAND_GRID_TREND_MA0", "100,120,150,200") or [100, 120, 150, 200]], "stop_loss_pct": _f("DBBAND_GRID_SL0", "1.5,2,2.5") or [1.5, 2.0, 2.5], "tp_mode": ["opposite_band", "rr"], "rr_ratio": _f("DBBAND_GRID_RR0", "1.5,2") or [1.5, 2.0], } def run_search_for_code( db: Any, code: str, start_date: str, end_date: str, tf: int, *, base_cfg: Optional[Dict[str, Any]] = None, top_n: int = 30, ) -> Dict[str, Any]: dsc.ensure_dbband_backtest_tables(db) base = dict(base_cfg or bbe.get_dbband_defaults_from_db(db)) snap = db.get_merged_env_snapshot() if hasattr(db, "get_merged_env_snapshot") else {} raw_candles = hb.get_stored_min_candles(db, code, start_date, end_date, tf_min=int(tf)) candles = bbc.normalize_stored_min_candles(raw_candles) if not candles: return {"error": f"{code} {tf}분봉 없음 — holding_min_candles 수집 필요", "results": []} fee_rate = float(snap.get("FEE_RATE_PCT") or 0.015) / 100 sell_tax = float(snap.get("SELL_TAX_RATE_PCT") or 0.18) / 100 slot = float(base.get("slot_money") or snap.get("DBBAND_SLOT_MONEY") or 3_000_000) axes = _grid_axes(snap) combos: List[Dict[str, Any]] = [] for inn, out, ma, sl, tm, rr in product( axes["bb_inner_std"], axes["bb_outer_std"], axes["trend_ma_period"], axes["stop_loss_pct"], axes["tp_mode"], axes["rr_ratio"], ): p = dict(base) p["bb_inner_std"] = inn p["bb_outer_std"] = out p["trend_ma_period"] = ma p["sl_pct"] = float(sl) / 100.0 p["stop_loss_pct"] = float(sl) p["tp_mode"] = tm p["rr_ratio"] = rr combos.append(p) from kis_trader.backtest.backtest_portfolio_common import backtest_slip_pct results: List[Dict[str, Any]] = [] for params in combos: trades = bbe.run_dbband_backtest_single(candles, params) bbc.attach_dbband_trade_pnl( trades, slot_money=slot, fee_rate=fee_rate, sell_tax=sell_tax, slip_pct=backtest_slip_pct(params), ) total_pnl = sum(int(t.get("pnl") or 0) for t in trades) wins = sum(1 for t in trades if int(t.get("pnl") or 0) > 0) n = len(trades) results.append({ "params": params, "apply_cfg": params_to_dbband_env_patch(params), "total_trades": n, "win_rate": round(wins / n * 100, 1) if n else 0.0, "total_pnl": total_pnl, }) results.sort(key=lambda x: (x["total_pnl"], x["win_rate"]), reverse=True) return { "code": code, "start": start_date, "end": end_date, "timeframe": int(tf), "tested_combos": len(combos), "candle_count": len(candles), "results": results[:top_n], } def main() -> None: today = datetime.now().date() parser = argparse.ArgumentParser(description="DBBAND 종목별 파라미터 탐색") parser.add_argument("--symbol", "--code", dest="code", required=True) parser.add_argument("--start", default=(today - timedelta(days=30)).isoformat()) parser.add_argument("--end", default=today.isoformat()) parser.add_argument("--tf", type=int, default=15) parser.add_argument("--top", type=int, default=50) parser.add_argument("--save-top", action="store_true", help="1위를 dbband_stock_config에 저장") args = parser.parse_args() db = TradeDB() try: dsc.ensure_dbband_backtest_tables(db) base = dsc.load_dbband_engine_cfg(db, args.code, bbe.get_dbband_defaults_from_db(db)) out = run_search_for_code( db, args.code, args.start, args.end, args.tf, base_cfg=base, top_n=args.top, ) ts = datetime.now().strftime("%Y%m%d_%H%M%S") sym = str(args.code).upper() path = os.path.join(_results_dir(), f"search_dbband_{sym}_{ts}.json") with open(path, "w", encoding="utf-8") as f: json.dump(out, f, ensure_ascii=False, indent=2) print(f"saved {path}") if args.save_top and out.get("results"): top = out["results"][0]["params"] dsc.set_dbband_stock_config(db, sym, sym, top, tf_min=args.tf) print(f"applied top-1 → dbband_stock_config ({sym})") finally: db.close() if __name__ == "__main__": main()