"""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))