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