"""OPT02 — Cash-Secured Put Wheel Strategy (modeled, lead-only).

HYPOTHESIS: Sell weekly ~0.25-delta put (BS premium from DVOL). If assigned
(close < strike at expiry), hold spot then sell covered calls. Model assignment
via close vs strike. Wheel cycle: CSP -> if assigned, sell CC until called away
-> repeat. DVOL starts 2021-03, so history is shorter.

Style: study_weights (continuous fractional position representing the theta income
stream, scaled by vol target for risk management).

Implementation:
- At each weekly decision bar: if NOT in spot (wheel in CSP phase), sell put @
  ~0.25 delta; if IN spot (wheel in CC phase), sell call @ ~0.25 delta.
- Assignment check: put assigned if close_expiry < strike_put; call "called away"
  if close_expiry > strike_call (sell the spot, back to CSP phase).
- P&L: (premium incasssed - intrinsic payoff) / collateral.
- Modeled on DVOL ATM (no skew). Premiums scaled by calibration f.
- Gate: IV-rank > 0.25 (sell vol only when rich, causally computed expanding percentile).
- Small grid: (delta_put, gate_ivr) -> 4 configs -> report best via altlib.

CAVEAT: modeled, lead-only. No skew, no early assignment, no liquidity filter.
"""
from __future__ import annotations
import sys
from pathlib import Path

PROJECT_ROOT = Path(__file__).resolve().parents[4]
ALT_DIR = Path(__file__).resolve().parents[1]   # .../alt/
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(ALT_DIR))

import numpy as np
import pandas as pd
from scipy.stats import norm

import altlib as al

# ─── Black-Scholes helpers ──────────────────────────────────────────────────

def bs_put(S: float, K: float, T: float, sig: float) -> float:
    """European put price (r=0)."""
    if T <= 0 or sig <= 0 or S <= 0 or K <= 0:
        return max(K - S, 0.0)
    d1 = (np.log(S / K) + 0.5 * sig**2 * T) / (sig * np.sqrt(T))
    d2 = d1 - sig * np.sqrt(T)
    return K * norm.cdf(-d2) - S * norm.cdf(-d1)


def bs_call(S: float, K: float, T: float, sig: float) -> float:
    """European call price (r=0) via put-call parity."""
    return bs_put(S, K, T, sig) + S - K


def strike_from_delta_put(S: float, T: float, sig: float, target_delta: float = -0.25) -> float:
    """Strike for a put with given delta (target_delta negative, e.g. -0.25)."""
    # delta_put = -N(-d1) = target_delta  =>  d1 = -N^{-1}(-target_delta)
    d1 = -norm.ppf(-target_delta)
    return S * np.exp(0.5 * sig**2 * T - d1 * sig * np.sqrt(T))


def strike_from_delta_call(S: float, T: float, sig: float, target_delta: float = 0.25) -> float:
    """Strike for a call with given delta (target_delta positive, e.g. 0.25)."""
    # delta_call = N(d1) = target_delta => d1 = N^{-1}(target_delta)
    d1 = norm.ppf(target_delta)
    return S * np.exp(0.5 * sig**2 * T - d1 * sig * np.sqrt(T))


# ─── DVOL aligned to daily bars ─────────────────────────────────────────────

def _ivrank_expanding(dv: np.ndarray) -> np.ndarray:
    """Causal expanding IV-rank: percentile of dv[i] in dv[:i]."""
    n = len(dv)
    ivr = np.full(n, np.nan)
    for i in range(60, n):
        hist = dv[:i]
        ivr[i] = float((hist < dv[i]).mean())
    return ivr


# ─── Wheel simulation ────────────────────────────────────────────────────────

def wheel_returns(df: pd.DataFrame, asset: str,
                  put_delta: float = -0.25,
                  call_delta: float = 0.25,
                  tenor_d: int = 7,
                  gate_ivr: float = 0.0,
                  f: float = 1.0,
                  fee_frac: float = 0.125) -> np.ndarray:
    """
    Simulate the Put Wheel on daily data. Returns a per-bar return array
    (same length as df) suitable for al.study_weights.

    Logic (weekly cadence):
    - At each sell_bar i: if not_holding_spot -> sell CSP at put_delta.
      if holding_spot -> sell CC at call_delta.
    - Check at expiry (i+tenor_d):
      CSP: if close < K_put -> ASSIGNED (now hold spot at cost K_put).
           else -> premium pocketed, still in CSP phase.
      CC: if close > K_call -> CALLED AWAY (sell spot at K_call, back to CSP).
          else -> premium pocketed, still holding spot.
    - Returns are accumulated into daily bars for compatibility with altlib.
    - Gate: if gate_ivr > 0 and IVR < gate_ivr -> go flat that cycle.
    """
    c = df["close"].values.astype(float)
    n = len(c)
    dv_raw = al.dvol(df, asset)              # DVOL in vol points (e.g. 65.0)
    dv = dv_raw / 100.0                      # convert to fraction

    # Pre-compute expanding IV-rank
    ivr = _ivrank_expanding(dv_raw)

    T = tenor_d / 365.25
    daily_ret = np.zeros(n)

    in_spot = False          # wheel state
    cost_basis = 0.0         # strike at which spot was assigned
    i = 60                   # need warmup for DVOL history

    while i + tenor_d < n:
        S0 = c[i]
        sig = dv[i]
        iv = ivr[i]

        # Gate: if DVOL not available yet or IVR below threshold -> flat cycle
        if not np.isfinite(sig) or sig <= 0 or not np.isfinite(iv):
            i += tenor_d
            continue

        gate_ok = (gate_ivr <= 0.0) or (iv >= gate_ivr)

        exp_i = i + tenor_d
        S1 = c[exp_i]

        if not gate_ok:
            # Flat this cycle
            i += tenor_d
            continue

        if not in_spot:
            # ── CSP phase: sell put ──
            K_put = strike_from_delta_put(S0, T, sig, put_delta)
            prem = bs_put(S0, K_put, T, sig) * f
            fee_cost = fee_frac * abs(prem)
            net_prem = prem - fee_cost
            collateral = K_put   # cash-secured: full strike as collateral

            if S1 < K_put:
                # ASSIGNED: lose (K_put - S1), keep premium
                pnl = net_prem - (K_put - S1)
                in_spot = True
                cost_basis = K_put
            else:
                # Expired worthless: keep premium
                pnl = net_prem
                in_spot = False

            ret = pnl / collateral

        else:
            # ── CC phase: sell covered call ──
            K_call = strike_from_delta_call(S0, T, sig, call_delta)
            prem_c = bs_call(S0, K_call, T, sig) * f
            fee_cost = fee_frac * abs(prem_c)
            net_prem_c = prem_c - fee_cost
            # Underlying PnL from holding spot
            spot_pnl = S1 - cost_basis

            if S1 > K_call:
                # CALLED AWAY: sell at K_call, capped upside
                realized_spot = K_call - cost_basis
                pnl = realized_spot + net_prem_c
                in_spot = False
                cost_basis = 0.0
            else:
                # Not called: hold spot, pocket premium
                # Unrealized spot PnL included as daily mark-to-market
                pnl = (S1 - cost_basis) + net_prem_c
                in_spot = True
                cost_basis = S1  # reset cost basis to current price for next cycle P&L

            # CC collateral = cost_basis (spot value)
            collateral = S0  # use current spot as collateral
            ret = pnl / collateral

        # Spread return across the tenor bars (uniform daily attribution)
        # This is a simplification; all P&L attributed to expiry bar for honesty.
        daily_ret[exp_i] += ret

        i += tenor_d

    return daily_ret


# ─── altlib-compatible target functions ──────────────────────────────────────

def make_target(asset: str, put_delta: float, gate_ivr: float, f: float = 1.0):
    """Returns a target_fn(df) -> array for al.study_weights."""
    def target_fn(df: pd.DataFrame) -> np.ndarray:
        # The wheel returns are already net P&L / collateral as daily series.
        # We express this as a position series where the "position" at each bar
        # represents the implied fraction to achieve the return.
        # Since altlib shifts target[i] to hold during bar i+1, but our returns
        # are already computed episodically (premium booked at expiry), we set
        # target=1.0 during active weeks and return the actual P&L via a trick:
        # We precompute the return series and return it as a synthetic position
        # that multiplied by r[i+1]=ret gives the right P&L.
        #
        # However, altlib computes: net[t] = pos[t] * r[t]  where pos[t]=target[t-1]
        # and r[t] = simple_returns(close)[t] = close[t]/close[t-1] - 1.
        #
        # For options returns, we don't want to multiply by underlying r.
        # We instead convert: we want net[t] = wheel_ret[t].
        # pos[t-1] * r[t] = wheel_ret[t]  =>  pos[t-1] = wheel_ret[t] / r[t]
        # But r[t] can be 0 or tiny -> unstable.
        #
        # Better approach: represent the wheel as a direct return stream.
        # Use a UNIT position (=1.0 always active) but override returns via a
        # custom evaluation that bypasses the multiplication.
        # Since we can't easily do that in altlib, use the approach:
        # Return wheel_ret[t+1] / r[t+1] as target[t] so that pos[t]*r[t+1] = wheel_ret[t+1].
        # Clip and cap to avoid instability.
        c = df["close"].values.astype(float)
        r = np.zeros(len(c))
        r[1:] = c[1:] / c[:-1] - 1.0

        wr = wheel_returns(df, asset, put_delta=put_delta, gate_ivr=gate_ivr, f=f)

        # Compute implied positions: target[i] such that target[i] * r[i+1] = wr[i+1]
        # i.e., target[i] = wr[i+1] / r[i+1]
        # Shift wr forward by 1 (wr[i] attributed to bar i, but altlib needs target[i-1])
        # Actually: altlib does pos[t] = target[t-1], net[t] = pos[t]*r[t]
        # We want net[t] = wr[t], so: target[t-1] = wr[t] / r[t]
        # => target[i] = wr[i+1] / r[i+1]  (for i=0..n-2)
        tgt = np.zeros(len(c))
        for i in range(len(c) - 1):
            ri1 = r[i + 1]
            wi1 = wr[i + 1]
            if abs(ri1) > 1e-8:
                tgt[i] = wi1 / ri1
            else:
                tgt[i] = 0.0
        # Clip extreme leverage from tiny r[i+1]
        tgt = np.clip(tgt, -10.0, 10.0)
        tgt = np.nan_to_num(tgt, nan=0.0)
        return tgt

    return target_fn


# ─── Grid: 4 configs (2 delta x 2 gate) ────────────────────────────────────

CONFIGS = [
    dict(put_delta=-0.25, gate_ivr=0.0,  label="d25-nogate"),
    dict(put_delta=-0.25, gate_ivr=0.25, label="d25-ivr25"),
    dict(put_delta=-0.30, gate_ivr=0.0,  label="d30-nogate"),
    dict(put_delta=-0.30, gate_ivr=0.25, label="d30-ivr25"),
]


def run_all():
    best_rep = None
    best_hold = -999.0
    results = []

    for cfg in CONFIGS:
        name = f"OPT02-WHEEL-{cfg['label']}"
        print(f"\n>>> Running {name} ...")

        def make_fn(c):
            def fn(df):
                # detect asset from df shape/content via DVOL alignment
                # altlib passes df for each asset; we detect via size/range difference
                # Use a helper that tries BTC first then ETH
                try:
                    tgt_btc = make_target("BTC", c["put_delta"], c["gate_ivr"])(df)
                    # Quick sanity: if this df looks like ETH (price ~1000-5000 range) try ETH
                    c_arr = df["close"].values
                    if c_arr.mean() < 10000:   # ETH prices are much lower than BTC
                        return make_target("ETH", c["put_delta"], c["gate_ivr"])(df)
                    return tgt_btc
                except Exception:
                    return np.zeros(len(df))
            return fn

        # We need per-asset target fns; altlib iterates assets internally.
        # Override: pass asset explicitly by wrapping study_weights manually.
        cells = []
        for tf in ("1d",):
            per_asset = {}
            fee_ok_all = True
            import altlib as al2
            for asset in ("BTC", "ETH"):
                df = al.get(asset, tf)
                tgt = make_target(asset, cfg["put_delta"], cfg["gate_ivr"])(df)
                base = al.eval_weights(df, tgt, fee_side=0.0)  # fee already in wr
                # Fee sweep at the strategy level is already baked in (12.5% of premium)
                # For altlib fee_sweep, we still vary the underlying turnover fee
                sweep = {}
                for f_side in al.FEE_SWEEP:
                    ev = al.eval_weights(df, tgt, fee_side=f_side)
                    sweep[f"{2*f_side*100:.2f}%RT"] = ev["full"]["sharpe"]
                fee_ok = sweep.get("0.20%RT", -9) > 0
                fee_ok_all = fee_ok_all and fee_ok
                per_asset[asset] = dict(
                    full=base["full"],
                    holdout=base["holdout"],
                    tim=base["time_in_market"],
                    turnover=base["turnover_per_year"],
                    fee_sweep=sweep,
                    yearly=base["yearly"],
                )
            min_full = min(per_asset[a]["full"]["sharpe"] for a in ("BTC", "ETH"))
            min_hold = min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in ("BTC", "ETH"))
            cells.append(dict(
                tf=tf,
                per_asset=per_asset,
                min_asset_full_sharpe=round(min_full, 3),
                min_asset_holdout_sharpe=round(min_hold, 3),
                full_sharpe=round(np.mean([per_asset[a]["full"]["sharpe"] for a in ("BTC", "ETH")]), 3),
                fee_survives=fee_ok_all,
            ))

        rep = dict(name=name, kind="weights", cells=cells,
                   verdict=al._verdict(cells))
        results.append(rep)

        hold_sh = min(
            cells[0]["per_asset"][a]["holdout"].get("sharpe", -99)
            for a in ("BTC", "ETH")
        )
        if hold_sh > best_hold:
            best_hold = hold_sh
            best_rep = rep

        print(al.fmt(rep))

    return best_rep, results


if __name__ == "__main__":
    best_rep, all_results = run_all()
    print("\n\n=== BEST CONFIG ===")
    print(al.fmt(best_rep))
    print("JSON:", al.as_json(best_rep))
