Files
Adriano Dal Pastro 5ac4e16af8 research(alt): sweep 104 strategie alternative su Deribit (153 agenti) + marginal scorer
Ondata di ricerca onesta a largo spettro su BTC/ETH+DVOL certificati: 104 ipotesi
distinte (11 famiglie), un agente-finder per ipotesi, verifica avversariale a 3
scettici sui promettenti, sintesi (153 agenti totali). Esito: NIENTE di nuovo regge
-> conferma del soffitto strutturale ~1.3 BTC/ETH-direzionale; lo stack
TP01+XS01+VRP01 resta imbattuto.

- altlib.py: harness condiviso vettoriale leak-free (eval_weights/study_weights,
  fee-sweep, both-asset + hold-out 2025+). Riproduce i numeri canonici di TP01.
- MARGINAL SCORER (study_marginal/marginal_vs_tp01): Sharpe INCREMENTALE vs baseline
  TP01 (corr, blend uplift OOS, alpha residua) + jackknife OOS (clean-year +
  drop-best-month). earns_slot = abs!=FAIL & ADDS & robust_oos. Smaschera gli overlay
  su TSMOM con PASS assoluti fasulli (CMB04, VOL11, ...) e il falso positivo KAMA
  (ADDS ma muore al jackknife).
- runs/*.py (104) script riproducibili per ipotesi; wf_altstrat.js workflow.
- Verdetto: 0 candidati deployabili; 2 LEAD fragili (VOL08, STA05_LS) da forward-monitor.
- test_marginal_scorer.py blocca baseline + invarianti. Suite: 32 verde.

Diario: docs/diary/2026-06-20-alt-strategies-100agent-sweep.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 19:50:39 +00:00

345 lines
14 KiB
Python

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