#!/usr/bin/env python3
"""
momentum_rr_crossval.py — 모멘텀 손익비(어깨·손절·익절) 스윕 → 파라서치 → 교차검증

1) 고정 진입(RSI·거래량=현 운영값) + 청산 축만 여러 프리셋 스윕 (학습 구간)
2) 1위 R:R 프리셋을 base 로 param_search (mode=rr) 실행
3) 탐색 상위 N개를 검증 구간(기본 2026-06-01)에서 재평가

실행:
  cd /home/hoon/kis_bot
  python3 kis_trader/backtest/momentum_rr_crossval.py
  python3 kis_trader/backtest/momentum_rr_crossval.py --train-start 2026-05-11 --train-end 2026-05-30 \\
      --oos-start 2026-06-01 --oos-end 2026-06-01 --search-top 5 --skip-search
"""
from __future__ import annotations

import argparse
import json
import os
import sys
import time
from datetime import datetime
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)

import logging

logging.getLogger("TradeDB").setLevel(logging.WARNING)

from database import TradeDB
from kis_trader.backtest import scalping_backtest_common as sbc
from kis_trader.backtest.param_search_momentum import (
    _evaluate_momentum_chunk,
    _load_candles_for_search,
    _mom_fixed_defaults,
    _momentum_grids,
    _ui_to_engine_params,
    run_search,
)
from kis_trader.backtest.param_search_cli_common import apply_session_to_fixed
from kis_trader.engine import momentum_engine as me
from kis_trader.utils.env import get_env_float, get_env_from_db, get_env_int


def _results_dir() -> str:
    d = os.path.join(HERE, "results")
    os.makedirs(d, exist_ok=True)
    return d


def _load_universe(start: str, end: str) -> Tuple[Optional[Dict[str, List[str]]], str]:
    start_ymd = start.replace("-", "")
    end_ymd = end.replace("-", "")
    universe, source, _, _ = sbc.resolve_scalp_universe(
        start_ymd, end_ymd, use_saved_history=True, strategy_id="MOMENTUM",
    )
    if universe:
        return universe, source
    start_key = start_ymd + "0000"
    end_key = end_ymd + "2359"
    db = TradeDB()
    try:
        from kis_trader.backtest import momentum_backtest_common as mbc
        candles, _ = mbc.load_momentum_candles_by_code(db, start_key, end_key)
    finally:
        db.close()
    sim = me.build_universe_simulation_momentum(candles)
    return sim, "sim"


def _base_entry_ui(fixed: Dict[str, Any]) -> Dict[str, Any]:
    """현 운영 진입 축 고정 — 청산(R:R)만 스윕."""
    return {
        "mom_rsi_min": int(get_env_int("MOMENTUM_RSI_MIN", 55)),
        "mom_rsi_max": int(get_env_int("MOMENTUM_RSI_MAX", 90)),
        "mom_vol_mult": float(get_env_float("MOMENTUM_VOL_MULT", 1.5)),
        "mom_vol_win": float(fixed.get("mom_vol_win", 5)),
        "mom_time_end_hm": int(fixed.get("mom_time_end_hm", 1430)),
        "mom_max_from_open_pct": float(get_env_float("MOMENTUM_MAX_FROM_OPEN_PCT", 30.0)),
        "cooldown_min": float(fixed.get("cooldown_min", 1)),
        "max_daily": float(fixed.get("max_daily", 10)),
        "fee_rate": float(fixed.get("fee_rate", 0.015)),
        "sell_tax": float(fixed.get("sell_tax", 0.18)),
        "time_start_hm": int(fixed.get("time_start_hm", 830)),
        "time_end_hm": int(fixed.get("time_end_hm", 1530)),
        "min_price": int(fixed.get("min_price", 1000)),
        "max_loss_krw": float(fixed.get("max_loss_krw", 200_000)),
        "min_margin": float(fixed.get("min_margin", 0.2)),
        "use_defense_filters": bool(fixed.get("use_defense_filters", True)),
        "slot_money": float(get_env_float("MOMENTUM_SLOT_MONEY", 200_000)),
    }


def _rr_presets() -> List[Dict[str, Any]]:
    """
    청산 프리셋 (UI % 단위).
    shoulder_min_high >= 50 → 사실상 어깨 OFF (일반 장중 +50% 미도달).
    """
    raw = str(get_env_from_db("MOMENTUM_RR_PRESET_GRID_JSON", "") or "").strip()
    if raw:
        try:
            data = json.loads(raw)
            if isinstance(data, list) and data:
                return data
        except json.JSONDecodeError:
            pass
    return [
        {
            "name": "baseline_live",
            "sl_pct": 2.0, "tp_pct": 2.0, "tp_max_pct": 1.8,
            "shoulder_min_high": 0.5, "shoulder_cut_pct": 0.02,
        },
        {
            "name": "shoulder_off_tp25",
            "sl_pct": 2.0, "tp_pct": 2.5, "tp_max_pct": 2.5,
            "shoulder_min_high": 50.0, "shoulder_cut_pct": 0.3,
        },
        {
            "name": "wide_shoulder_sl15",
            "sl_pct": 1.5, "tp_pct": 2.5, "tp_max_pct": 2.5,
            "shoulder_min_high": 1.0, "shoulder_cut_pct": 0.25,
        },
        {
            "name": "delayed_shoulder_sl12",
            "sl_pct": 1.2, "tp_pct": 3.0, "tp_max_pct": 3.0,
            "shoulder_min_high": 1.2, "shoulder_cut_pct": 0.30,
        },
        {
            "name": "tp_only_sl15",
            "sl_pct": 1.5, "tp_pct": 2.0, "tp_max_pct": 2.0,
            "shoulder_min_high": 50.0, "shoulder_cut_pct": 0.2,
        },
        {
            "name": "loose_shoulder_sl18",
            "sl_pct": 1.8, "tp_pct": 2.2, "tp_max_pct": 2.2,
            "shoulder_min_high": 0.8, "shoulder_cut_pct": 0.20,
        },
    ]


def _run_backtest_period(
    candles: Dict[str, List[Dict]],
    universe: Optional[Dict[str, List[str]]],
    ui_params: Dict[str, Any],
    *,
    slot_money: float,
    max_stocks: int,
    total_budget: float,
    fee_rate: float,
    sell_tax: float,
) -> Dict[str, Any]:
    engine = _ui_to_engine_params(ui_params)
    engine["slot_money"] = float(slot_money)
    meta: Dict[str, Any] = {}
    trades = sbc.run_scalping_backtest_web_aligned(
        candles,
        engine,
        universe,
        slot_money=slot_money,
        fee_rate=fee_rate,
        sell_tax=sell_tax,
        max_stocks=max_stocks,
        total_budget_krw=total_budget,
        meta_out=meta,
        mode="momentum",
    )
    stats = sbc.summarize_trades(trades, total_budget_krw=total_budget)
    by_reason: Dict[str, Dict[str, float]] = {}
    for t in trades:
        r = str(t.get("sell_reason") or t.get("reason") or "?").split("(")[0].strip()
        pnl = float(t.get("pnl") or t.get("realized_pnl") or 0)
        bucket = by_reason.setdefault(r, {"n": 0, "pnl": 0.0})
        bucket["n"] += 1
        bucket["pnl"] += pnl
    wins = [t for t in trades if float(t.get("pnl") or t.get("realized_pnl") or 0) > 0]
    losses = [t for t in trades if float(t.get("pnl") or t.get("realized_pnl") or 0) <= 0]
    avg_win = (
        sum(float(t.get("pnl") or t.get("realized_pnl") or 0) for t in wins) / len(wins)
        if wins else 0.0
    )
    avg_loss = (
        sum(float(t.get("pnl") or t.get("realized_pnl") or 0) for t in losses) / len(losses)
        if losses else 0.0
    )
    return {
        "trades": len(trades),
        "total_pnl": round(stats.get("total_pnl", 0)),
        "win_rate": round(stats.get("win_rate", 0), 2),
        "pf": round(float(stats.get("pf") or 0), 3),
        "bot_pct": round(stats.get("bot_pct", 0), 3),
        "avg_win": round(avg_win),
        "avg_loss": round(avg_loss),
        "by_reason": by_reason,
        "params_ui": ui_params,
    }


def phase_rr_sweep(
    train_start: str,
    train_end: str,
    *,
    slot_money: float,
    max_stocks: int,
    total_budget: float,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
    fixed = _mom_fixed_defaults()
    apply_session_to_fixed(fixed, time_start_hm=830, time_end_hm=1530)
    entry_base = _base_entry_ui(fixed)

    print("\n" + "=" * 70)
    print(f"[Phase 1] R:R 프리셋 스윕 | 학습 {train_start} ~ {train_end}")
    print("=" * 70)

    candles = _load_candles_for_search(train_start, train_end, int(fixed["rsi_period"]))
    universe, src = _load_universe(train_start, train_end)
    print(f"  캔들 종목 {len(candles)} | 유니버스={src} 슬롯={len(universe or {})}")

    db = TradeDB()
    try:
        row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
        env_row = dict(row) if row else {}
    finally:
        db.close()
    fee_rate, sell_tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")

    results: List[Dict[str, Any]] = []
    for preset in _rr_presets():
        ui = dict(entry_base)
        ui.update({k: v for k, v in preset.items() if k != "name"})
        ui["preset_name"] = preset.get("name", "unnamed")
        row = _run_backtest_period(
            candles, universe, ui,
            slot_money=slot_money, max_stocks=max_stocks, total_budget=total_budget,
            fee_rate=fee_rate, sell_tax=sell_tax,
        )
        row["preset_name"] = ui["preset_name"]
        results.append(row)
        print(
            f"  {ui['preset_name']:<22} "
            f"trades={row['trades']:3d} wr={row['win_rate']:5.1f}% "
            f"pnl={row['total_pnl']:>10,} pf={row['pf']:.2f} "
            f"avgW={row['avg_win']:>6,} avgL={row['avg_loss']:>7,}"
        )

    results.sort(key=lambda x: (x["total_pnl"], x["pf"]), reverse=True)
    best = results[0] if results else {}
    print(f"\n  ▶ 학습 1위 프리셋: {best.get('preset_name')} pnl={best.get('total_pnl'):,}")
    return results, best


def phase_crossval_presets(
    presets_results: List[Dict[str, Any]],
    oos_start: str,
    oos_end: str,
    top_k: int,
    *,
    slot_money: float,
    max_stocks: int,
    total_budget: float,
) -> List[Dict[str, Any]]:
    print("\n" + "=" * 70)
    print(f"[Phase 1b] R:R 프리셋 교차검증 (OOS) {oos_start} ~ {oos_end}")
    print("=" * 70)

    fixed = _mom_fixed_defaults()
    entry_base = _base_entry_ui(fixed)
    candles = _load_candles_for_search(oos_start, oos_end, int(fixed["rsi_period"]))
    universe, src = _load_universe(oos_start, oos_end)
    print(f"  캔들 종목 {len(candles)} | 유니버스={src}")

    db = TradeDB()
    try:
        row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
        env_row = dict(row) if row else {}
    finally:
        db.close()
    fee_rate, sell_tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")

    oos_rows: List[Dict[str, Any]] = []
    for train_row in presets_results[:top_k]:
        ui = dict(entry_base)
        p = train_row.get("params_ui") or {}
        for k in ("sl_pct", "tp_pct", "tp_max_pct", "shoulder_min_high", "shoulder_cut_pct"):
            if k in p:
                ui[k] = p[k]
        ui["preset_name"] = train_row.get("preset_name", "?")
        row = _run_backtest_period(
            candles, universe, ui,
            slot_money=slot_money, max_stocks=max_stocks, total_budget=total_budget,
            fee_rate=fee_rate, sell_tax=sell_tax,
        )
        row["preset_name"] = ui["preset_name"]
        row["train_pnl"] = train_row.get("total_pnl")
        oos_rows.append(row)
        print(
            f"  {ui['preset_name']:<22} train={train_row.get('total_pnl'):>9,} "
            f"oos={row['total_pnl']:>9,} wr={row['win_rate']:.1f}% pf={row['pf']:.2f}"
        )
    return oos_rows


def _apply_rr_base_to_fixed(fixed: Dict[str, Any], best_ui: Dict[str, Any]) -> None:
    for k in ("sl_pct", "tp_pct", "tp_max_pct", "shoulder_min_high", "shoulder_cut_pct"):
        if k in best_ui:
            fixed[k] = best_ui[k]


def phase_param_search(
    train_start: str,
    train_end: str,
    mode: str,
    best_rr_ui: Dict[str, Any],
    *,
    slot_money: float,
    max_stocks: int,
    total_budget: float,
    top_n: int,
) -> Optional[str]:
    print("\n" + "=" * 70)
    print(f"[Phase 2] 파라서치 mode={mode} (R:R 1위 청산축 고정 폴백 반영)")
    print("=" * 70)
    os.environ["MOMENTUM_RR_SEARCH_BASE_JSON"] = json.dumps({
        k: best_rr_ui[k]
        for k in ("sl_pct", "tp_pct", "tp_max_pct", "shoulder_min_high", "shoulder_cut_pct")
        if k in best_rr_ui
    })
    run_search(
        train_start, train_end, mode, top_n,
        min_trades=5,
        min_win_rate=0.0,
        apply_rank=None,
        use_fallback_universe=False,
        slot_money=slot_money,
        max_stocks=max_stocks,
        total_budget_krw=total_budget,
        time_start_hm=830,
        time_end_hm=1530,
    )
    from kis_trader.backtest.param_search_momentum import _latest_json
    return _latest_json("search_momentum_")


def phase_crossval_search_json(
    json_path: Optional[str],
    oos_start: str,
    oos_end: str,
    top_k: int,
    *,
    slot_money: float,
    max_stocks: int,
    total_budget: float,
) -> List[Dict[str, Any]]:
    from kis_trader.backtest.param_search_momentum import _latest_json

    path = json_path or _latest_json("search_momentum_")
    if not path or not os.path.isfile(path):
        print("  ⚠️ search_momentum JSON 없음 — Phase 3 스킵")
        return []

    print("\n" + "=" * 70)
    print(f"[Phase 3] 파라서치 상위 {top_k} → OOS {oos_start}~{oos_end}")
    print(f"  JSON: {path}")
    print("=" * 70)

    with open(path, "r", encoding="utf-8") as f:
        doc = json.load(f)
    ranked = doc.get("top") or doc.get("ranked") or doc.get("results") or []
    if not ranked:
        print("  top/ranked 비어 있음")
        return []

    fixed = _mom_fixed_defaults()
    apply_session_to_fixed(fixed, time_start_hm=830, time_end_hm=1530)
    candles = _load_candles_for_search(oos_start, oos_end, int(fixed["rsi_period"]))
    universe, _ = _load_universe(oos_start, oos_end)

    db = TradeDB()
    try:
        row = db.conn.execute("SELECT * FROM env_config ORDER BY id DESC LIMIT 1").fetchone()
        env_row = dict(row) if row else {}
    finally:
        db.close()
    fee_rate, sell_tax, _ = sbc.fee_and_slot_from_env(env_row, strategy="MOMENTUM")

    oos_rows: List[Dict[str, Any]] = []
    for i, item in enumerate(ranked[:top_k], 1):
        merged = dict(item.get("merged_params") or item.get("params") or {})
        ui = {**fixed, **merged}
        row = _run_backtest_period(
            candles, universe, ui,
            slot_money=slot_money, max_stocks=max_stocks, total_budget=total_budget,
            fee_rate=fee_rate, sell_tax=sell_tax,
        )
        row["rank"] = i
        row["train_pnl"] = item.get("total_pnl")
        row["train_pf"] = item.get("pf")
        oos_rows.append(row)
        print(
            f"  #{i} train_pnl={item.get('total_pnl'):>9,} "
            f"oos={row['total_pnl']:>9,} trades={row['trades']} wr={row['win_rate']:.1f}% "
            f"sl={merged.get('sl_pct')} tp={merged.get('tp_pct')} "
            f"sh={merged.get('shoulder_min_high')}/{merged.get('shoulder_cut_pct')}"
        )
    return oos_rows


def main() -> None:
    ap = argparse.ArgumentParser(description="모멘텀 R:R 스윕 + 파라서치 + 교차검증")
    ap.add_argument("--train-start", default="2026-05-11")
    ap.add_argument("--train-end", default="2026-05-30")
    ap.add_argument("--oos-start", default="2026-06-01")
    ap.add_argument("--oos-end", default="2026-06-01")
    ap.add_argument("--slot-money", type=float, default=None)
    ap.add_argument("--max-stocks", type=int, default=20)
    ap.add_argument("--total-budget", type=float, default=2_000_000)
    ap.add_argument("--search-mode", default="rr", choices=["rr", "fast", "coarse"])
    ap.add_argument("--search-top", type=int, default=30)
    ap.add_argument("--cross-top", type=int, default=5)
    ap.add_argument("--skip-search", action="store_true")
    ap.add_argument("--skip-sweep", action="store_true")
    args = ap.parse_args()

    slot = float(args.slot_money or get_env_float("MOMENTUM_SLOT_MONEY", 200_000))
    t0 = time.time()
    report: Dict[str, Any] = {
        "generated_at": datetime.now().isoformat(timespec="seconds"),
        "train": [args.train_start, args.train_end],
        "oos": [args.oos_start, args.oos_end],
        "portfolio": {
            "slot_money": slot,
            "max_stocks": args.max_stocks,
            "total_budget_krw": args.total_budget,
        },
    }

    sweep_results: List[Dict[str, Any]] = []
    best_rr: Dict[str, Any] = {}
    if not args.skip_sweep:
        sweep_results, best_rr = phase_rr_sweep(
            args.train_start, args.train_end,
            slot_money=slot, max_stocks=args.max_stocks, total_budget=args.total_budget,
        )
        report["rr_sweep_train"] = sweep_results
        report["rr_sweep_oos"] = phase_crossval_presets(
            sweep_results, args.oos_start, args.oos_end, args.cross_top,
            slot_money=slot, max_stocks=args.max_stocks, total_budget=args.total_budget,
        )

    best_ui = (best_rr.get("params_ui") or {}) if best_rr else {}
    json_path: Optional[str] = None
    if not args.skip_search and best_ui:
        json_path = phase_param_search(
            args.train_start, args.train_end, args.search_mode, best_ui,
            slot_money=slot, max_stocks=args.max_stocks, total_budget=args.total_budget,
            top_n=args.search_top,
        )
        report["search_json"] = json_path

    report["search_oos"] = phase_crossval_search_json(
        json_path, args.oos_start, args.oos_end, args.cross_top,
        slot_money=slot, max_stocks=args.max_stocks, total_budget=args.total_budget,
    )

    out_path = os.path.join(
        _results_dir(),
        f"momentum_rr_crossval_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
    )
    with open(out_path, "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(f"\n✅ 리포트 저장: {out_path}")
    print(f"⏱ 총 {time.time() - t0:.0f}초")


if __name__ == "__main__":
    main()
