#!/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()
