#!/usr/bin/env python3
"""
kis_holding_ver1.py — 홀딩 전략 V1 (RSI 3단계 분할매수 · 횡보장 특화)
=======================================================================
holding_bot.py(추세추종)의 대응 버전. 추세 판단 없이 RSI만으로 진입,
가격이 더 빠질수록 비중을 늘려가는 전통적 '물타기(분할매수)' 전략.

전략 개요
---------
진입 (분할매수):
  · RSI ≤ rsi_buy1            → 1단계 매수 (slot_money × buy1_ratio)
  · 보유 중 RSI ≤ rsi_buy2   → 2단계 추가매수 (× buy2_ratio)
  · 보유 중 RSI ≤ rsi_buy3   → 3단계 추가매수 (× buy3_ratio)
  · 낙폭 필터(ath/year/w52)로 "충분히 싼 구간"에만 진입 가능

청산:
  · 평단가 대비 +take_profit_pct% → 익절
  · RSI ≥ rsi_sell              → 과열 청산
  · 평단가 대비 −stop_loss_pct%  → 손절

holding_bot.py와 동일한 DB 테이블(holding_candles, holding_stock_config) 사용.

실행 예시:
  python3 kis_holding_ver1.py --code 005930 \\
      --start 2024-01-01 --end 2026-03-06
"""

import sys, os, json, argparse
from datetime import date as _date, timedelta as _td, datetime
from typing import List, Dict, Optional

ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT)

import logging
logger = logging.getLogger(__name__)

# holding_bot.py와 DB/캔들 공통 함수 공유
from holding_bot import (
    ensure_holding_tables, get_stored_candles,
    _rsi_series,
)
from database import TradeDB


# ─────────────────────────────────────────────────────────────────────────────
# 기본 파라미터 (V1 전용 — MA/추세 관련 파라미터 없음)
# ─────────────────────────────────────────────────────────────────────────────
DEFAULT_V1_CONFIG: Dict = {
    "rsi_period":        14.0,

    # ── 진입 RSI 임계값 (rsi_buy1 > rsi_buy2 > rsi_buy3 이어야 함) ──────────
    "rsi_buy1":          50.0,   # 1단계: RSI ≤ 이 값 → 처음 매수
    "rsi_buy2":          40.0,   # 2단계: RSI ≤ 이 값 → 추가매수
    "rsi_buy3":          30.0,   # 3단계: RSI ≤ 이 값 → 최종 추가매수

    # ── 청산 기준 ────────────────────────────────────────────────────────────
    "rsi_sell":          75.0,   # RSI 과열 청산
    "take_profit_pct":   15.0,   # 평단가 대비 익절 %
    "stop_loss_pct":     10.0,   # 평단가 대비 손절 %

    # ── 투자금 / 분할 비율 ────────────────────────────────────────────────────
    "slot_money":     3_000_000.0,
    "buy1_ratio":        0.4,    # 1단계 투자금 비율 (40%)
    "buy2_ratio":        0.35,   # 2단계 투자금 비율 (35%)
    "buy3_ratio":        0.25,   # 3단계 투자금 비율 (25%)

    # ── 비용 ────────────────────────────────────────────────────────────────
    "fee_rate":          0.0015, # 수수료율 (편도)
    "sell_tax":          0.0018, # 증권거래세

    # ── 낙폭 필터 (0=비활성) ─────────────────────────────────────────────────
    "ath_drop_min_pct":  0.0,    # 역대 최고가 대비 최소 낙폭 %
    "year_drop_min_pct": 0.0,    # 당해연도 고점 대비 최소 낙폭 %
    "w52_drop_min_pct":  0.0,    # 52주 고점 대비 최소 낙폭 %
}


# ─────────────────────────────────────────────────────────────────────────────
# 백테스트 엔진
# ─────────────────────────────────────────────────────────────────────────────
def run_backtest_v1(candles: List[Dict], cfg: Dict) -> Dict:
    """
    V1 RSI 분할매수 전략 백테스트.

    실행가: 신호봉 다음 봉 시가 (1봉 지연, 실매매와 동일)
    수수료: 편도 fee_rate × 2 + 매도 시 sell_tax
    """
    rsi_period    = int(cfg.get("rsi_period",       DEFAULT_V1_CONFIG["rsi_period"]))
    rsi_buy1      = float(cfg.get("rsi_buy1",       DEFAULT_V1_CONFIG["rsi_buy1"]))
    rsi_buy2      = float(cfg.get("rsi_buy2",       DEFAULT_V1_CONFIG["rsi_buy2"]))
    rsi_buy3      = float(cfg.get("rsi_buy3",       DEFAULT_V1_CONFIG["rsi_buy3"]))
    rsi_sell      = float(cfg.get("rsi_sell",       DEFAULT_V1_CONFIG["rsi_sell"]))
    tp_pct        = float(cfg.get("take_profit_pct",DEFAULT_V1_CONFIG["take_profit_pct"]))
    sl_pct        = float(cfg.get("stop_loss_pct",  DEFAULT_V1_CONFIG["stop_loss_pct"]))
    slot_money    = float(cfg.get("slot_money",     DEFAULT_V1_CONFIG["slot_money"]))
    buy1_r        = float(cfg.get("buy1_ratio",     DEFAULT_V1_CONFIG["buy1_ratio"]))
    buy2_r        = float(cfg.get("buy2_ratio",     DEFAULT_V1_CONFIG["buy2_ratio"]))
    buy3_r        = float(cfg.get("buy3_ratio",     DEFAULT_V1_CONFIG["buy3_ratio"]))
    fee_rate      = float(cfg.get("fee_rate",       DEFAULT_V1_CONFIG["fee_rate"]))
    sell_tax      = float(cfg.get("sell_tax",       DEFAULT_V1_CONFIG["sell_tax"]))
    ath_drop_min  = float(cfg.get("ath_drop_min_pct",  DEFAULT_V1_CONFIG["ath_drop_min_pct"]))
    year_drop_min = float(cfg.get("year_drop_min_pct", DEFAULT_V1_CONFIG["year_drop_min_pct"]))
    w52_drop_min  = float(cfg.get("w52_drop_min_pct",  DEFAULT_V1_CONFIG["w52_drop_min_pct"]))

    if len(candles) < rsi_period + 5:
        return {"error": f"봉 부족: {len(candles)}개 (최소 {rsi_period + 5}개 필요)"}

    closes    = [float(c["close"]) for c in candles]
    highs     = [float(c["high"])  for c in candles]
    lows      = [float(c["low"])   for c in candles]
    opens     = [float(c["open"])  for c in candles]
    dates     = [str(c["candle_date"])[:10] for c in candles]

    rsis = _rsi_series(closes, rsi_period)

    # ── 컨텍스트 배열 사전 계산 (ATH / 연도 고점 / 52주 고점) ─────────────────
    ath_arr: List[float]  = []
    year_arr: List[float] = []
    w52_arr: List[float]  = []
    _year_max: Dict[str, float] = {}
    _ath_run = 0.0

    for i in range(len(candles)):
        h = highs[i]; y = dates[i][:4]
        _ath_run     = max(_ath_run, h)
        _year_max[y] = max(_year_max.get(y, 0.0), h)
        # 52주(약 252거래일) 슬라이딩 윈도우
        w52_s = max(0, i - 251)
        try:
            cutoff = str(_date.fromisoformat(dates[i]) - _td(days=365))
            tmp = i
            while tmp > 0 and dates[tmp - 1] >= cutoff:
                tmp -= 1
            w52_s = tmp
        except Exception:
            pass
        ath_arr.append(_ath_run)
        year_arr.append(_year_max[y])
        w52_arr.append(max(highs[w52_s:i + 1]))

    # ── 시뮬레이션 ────────────────────────────────────────────────────────────
    position = None          # None 또는 Dict
    trades:  List[Dict] = []
    equity:  List[Dict] = []
    cum_pnl = 0.0
    start_i = rsi_period + 1

    for i in range(start_i, len(candles) - 1):
        rsi = rsis[i]
        if rsi is None:
            continue

        close     = closes[i]
        next_open = float(opens[i + 1]) if opens[i + 1] > 0 else close
        if next_open <= 0:
            continue

        date_str = dates[i]

        def _drop(ref: float) -> float:
            """현재가 기준 고점 대비 낙폭 %"""
            return (ref - close) / ref * 100 if ref > 0 else 0.0

        # ─── 보유 중: 청산 먼저 체크, 그 다음 추가매수 ───────────────────────
        if position is not None:
            avg   = position["avg"]
            qty   = position["qty"]
            stage = position["stage"]

            profit_pct_now = (close - avg) / avg * 100 if avg > 0 else 0.0

            # 청산 조건
            sell_reason = None
            if profit_pct_now >= tp_pct:
                sell_reason = f"익절(+{profit_pct_now:.1f}%)"
            elif rsi >= rsi_sell:
                sell_reason = f"RSI과열({rsi:.1f})"
            elif profit_pct_now <= -sl_pct:
                sell_reason = f"손절({profit_pct_now:.1f}%)"

            if sell_reason:
                exit_price = next_open
                fee        = exit_price * qty * (fee_rate + sell_tax)
                pnl        = (exit_price - avg) * qty - fee
                cum_pnl   += pnl
                trades.append({
                    "buy_date":   dates[position["entry_i"]],
                    "sell_date":  dates[i + 1],
                    "avg_price":  round(avg),
                    "exit_price": round(exit_price),
                    "qty":        qty,
                    "pnl":        round(pnl),
                    "hold_days":  i + 1 - position["entry_i"],
                    "reason":     sell_reason,
                    "stage":      stage,
                    "ath_drop":   position.get("ath_drop", 0),
                })
                equity.append({"date": dates[i + 1], "cum_pnl": round(cum_pnl)})
                position = None
            else:
                # ─ 추가매수 (분할매수 핵심) ─────────────────────────────────
                # 2단계: 더 빠져서 rsi_buy2 이하가 됐을 때 추가
                if stage == 1 and rsi <= rsi_buy2:
                    add_inv  = slot_money * buy2_r
                    add_qty  = max(1, int(add_inv / next_open))
                    add_cost = next_open * add_qty * (1 + fee_rate)
                    new_qty  = qty + add_qty
                    position["avg"]   = (avg * qty + next_open * add_qty) / new_qty
                    position["qty"]   = new_qty
                    position["cost"] += add_cost
                    position["stage"] = 2

                # 3단계: 더 빠져서 rsi_buy3 이하가 됐을 때 추가
                elif stage == 2 and rsi <= rsi_buy3:
                    add_inv  = slot_money * buy3_r
                    add_qty  = max(1, int(add_inv / next_open))
                    add_cost = next_open * add_qty * (1 + fee_rate)
                    new_qty  = qty + add_qty
                    position["avg"]   = (avg * qty + next_open * add_qty) / new_qty
                    position["qty"]   = new_qty
                    position["cost"] += add_cost
                    position["stage"] = 3

            continue   # 보유 중엔 신규 진입 스킵

        # ─── 미보유: 낙폭 필터 → 1단계 진입 ──────────────────────────────────
        if ath_drop_min  > 0 and _drop(ath_arr[i])  < ath_drop_min:  continue
        if year_drop_min > 0 and _drop(year_arr[i]) < year_drop_min: continue
        if w52_drop_min  > 0 and _drop(w52_arr[i])  < w52_drop_min:  continue

        if rsi > rsi_buy1:
            continue

        invest = slot_money * buy1_r
        qty    = max(1, int(invest / next_open))
        cost   = next_open * qty * (1 + fee_rate)
        position = {
            "avg":      next_open,
            "qty":      qty,
            "cost":     cost,
            "stage":    1,
            "entry_i":  i + 1,
            "ath_drop": round(_drop(ath_arr[i]), 1),
        }

    # ── 기간 종료 강제 청산 ───────────────────────────────────────────────────
    if position is not None and candles:
        exit_price = closes[-1]
        avg = position["avg"]; qty = position["qty"]
        fee = exit_price * qty * (fee_rate + sell_tax)
        pnl = (exit_price - avg) * qty - fee
        cum_pnl += pnl
        trades.append({
            "buy_date":   dates[position["entry_i"]],
            "sell_date":  dates[-1],
            "avg_price":  round(avg),
            "exit_price": round(exit_price),
            "qty":        qty,
            "pnl":        round(pnl),
            "hold_days":  len(candles) - 1 - position["entry_i"],
            "reason":     "기간종료",
            "stage":      position["stage"],
            "ath_drop":   position.get("ath_drop", 0),
        })
        equity.append({"date": dates[-1], "cum_pnl": round(cum_pnl)})

    # ── 요약 통계 ──────────────────────────────────────────────────────────────
    total = len(trades)
    wins   = [t for t in trades if t["pnl"] > 0]
    losses = [t for t in trades if t["pnl"] < 0]

    gross_profit = sum(t["pnl"] for t in wins)
    gross_loss   = abs(sum(t["pnl"] for t in losses))
    pf           = round(gross_profit / gross_loss, 2) if gross_loss > 0 else 9999.0

    peak_eq = 0.0; mdd = 0.0; run_pnl = 0.0
    for t in trades:
        run_pnl += t["pnl"]
        peak_eq  = max(peak_eq, run_pnl)
        mdd      = max(mdd, peak_eq - run_pnl)

    # Buy & Hold 비교 (첫 진입 시점 기준)
    bnh_pct = 0.0; bnh_pnl = 0.0; bot_pct = 0.0
    if candles and slot_money > 0:
        first_p = float(candles[start_i]["open"])
        last_p  = float(candles[-1]["close"])
        if first_p > 0:
            bnh_qty = max(1, int(slot_money / first_p))
            bnh_pnl = round((last_p - first_p) * bnh_qty)
            bnh_pct = round((last_p - first_p) / first_p * 100, 2)
    total_pnl = sum(t["pnl"] for t in trades)
    bot_pct   = round(total_pnl / slot_money * 100, 2) if slot_money > 0 else 0.0

    reason_dist: Dict[str, int] = {}
    for t in trades:
        r = t["reason"].split("(")[0]
        reason_dist[r] = reason_dist.get(r, 0) + 1

    avg_hold = round(sum(t["hold_days"] for t in trades) / total, 1) if total else 0

    return {
        "candle_count": len(candles),
        "summary": {
            "total_trades":  total,
            "win_rate":      round(len(wins) / total * 100, 1) if total else 0,
            "total_pnl":     round(total_pnl),
            "profit_factor": pf,
            "max_drawdown":  round(mdd),
            "avg_hold_days": avg_hold,
            "reason_dist":   reason_dist,
            "bnh_pct":       bnh_pct,
            "bnh_pnl":       bnh_pnl,
            "bot_pct":       bot_pct,
        },
        "equity": equity[-200:],
        "trades": trades[-200:],
    }


# ─────────────────────────────────────────────────────────────────────────────
# 파라미터 Grid Search
# ─────────────────────────────────────────────────────────────────────────────
def run_param_search_v1(
    candles:    List[Dict],
    grid:       Optional[Dict] = None,
    min_trades: int = 2,
    base_cfg:   Optional[Dict] = None,
) -> List[Dict]:
    """
    V1 전략 파라미터 Grid Search.

    base_cfg: 탐색 그리드에 없는 파라미터 기준값.
              웹 카드에서 전달받은 사용자 설정값 사용.
    """
    from itertools import product as iproduct

    if grid is None:
        grid = {
            # ── 진입 RSI 임계값 ──────────────────────────────────────────────
            "rsi_buy1":         [60, 55, 50, 45],
            "rsi_buy2":         [50, 45, 40, 35],
            "rsi_buy3":         [40, 35, 30, 25],
            # ── 매도 RSI ────────────────────────────────────────────────────
            "rsi_sell":         [70, 75, 80],
            # ── 익절/손절 ────────────────────────────────────────────────────
            "take_profit_pct":  [10.0, 15.0, 20.0, 30.0],
            "stop_loss_pct":    [7.0, 10.0, 15.0],
            # ── ATH 낙폭 필터 ─────────────────────────────────────────────
            "ath_drop_min_pct": [0.0, 20.0],
        }

    keys   = list(grid.keys())
    combos = list(iproduct(*[grid[k] for k in keys]))

    _base = dict(DEFAULT_V1_CONFIG)
    if base_cfg:
        _base.update(base_cfg)

    results = []
    for vals in combos:
        cfg = dict(_base)
        cfg.update(dict(zip(keys, vals)))
        # rsi_buy1 > rsi_buy2 > rsi_buy3 조건 보정
        if not (cfg["rsi_buy1"] > cfg["rsi_buy2"] > cfg["rsi_buy3"]):
            continue

        res = run_backtest_v1(candles, cfg)
        if "error" in res:
            continue
        s = res.get("summary", {})
        if s.get("total_trades", 0) < min_trades:
            continue
        results.append({
            "params":       {k: cfg[k] for k in keys},
            "total_pnl":    s["total_pnl"],
            "win_rate":     s["win_rate"],
            "total_trades": s["total_trades"],
            "pf":           s["profit_factor"],
            "avg_hold":     s["avg_hold_days"],
            "mdd":          s["max_drawdown"],
        })

    results.sort(key=lambda x: x["total_pnl"], reverse=True)
    return results


# ─────────────────────────────────────────────────────────────────────────────
# CLI 진입점
# ─────────────────────────────────────────────────────────────────────────────
def main():
    today    = datetime.now().strftime("%Y-%m-%d")
    year_ago = (datetime.now() - _td(days=365)).strftime("%Y-%m-%d")

    parser = argparse.ArgumentParser(
        description="홀딩 V1 (RSI 분할매수) 백테스트 · 파라미터탐색"
    )
    parser.add_argument("--code",       required=True,         help="종목 코드 (예: 005930)")
    parser.add_argument("--start",      default=year_ago,      help="시작일 YYYY-MM-DD")
    parser.add_argument("--end",        default=today,         help="종료일 YYYY-MM-DD")
    parser.add_argument("--search",     action="store_true",   help="파라미터 탐색 모드")
    parser.add_argument("--min_trades", default=2, type=int,   help="탐색 최소 거래 수")
    parser.add_argument("--top",        default=20, type=int,  help="탐색 결과 상위 N개")
    parser.add_argument("--rsi_buy1",   default=None, type=float)
    parser.add_argument("--rsi_buy2",   default=None, type=float)
    parser.add_argument("--rsi_buy3",   default=None, type=float)
    parser.add_argument("--rsi_sell",   default=None, type=float)
    parser.add_argument("--tp",         default=None, type=float, dest="take_profit_pct")
    parser.add_argument("--sl",         default=None, type=float, dest="stop_loss_pct")
    parser.add_argument("--ath_drop",   default=None, type=float, dest="ath_drop_min_pct")
    args = parser.parse_args()

    import logging as _lg
    _lg.getLogger("TradeDB").setLevel(_lg.WARNING)

    db = TradeDB()
    ensure_holding_tables(db)
    candles = get_stored_candles(db, args.code, args.start, args.end)
    db.close()

    if not candles:
        print(f"❌ {args.code} 캔들 없음 (holding_candles 테이블 확인)")
        return
    print(f"✅ {args.code} | {len(candles)}봉 ({candles[0]['candle_date']} ~ {candles[-1]['candle_date']})")

    # CLI 파라미터 오버라이드
    override = {}
    for k in ["rsi_buy1", "rsi_buy2", "rsi_buy3", "rsi_sell",
              "take_profit_pct", "stop_loss_pct", "ath_drop_min_pct"]:
        v = getattr(args, k, None)
        if v is not None:
            override[k] = v

    if args.search:
        print(f"\n🔍 V1 파라미터 탐색 중…")
        results = run_param_search_v1(candles, min_trades=args.min_trades,
                                      base_cfg=override or None)
        if not results:
            print("⚠️  유효 결과 없음")
            return
        print(f"\n{'='*70}")
        print(f" 🏆 RSI 분할매수 V1 — TOP {min(args.top, len(results))}")
        print(f"{'='*70}")
        keys = list(results[0]["params"].keys())
        hdr  = "  ".join(f"{k:>14}" for k in keys)
        print(f"{hdr}  | {'손익':>10}  {'승률':>6}  {'거래':>5}  {'PF':>5}  {'보유':>6}")
        print("-" * (len(hdr) + 50))
        for r in results[:args.top]:
            p   = r["params"]
            row = "  ".join(f"{p[k]:>14.4g}" for k in keys)
            print(f"{row}  | {r['total_pnl']:>+10,.0f}  {r['win_rate']:>5.1f}%  "
                  f"{r['total_trades']:>5}  {r['pf']:>5.2f}  {r['avg_hold']:>5.1f}일")
    else:
        cfg = dict(DEFAULT_V1_CONFIG)
        cfg.update(override)
        res = run_backtest_v1(candles, cfg)
        if "error" in res:
            print(f"❌ {res['error']}")
            return
        s = res["summary"]
        print(f"""
╔══════════════════════════════════════════════╗
║  📊 V1 RSI 분할매수 백테스트 결과            ║
╠══════════════════════════════════════════════╣
║  총 거래  : {s['total_trades']:>5}건                       ║
║  승률     : {s['win_rate']:>5.1f}%                      ║
║  순손익   : {s['total_pnl']:>+12,.0f} 원           ║
║  PF       : {s['profit_factor']:>5.2f}                      ║
║  MDD      : {s['max_drawdown']:>12,.0f} 원           ║
║  평균보유 : {s['avg_hold_days']:>5.1f}일                      ║
╠══════════════════════════════════════════════╣
║  봇 수익률  : {s['bot_pct']:>+8.2f}%                   ║
║  B&H 수익률 : {s['bnh_pct']:>+8.2f}%                   ║
║  알파       : {round(s['bot_pct']-s['bnh_pct'],2):>+8.2f}%p                  ║
╚══════════════════════════════════════════════╝""")
        for t in res["trades"][-10:]:
            print(f"  {t['buy_date']} → {t['sell_date']}  "
                  f"평단:{t['avg_price']:,}  매도:{t['exit_price']:,}  "
                  f"수량:{t['qty']}  손익:{t['pnl']:+,}  단계:{t['stage']}  {t['reason']}")


if __name__ == "__main__":
    main()
