"""OPT06 — Ratio Put Spread (Defensive Short-Vol with Tail Hedge) IDEA: Ratio put spread (1x2 put ratio) modeled on DVOL: - Sell 1 OTM put at strike K1 = S * exp(-delta1) (e.g., -0.15 log-moneyness) - Buy 2 OTM puts at strike K2 = S * exp(-delta2) (e.g., -0.30 log-moneyness) Net: collect premium from the short put, use proceeds to buy tail protection. This is a "defensive short-vol" structure: - Moderate down moves (to K2) → profitable (net premium + short put profit) - Crash moves (below K2) → protected (long 2 puts offset the short) - Up moves → lose net premium received (small cost) The ratio 1:2 means the structure has POSITIVE gamma below K2 (net long put delta when S < K2) — the tail hedge kicks in. Above K2 but below K1, it's short-gamma (collects theta). Above K1, it's short a single put (small risk). GATE: Only enter when DVOL >= gate threshold (elevated IV → richer premium). Also gated on DVOL/RV ratio (only sell vol when IV > RV). ROLL: Weekly (7d) or biweekly (14d). GRID: 4 configs: (short_moneyness=-0.10, long_moneyness=-0.25, gate_dvol=50) (short_moneyness=-0.10, long_moneyness=-0.25, gate_dvol=60) (short_moneyness=-0.15, long_moneyness=-0.30, gate_dvol=50) (short_moneyness=-0.15, long_moneyness=-0.30, gate_dvol=60) → 4 configs × 1d TF = 4 backtests (within <=6 limit) CAVEAT: - MODELED on DVOL (ATM). Real puts have skew (OTM puts cost more → less premium). - History starts 2021-03 (DVOL). Backtest from 2021-03 only. - Tail risk partially mitigated by the ratio structure, but skew model error matters. - Not for deployment without real options pricing data. - Lead-only / modeled. Style: study_weights (continuous modeled position via P&L series). """ import sys sys.path.insert(0, "/opt/docker/PythagorasGoal/scripts/research/alt") import altlib as al import numpy as np import pandas as pd from scipy.stats import norm # ── Black-Scholes helpers ────────────────────────────────────────────────── def bs_put(S: float, K: float, T: float, sigma: float) -> float: """Black-Scholes put price (r=0, crypto/futures).""" if T <= 0 or sigma <= 0 or S <= 0 or K <= 0: return max(0.0, K - S) d1 = (np.log(S / K) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T)) d2 = d1 - sigma * np.sqrt(T) return float(K * norm.cdf(-d2) - S * norm.cdf(-d1)) def bs_put_delta(S: float, K: float, T: float, sigma: float) -> float: """Black-Scholes put delta (negative).""" if T <= 0 or sigma <= 0 or S <= 0 or K <= 0: return -1.0 if S < K else 0.0 d1 = (np.log(S / K) + 0.5 * sigma**2 * T) / (sigma * np.sqrt(T)) return float(norm.cdf(d1) - 1.0) def ratio_spread_value(S: float, K1: float, K2: float, T: float, sigma: float) -> float: """Value of short 1 put(K1) + long 2 puts(K2). Positive = we received cash.""" # Short 1 put at K1 (we receive premium = +put_K1) # Long 2 puts at K2 (we pay premium = -2*put_K2) # Net received = put(K1) - 2*put(K2) p1 = bs_put(S, K1, T, sigma) p2 = bs_put(S, K2, T, sigma) return p1 - 2.0 * p2 def ratio_spread_delta(S: float, K1: float, K2: float, T: float, sigma: float) -> float: """Net delta of position: short 1 put(K1) + long 2 puts(K2).""" d1 = bs_put_delta(S, K1, T, sigma) d2 = bs_put_delta(S, K2, T, sigma) return -d1 + 2.0 * d2 def ratio_spread_payoff(S_exp: float, K1: float, K2: float) -> float: """Payoff at expiry of short 1 put(K1) + long 2 puts(K2) (as fraction of S0).""" payoff_short = -max(0.0, K1 - S_exp) payoff_long = 2.0 * max(0.0, K2 - S_exp) return payoff_short + payoff_long def simulate_ratio_spread_cycle( close: np.ndarray, sigma_iv: np.ndarray, i0: int, roll_bars: int, short_moneyness: float, # log-moneyness of short put (e.g., -0.10 → 10% OTM) long_moneyness: float, # log-moneyness of long puts (e.g., -0.25 → 25% OTM) fee_side: float = 0.001 # 0.10% per leg per side (options spread) ) -> tuple[float, int]: """ Simulate one ratio put spread cycle. At entry i0: - K1 = S0 * exp(short_moneyness) [e.g., S0 * exp(-0.10) ≈ S0 * 0.905] - K2 = S0 * exp(long_moneyness) [e.g., S0 * exp(-0.25) ≈ S0 * 0.779] - Sell 1 put at K1, buy 2 puts at K2 - Net premium received = put(K1) - 2*put(K2) [in $] At expiry i_exp: - P&L = net_premium_received + payoff_at_expiry - transaction_costs P&L per unit of notional S0 (fraction of S0): net_pnl = (p1_entry - 2*p2_entry)/S0 + payoff(S_exp, K1, K2)/S0 - (3 legs * 2 sides * fee_side) [3 legs: 1 short + 2 long → 3 contracts] """ n = len(close) S0 = close[i0] T = roll_bars / 365.25 sig = sigma_iv[i0] if not (np.isfinite(sig) and sig > 0.02): return 0.0, min(i0 + roll_bars, n - 1) K1 = S0 * np.exp(short_moneyness) # short put (less OTM) K2 = S0 * np.exp(long_moneyness) # long puts (more OTM) # Net premium received at entry p1 = bs_put(S0, K1, T, sig) p2 = bs_put(S0, K2, T, sig) net_prem = p1 - 2.0 * p2 # positive → we received net premium i_exp = min(i0 + roll_bars, n - 1) S_exp = close[i_exp] # Payoff at expiry (from position payoff) payoff = ratio_spread_payoff(S_exp, K1, K2) # Transaction costs: 3 contracts (1 short + 2 long), entry + exit = 2 sides each # fee_side applies per contract per side tx_cost = 3 * 2 * fee_side * S0 # in $ terms net_pnl_dollar = net_prem + payoff - tx_cost net_pnl_frac = net_pnl_dollar / S0 return float(net_pnl_frac), i_exp def compute_ratio_spread_series( df: pd.DataFrame, asset: str, roll_days: int, short_moneyness: float, long_moneyness: float, gate_dvol: float, # minimum DVOL level to enter (vol points, e.g., 50) iv_rv_gate: float = 1.05, # minimum IV/RV ratio to enter rv_win_days: int = 20, fee_side: float = 0.001 ) -> np.ndarray: """ Simulate the full ratio put spread strategy. Returns per-bar P&L as fraction of equity (additive). Flat when not in a cycle or gate not met. """ close = df["close"].values.astype(float) n = len(close) sigma_iv = al.dvol(df, asset) / 100.0 # convert vol points → decimal log_r = al.log_returns(close) bpy = al.bars_per_year(df) rv_win = max(5, rv_win_days) rv_ann = pd.Series(log_r).rolling(rv_win, min_periods=max(2, rv_win // 2)).std().values * np.sqrt(bpy) # Find first bar with valid DVOL first_valid = np.where(np.isfinite(sigma_iv) & (sigma_iv > 0.02))[0] if len(first_valid) == 0: return np.zeros(n) start_bar = int(first_valid[0]) + rv_win # also need RV to warm up r_opt = np.zeros(n) i = start_bar while i < n - 1: sig_iv = sigma_iv[i] sig_rv = rv_ann[i] dvol_pts = sig_iv * 100.0 # back to vol points for gate # Entry conditions: # 1. Valid DVOL # 2. DVOL >= gate_dvol (vol is elevated → richer premium) # 3. IV/RV >= iv_rv_gate (selling vol when IV > RV) if (np.isfinite(sig_iv) and sig_iv > 0.02 and np.isfinite(sig_rv) and sig_rv > 0.02 and dvol_pts >= gate_dvol and sig_iv / sig_rv >= iv_rv_gate): net_pnl, i_exp = simulate_ratio_spread_cycle( close, sigma_iv, i, roll_days, short_moneyness=short_moneyness, long_moneyness=long_moneyness, fee_side=fee_side ) r_opt[i_exp] = net_pnl i = i_exp + 1 else: i += 1 return r_opt def eval_ratio_spread(df: pd.DataFrame, r_opt: np.ndarray) -> dict: """Evaluate ratio put spread P&L series into standard metrics.""" idx = pd.DatetimeIndex(pd.to_datetime(df["datetime"], utc=True)) n = len(r_opt) # The transaction costs are already inside simulate_ratio_spread_cycle. # Just compound the net P&L. r_net = r_opt.copy() eq = np.cumprod(1.0 + np.clip(r_net, -0.99, None)) eq = np.concatenate([[1.0], eq]) r_eq = np.diff(eq) / eq[:-1] r_eq = np.nan_to_num(r_eq) bpy = al.bars_per_year(df) rr = r_eq[np.isfinite(r_eq)] sharpe = float(np.mean(rr) / np.std(rr) * np.sqrt(bpy)) if np.std(rr) > 0 else 0.0 pk = np.maximum.accumulate(eq[1:]) dd = float(np.max((pk - eq[1:]) / pk)) if len(eq) > 1 else 0.0 span_days = (idx[-1] - idx[0]).total_seconds() / 86400 if len(idx) > 1 else 1.0 years = max(span_days / 365.25, 1e-6) total_ret = eq[-1] / eq[0] - 1 cagr = (eq[-1] / eq[0]) ** (1 / years) - 1 full = dict(sharpe=round(sharpe, 3), cagr=round(cagr, 4), maxdd=round(dd, 4), ret=round(total_ret, 4), n=int(n)) hmask = idx >= al.HOLDOUT hold = dict(sharpe=0.0, ret=0.0, n=0) if hmask.sum() > 3: r_h = r_eq[hmask] hs = float(np.mean(r_h) / np.std(r_h) * np.sqrt(bpy)) if np.std(r_h) > 0 else 0.0 eq_h = np.cumprod(1.0 + np.clip(r_h, -0.99, None)) hold = dict(sharpe=round(hs, 3), ret=round(float(eq_h[-1] - 1), 4), n=int(hmask.sum())) s = pd.Series(r_eq, index=idx) yearly = {} for y, g in s.groupby(s.index.year): eq_y = np.cumprod(1 + g.values) pk_y = np.maximum.accumulate(eq_y) yearly[int(y)] = dict(ret=round(float(eq_y[-1] - 1), 4), dd=round(float(np.max((pk_y - eq_y) / pk_y)), 4)) settle_bars = (r_opt != 0).sum() turnover_per_year = round(float(settle_bars / (span_days / 365.25)), 1) return dict(full=full, holdout=hold, yearly=yearly, time_in_market=round(float(settle_bars / n), 3), turnover_per_year=turnover_per_year) def run_ratio_spread( short_moneyness: float, long_moneyness: float, gate_dvol: float, roll_days: int = 7, tfs=("1d",) ) -> dict: """Run ratio put spread study for one parameter config.""" name = (f"OPT06-RatioPutSpread-short{abs(short_moneyness)*100:.0f}pct" f"-long{abs(long_moneyness)*100:.0f}pct-dvol{gate_dvol:.0f}") cells = [] for tf in tfs: per_asset = {} fee_ok_all = True for asset in al.CERTIFIED: df = al.get(asset, tf) r_opt = compute_ratio_spread_series( df, asset, roll_days=roll_days, short_moneyness=short_moneyness, long_moneyness=long_moneyness, gate_dvol=gate_dvol ) base = eval_ratio_spread(df, r_opt) # Fee sweep: scale the option tx cost # Base fee_side=0.001; sweep by adjusting the per-cycle cost sweep = {} for f_side in al.FEE_SWEEP: r_sweep = compute_ratio_spread_series( df, asset, roll_days=roll_days, short_moneyness=short_moneyness, long_moneyness=long_moneyness, gate_dvol=gate_dvol, fee_side=f_side ) sw = eval_ratio_spread(df, r_sweep) # Key: 0.20%RT = 0.0010/side = what we label sweep[f"{2*f_side*100:.2f}%RT"] = sw["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 al.CERTIFIED) min_hold = min(per_asset[a]["holdout"].get("sharpe", 0.0) for a in al.CERTIFIED) 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 al.CERTIFIED]), 3), fee_survives=fee_ok_all)) verdict = al._verdict(cells) return dict(name=name, kind="weights", cells=cells, verdict=verdict) if __name__ == "__main__": print("OPT06 — Ratio Put Spread (Defensive Short-Vol with Tail Hedge)") print("CAVEAT: MODELED on DVOL ATM. Skew not modeled → OTM puts underpriced in model.") print("DVOL starts 2021-03 → backtest from 2021-03 only.") print("Lead-only / modeled. Not for deployment.") print() # Grid: 4 configs # (short_moneyness, long_moneyness, gate_dvol) CONFIGS = [ (-0.10, -0.25, 50.0), # 10%/25% OTM, gate DVOL>=50 (-0.10, -0.25, 60.0), # 10%/25% OTM, gate DVOL>=60 (-0.15, -0.30, 50.0), # 15%/30% OTM, gate DVOL>=50 (-0.15, -0.30, 60.0), # 15%/30% OTM, gate DVOL>=60 ] best_rep = None best_score = -999.0 for short_m, long_m, gate_d in CONFIGS: print(f"--- short={short_m*100:.0f}%, long={long_m*100:.0f}%, gate_dvol={gate_d} ---") rep = run_ratio_spread( short_moneyness=short_m, long_moneyness=long_m, gate_dvol=gate_d, roll_days=7, tfs=("1d",) ) print(al.fmt(rep)) score = rep["verdict"].get("best_holdout_sharpe", -9) if score > best_score: best_score = score best_rep = rep print() print("=" * 60) print("BEST CONFIG:") print(al.fmt(best_rep)) print() print("JSON:", al.as_json(best_rep))