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>
This commit is contained in:
@@ -0,0 +1,344 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user