merge(research): gamma-scalp + cash-carry + TP01×DVOL + vol-termstructure

Analisi onesta di 5 angoli (tutti su dati certificati, leak-free, netto fee,
scoring marginale vs TP01, vincoli eseguibilita' a $600):
- gamma scalping (scalp+opzioni)  -> SCARTATO (specchio del VRP, perde ogni freq)
- funding cross-sectional (FC01)  -> gia' morto (DILUTES)
- cash-and-carry (CC01)           -> premio reale ma Sharpe artefatto, STAT-MODE -> lead
- TP01 x DVOL vol-targeting       -> non migliora (de-levering, non timing)
- calendar-vol / term-structure   -> dato storico non pubblico -> forward logger (gia' su main)

Zero impatto sul codice live: solo script di ricerca + test + diari + note CLAUDE.md.
Il forward logger era gia' stato cablato in cron su main (555977d).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-26 19:28:54 +00:00
11 changed files with 1052 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
"""CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid. Backtest onesto, STAT-MODE.
DIVERSO da FC01 (funding cross-sectional, gia' SCARTATO 2026-06-22). Qui NON si pickano
vincitori/perdenti cross-section: si HARVESTA il premio di funding AGGREGATO restando
delta-neutral sullo STESSO asset.
MECCANISMO. Long spot + short perp (stesso asset, stessa size):
long spot -> +price_ret
short perp -> -price_ret + funding (lo short INCASSA il funding quando f>0)
netto -> +funding (il prezzo si cancella -> zero esposizione direzionale)
Il ritorno della gamba delta-neutral = il funding realizzato, meno fee. Entrambe le gambe sono
lineari nel prezzo => restano matchate in nozionale senza ribilanciare (delta ~neutro da solo);
i costi reali sono entry/exit + spread, modellati come drag annuo fisso.
VARIANTI:
CC-static -> sempre long-spot/short-perp (basis trade classico): r = funding. Perde se f<0.
CC-gated -> harvest solo quando il funding trailing CAUSALE > 0 (esci dai regimi a funding
negativo invece di pagarli). r = funding se trail>0 else 0.
UNIVERSI: BTC/ETH (sottoinsieme rilevante per l'esecuzione) e i 19 major (basket pieno).
GIUDIZIO: standalone (Sharpe/DD/anni) + marginal_vs_tp01. CAVEAT ONESTI (pre-risultato):
- NON eseguibile a $600: serve spot+perp per gamba (BTC/ETH = 4 posizioni; 19 = 38). Su Deribit
lo storico funding e' bloccato e non operiamo spot HL -> STAT-MODE.
- Il modello "r=funding" IGNORA il rischio di base (perp != spot), la liquidazione dello short in
uno squeeze, e l'inversione brusca del funding. La vol modellata SOTTOSTIMA la coda.
- Lo storico funding parte 2023-05 -> NON contiene il deleveraging 2022 (il regime peggiore per il
carry). Edge potenzialmente sovrastimato.
uv run python scripts/research/cash_carry_hl.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
from src.portfolio.sleeves import XS_UNIVERSE, _HL_DIR
from altlib import marginal_vs_tp01 # type: ignore
SQ365 = np.sqrt(365.25)
ANNUAL_COST = 0.02 # drag annuo fisso: entry/exit + spread + borrow (generoso per un hold continuo)
def load_funding_panel(universe):
"""FUND, PREM [date x asset]: funding giornaliero (somma oraria) e premium/basis (ultimo del
giorno) per gli asset con dati. PREM serve a iniettare il mark-to-market della BASE (perp!=spot)."""
fund, prem = {}, {}
for sym in universe:
fp = _HL_DIR / f"hlfund_{sym.lower()}_1h.parquet"
if not fp.exists():
continue
df = pd.read_parquet(fp)
fund[sym] = df["funding"].resample("1D").sum()
prem[sym] = df["premium"].resample("1D").last()
FUND = pd.concat(fund, axis=1).sort_index()
PREM = pd.concat(prem, axis=1).sort_index().reindex(FUND.index)
if FUND.index.tz is None:
FUND.index = FUND.index.tz_localize("UTC"); PREM.index = PREM.index.tz_localize("UTC")
return FUND, PREM
def cc_returns(universe, mode="static", trail=14, cost=ANNUAL_COST, with_basis=False) -> pd.Series:
"""Ritorno giornaliero del basket cash-and-carry equal-weight, netto drag annuo.
with_basis=True inietta il mark-to-market reale della base: r = funding - Δpremium (lo short
perp marca contro l'allargamento del basis). E' il rischio che 'r=funding' nasconde."""
FUND, PREM = load_funding_panel(universe)
daily_cost = cost / 365.25
leg_raw = FUND - PREM.diff() if with_basis else FUND # per-asset daily mark
if mode == "gated":
sig = FUND.rolling(trail, min_periods=trail // 2).mean().shift(1) # funding trailing causale
active = (sig > 0).astype(float)
n_active = active.sum(axis=1).replace(0, np.nan)
gross = ((leg_raw * active).sum(axis=1) / n_active).fillna(0.0)
drag = (active.sum(axis=1) > 0).astype(float) * daily_cost
else:
gross = leg_raw.mean(axis=1)
drag = daily_cost
return (gross - drag).dropna()
def metrics(daily: pd.Series) -> dict:
r = daily.values
sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
pk = np.maximum.accumulate(eq)
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
s = pd.Series(eq, index=daily.index)
yearly = {}
for y, g in s.groupby(s.index.year):
if len(g) > 1:
v = g.values; p = np.maximum.accumulate(v)
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p)))
return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly, gross_ann=float(np.mean(r) * 365.25))
def main():
print("=" * 92)
print(" CC01 — CASH-AND-CARRY (basis trade) delta-neutral su Hyperliquid")
print(" long spot + short perp -> r = funding (zero esposizione prezzo). Netto drag 2%/anno.")
print("=" * 92)
configs = [
("BTC/ETH CC-static", ["BTC", "ETH"], "static"),
("BTC/ETH CC-gated", ["BTC", "ETH"], "gated"),
("19-major CC-static", XS_UNIVERSE, "static"),
("19-major CC-gated", XS_UNIVERSE, "gated"),
]
print("\n [A] MODELLO INGENUO (r = funding) — IGNORA il rischio di base:")
series = {}
for label, uni, mode in configs:
r = cc_returns(uni, mode=mode)
series[label] = r
m = metrics(r)
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
print(f"\n --- {label} --- ({r.index[0].date()} -> {r.index[-1].date()}, {len(r)}g)")
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
f"carry lordo {m['gross_ann']*100:+.1f}%/anno | per-anno: {ys}")
print("\n [B] CON MARK-TO-MARKET DELLA BASE (r = funding - Δpremium) — il rischio nascosto:")
series_b = {}
for label, uni, mode in configs:
r = cc_returns(uni, mode=mode, with_basis=True)
series_b[label] = r
m = metrics(r)
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
print(f"\n --- {label} (basis) ---")
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}% "
f"| per-anno: {ys}")
print("\n" + "=" * 92)
print(" REALITY CHECK — perche' uno Sharpe 11-13 e' un ARTEFATTO, non un edge")
print("=" * 92)
FUND, _ = load_funding_panel(["BTC", "ETH"])
agg = FUND.mean(axis=1)
neg = float((agg < 0).mean())
by_year = agg.groupby(agg.index.year).apply(lambda x: float(x.sum()))
print(f" funding aggregato BTC/ETH: giorni a funding NEGATIVO {neg*100:.0f}% | "
f"per-anno (somma): " + " ".join(f"{y}:{v*100:+.1f}%" for y, v in by_year.items()))
print(" -> il carry e' PROCYCLICO: +23% nel toro 2024, ~+1% nel 2026 (si comprime nel bear).")
print(" -> lo storico funding parte 2023-05: ASSENTE il deleveraging 2022 (funding -, basis blow-out),")
print(" il regime che farebbe il vero drawdown. + assenti: liquidazione short in squeeze, slippage")
print(" su spot+perp a $600. Sharpe reale di un basis-trade ~1-3 con code brusche, NON 13.")
print("\n" + "=" * 92)
print(" SCORING MARGINALE vs TP01 (sul modello ONESTO con rischio di base [B])")
print(" NB: ADDS/robust_oos qui ESPONGONO un punto cieco dello scorer — si fida della vol")
print(" riportata e non ha un gate 'Sharpe implausibile -> rischio nascosto'.")
print("=" * 92)
for label, r in series_b.items():
if r.std() == 0:
print(f"\n[{label}] flat — skip"); continue
m = marginal_vs_tp01(r)
b = m.get("blends", {}).get("w25", {})
print(f"\n[{label}]")
print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} "
f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} "
f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})")
print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | "
f"blend25 full {b.get('full')} (upl {b.get('uplift_full')}) "
f"hold {b.get('hold')} (upl {b.get('uplift_hold')}) DD {b.get('dd')}")
print(f" multicut persistente={m.get('multicut_persistent')} robust_oos={m.get('robust_oos')} "
f"hedge-check up/down {m.get('uplift_tp01_up')}/{m.get('uplift_tp01_down')}")
if __name__ == "__main__":
main()
+296
View File
@@ -0,0 +1,296 @@
"""GAMMA SCALPING (long-vol) — "scalping BTC/ETH con copertura in opzioni", valutato onestamente.
Interpretazione rigorosa di "fare scalping con copertura in opzioni" = GAMMA SCALPING:
compri un'opzione (la COPERTURA = long gamma), delta-hedgi il sottostante a cadenza fissa
(lo SCALP), e il P&L netto e' ~ dollar-gamma * (vol realizzata^2 - vol implicita^2). E' lo
SPECCHIO ESATTO del VRP01 (short-vol): VRP01 incassa IV-RV (positivo in media), il gamma
scalping incassa RV-IV (negativo in media, ma CONVESSO -> paga nei crash).
Modello (mirror della struttura VRP per comparabilita'):
- long STRADDLE ATM, tenor settimanale (7g), strike = spot all'ingresso.
- IV = DVOL Deribit (data/raw/dvol_*.parquet) — la stessa fonte IV del VRP.
- delta-hedge GIORNALIERO sui prezzi certificati 1d (BTC/ETH).
- P&L delta-hedged classico per step: DG_t * [(dS/S)^2 - sigma^2*dt],
DG_t = dollar-gamma dello straddle = phi(d1)*S/(sigma*sqrt(tau)).
- fee opzioni Deribit cap 12.5% del premio (come VRP) + fee perp sull'hedge turnover.
- return-on-notional (pnl/S0), poi VOL-TARGET 20% annuo per apples-to-apples con gli altri sleeve.
Varianti testate:
NAKED -> sempre long gamma (baseline: deve perdere il premio in media).
CHEAP-GATED -> entra solo quando IV-rank < gate (compri vol a sconto = specchio del gate VRP).
RICH-SKIP -> entra sempre tranne quando IV-rank > skip (evita di pagare vol carissima).
Output: metriche standalone + per-anno + SCORING MARGINALE vs TP01 (corr, blend uplift, is_hedge,
has_insample_edge) + il muro di ESEGUIBILITA' a $600 (min size opzioni Deribit).
uv run python scripts/research/options_gamma_scalp.py
ONESTA': premio MODELLATO su DVOL ATM (skew non esplicito) — stesso caveat del VRP. Long-vol da
modello: meno pericoloso dello short-vol (loss capped al premio), ma resta un LEAD da modello.
"""
from __future__ import annotations
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
sys.path.insert(0, str(PROJECT_ROOT / "scripts" / "research" / "alt"))
import numpy as np
import pandas as pd
from scipy.stats import norm
from src.data.downloader import load_data
from src.strategies.trend_portfolio import resample_1d
from altlib import marginal_vs_tp01 # type: ignore
RAW = PROJECT_ROOT / "data" / "raw"
SQ365 = np.sqrt(365.25)
DT = 1.0 / 365.25
# fee model (mirror VRP): opzioni Deribit cap 12.5% del premio; perp taker 0.05%/lato sull'hedge.
OPT_FEE_FRAC = 0.125
HEDGE_FEE_SIDE = 0.0005
TENOR_D = 7
def _bs_straddle(S, K, T, sig):
"""Premio BS di uno straddle ATM (call+put), r=0."""
if T <= 0 or sig <= 0:
return abs(S - K)
d1 = (np.log(S / K) + 0.5 * sig ** 2 * T) / (sig * np.sqrt(T))
d2 = d1 - sig * np.sqrt(T)
call = S * norm.cdf(d1) - K * norm.cdf(d2)
put = K * norm.cdf(-d2) - S * norm.cdf(-d1)
return call + put
def _dvol(asset: str) -> pd.Series:
dv = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
return pd.Series(dv["close"].values.astype(float),
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)) / 100.0
def _load(asset: str):
df = resample_1d(load_data(asset, "1h"))
s = pd.Series(df["close"].values.astype(float), index=pd.to_datetime(df["datetime"]))
if s.index.tz is None:
s.index = s.index.tz_localize("UTC")
J = pd.concat({"px": s, "dvol": _dvol(asset)}, axis=1, join="inner").sort_index().dropna()
return J["px"].values, J["dvol"].values / 1.0, J.index
def _load_hourly(asset: str):
df = load_data(asset, "1h")
s = pd.Series(df["close"].values.astype(float),
index=pd.to_datetime(df["timestamp"], unit="ms", utc=True)).sort_index()
return s
def rv_iv_diagnostic(asset: str):
"""Il CUORE strutturale: vol realizzata (a vari campionamenti) vs vol implicita (DVOL).
Se RV < IV in media -> il long gamma PERDE gross, prima di ogni fee. E' il VRP, di segno opposto."""
px, dvf, idx = _load(asset)
iv = float(np.mean(dvf))
rdaily = np.diff(np.log(px))
rv_daily = float(np.std(rdaily) * SQ365)
h = _load_hourly(asset)
rhour = np.diff(np.log(h.values))
rv_hour = float(np.std(rhour) * np.sqrt(365.25 * 24))
return dict(asset=asset, iv=iv, rv_daily=rv_daily, rv_hour=rv_hour,
spread_daily=iv - rv_daily, spread_hour=iv - rv_hour)
def gamma_scalp_hourly(asset: str, mode: str = "naked",
gate_cheap: float = 0.30) -> pd.Series:
"""Gamma scalp a rehedge ORARIO (la 'vera' frequenza di scalping): cattura la RV intraday,
ma paga 24x la fee di hedge. Tenor 7g = 168 step orari. IV costante nel giorno (DVOL)."""
h = _load_hourly(asset)
dv = _dvol(asset).reindex(h.index.normalize(), method="ffill")
dv.index = h.index
J = pd.concat({"px": h, "dvol": dv}, axis=1).dropna()
px = J["px"].values; dvf = J["dvol"].values; idx = J.index
n = len(px); STEPS = TENOR_D * 24; dt = 1.0 / (365.25 * 24)
# IV-rank causale su DVOL giornaliero per il gate
daily_iv = _dvol(asset)
rets = {}
i = 24 * 60
while i + STEPS < n:
S0 = px[i]; sig = dvf[i]; K = S0
if mode == "cheap":
day = idx[i].normalize()
hist = daily_iv[daily_iv.index < day]
if len(hist) >= 60 and float((hist < sig).mean()) > gate_cheap:
rets[idx[i + STEPS]] = 0.0; i += STEPS; continue
T = TENOR_D / 365.25
premium = _bs_straddle(S0, K, T, sig)
gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None
for t in range(1, STEPS + 1):
tau = max((STEPS - (t - 1)) * dt, dt)
Sp = px[i + t - 1]; Sn = px[i + t]
r = Sn / Sp - 1.0
d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau))
dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau))
gamma_pnl += dollar_gamma * (r * r - sig * sig * dt)
delta = 2.0 * norm.cdf(d1) - 1.0
if prev_delta is not None:
hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp
prev_delta = delta
pnl = gamma_pnl - OPT_FEE_FRAC * premium - hedge_fee
rets[idx[i + STEPS]] = pnl / S0
i += STEPS
out = pd.Series(rets)
out.index = out.index.normalize()
return out
def gamma_scalp_asset(asset: str, mode: str = "naked",
gate_cheap: float = 0.30, skip_rich: float = 0.90) -> pd.Series:
"""Rendimenti settimanali (return-on-notional) di una catena di long-straddle delta-hedged,
lumpati sul giorno di scadenza. Causale: IV/strike/gate da dati <= entry; payoff sul path."""
px, dvf, idx = _load(asset)
n = len(px)
rets = {}
i = 60
while i + TENOR_D < n:
S0 = px[i]; sig = dvf[i]; K = S0
skip = False
if i >= 60 and mode in ("cheap", "rich"):
ivr = float((dvf[:i] < dvf[i]).mean()) # IV-rank espandente causale
if mode == "cheap" and ivr > gate_cheap: # compra gamma solo se vol a SCONTO
skip = True
if mode == "rich" and ivr > skip_rich: # evita di pagare vol carissima
skip = True
if skip:
rets[idx[i + TENOR_D]] = 0.0; i += TENOR_D; continue
T = TENOR_D / 365.25
premium = _bs_straddle(S0, K, T, sig)
gamma_pnl = 0.0; hedge_fee = 0.0; prev_delta = None
for t in range(1, TENOR_D + 1):
tau = (TENOR_D - (t - 1)) / 365.25 # tempo residuo a inizio step
Sp = px[i + t - 1]; Sn = px[i + t]
r = Sn / Sp - 1.0
d1 = (np.log(Sp / K) + 0.5 * sig ** 2 * tau) / (sig * np.sqrt(tau))
dollar_gamma = norm.pdf(d1) * Sp / (sig * np.sqrt(tau)) # DG straddle = phi(d1)*S/(sig*sqrt(tau))
gamma_pnl += dollar_gamma * (r * r - sig * sig * DT)
delta = 2.0 * norm.cdf(d1) - 1.0 # delta straddle ATM
if prev_delta is not None:
hedge_fee += HEDGE_FEE_SIDE * abs(delta - prev_delta) * Sp
prev_delta = delta
opt_fee = OPT_FEE_FRAC * premium
pnl = gamma_pnl - opt_fee - hedge_fee
rets[idx[i + TENOR_D]] = pnl / S0 # return-on-notional
i += TENOR_D
return pd.Series(rets)
def to_daily_voltgt(weekly_btc: pd.Series, weekly_eth: pd.Series, tgt_vol: float = 0.20) -> pd.Series:
"""Book 50/50 BTC+ETH su griglia giornaliera, poi scalato a tgt_vol annuo (apples-to-apples
con gli altri sleeve, tutti vol-targeted ~20%)."""
wk = pd.concat({"B": weekly_btc, "E": weekly_eth}, axis=1, join="inner").mean(axis=1).sort_index()
if wk.empty:
return wk
days = pd.date_range(wk.index.min().normalize(), wk.index.max().normalize(), freq="1D", tz="UTC")
daily = pd.Series(0.0, index=days)
daily.loc[wk.index.normalize()] = wk.values
sd = daily.std()
if sd > 0:
daily = daily * (tgt_vol / (sd * SQ365))
return daily
def _metrics(daily: pd.Series) -> dict:
r = daily.values
sh = float(np.mean(r) / np.std(r) * SQ365) if np.std(r) > 0 else 0.0
eq = np.cumprod(1.0 + np.clip(r, -0.99, None))
pk = np.maximum.accumulate(eq)
dd = float(np.max((pk - eq) / pk))
yrs = (daily.index[-1] - daily.index[0]).days / 365.25
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and eq[-1] > 0 else -1.0
s = pd.Series(eq, index=daily.index)
yearly = {}
for y, g in s.groupby(s.index.year):
if len(g) > 1:
v = g.values; p = np.maximum.accumulate(v)
yearly[int(y)] = (float(g.iloc[-1] / g.iloc[0] - 1), float(np.max((p - v) / p)))
return dict(sharpe=sh, dd=dd, cagr=cagr, yearly=yearly)
def main():
print("=" * 92)
print(" GAMMA SCALPING (long-vol) — scalping BTC/ETH con copertura in opzioni")
print(" Modello: long straddle ATM 7g, delta-hedge 1d, P&L = DG*(RV^2 - IV^2). Mirror del VRP01.")
print("=" * 92)
print("\n DIAGNOSTICA STRUTTURALE — vol implicita (DVOL) vs realizzata (il segno dell'edge):")
for a in ("BTC", "ETH"):
d = rv_iv_diagnostic(a)
print(f" {a}: IV {d['iv']*100:5.1f}% | RV_1d {d['rv_daily']*100:5.1f}% "
f"(IV-RV {d['spread_daily']*100:+.1f}pp) RV_1h {d['rv_hour']*100:5.1f}% "
f"(IV-RV {d['spread_hour']*100:+.1f}pp)")
print(" -> IV-RV > 0 = il mercato PAGA per essere short-vol (VRP). Il long gamma e' su questo lato"
", al ROVESCIO: paga il premio. RV_1h>RV_1d -> rehedge orario cattura piu' RV (test sotto).")
variants = {
"NAKED (sempre long gamma)": dict(mode="naked"),
"CHEAP-GATED (IV-rank<0.30 = vol scont)": dict(mode="cheap", gate_cheap=0.30),
"CHEAP-GATED (IV-rank<0.50)": dict(mode="cheap", gate_cheap=0.50),
"RICH-SKIP (no entry se IV-rank>0.90)": dict(mode="rich", skip_rich=0.90),
}
series = {}
for label, kw in variants.items():
wB = gamma_scalp_asset("BTC", **kw)
wE = gamma_scalp_asset("ETH", **kw)
daily = to_daily_voltgt(wB, wE)
series[label] = daily
m = _metrics(daily)
ntr = int((wB != 0).sum() + (wE != 0).sum())
print(f"\n--- {label} ---")
print(f" standalone (vol-tgt 20%): Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% "
f"maxDD {m['dd']*100:.1f}% trade {ntr}")
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
print(f" per-anno PnL: {ys}")
print("\n" + "=" * 92)
print(" REHEDGE ORARIO (la 'vera' frequenza di scalping: cattura RV intraday, paga 24x hedge fee)")
print("=" * 92)
for label, kw in (("NAKED orario", dict(mode="naked")),
("CHEAP-GATED orario (IV-rank<0.30)", dict(mode="cheap", gate_cheap=0.30))):
wB = gamma_scalp_hourly("BTC", **kw); wE = gamma_scalp_hourly("ETH", **kw)
daily = to_daily_voltgt(wB, wE)
m = _metrics(daily)
ys = " ".join(f"{y}:{p*100:+.0f}%" for y, (p, d) in sorted(m['yearly'].items()))
print(f"\n--- {label} ---")
print(f" Sharpe {m['sharpe']:+.2f} CAGR {m['cagr']*100:+.1f}% maxDD {m['dd']*100:.1f}%")
print(f" per-anno PnL: {ys}")
print("\n" + "=" * 92)
print(" SCORING MARGINALE vs TP01 (lo standard del progetto: un nuovo sleeve si giudica QUI)")
print("=" * 92)
for label, daily in series.items():
if daily.std() == 0:
print(f"\n[{label}] flat — skip"); continue
m = marginal_vs_tp01(daily)
b25 = m.get("blends", {}).get("w25", {})
print(f"\n[{label}]")
print(f" verdict={m.get('marginal_verdict')} corr->TP01 full={m.get('corr_full')} "
f"hold={m.get('corr_hold')} is_hedge={m.get('is_hedge')} "
f"has_insample_edge={m.get('has_insample_edge')} (cand IS Sharpe {m.get('cand_insample_sharpe')})")
print(f" cand Sharpe full={m.get('cand_full_sharpe')} hold={m.get('cand_hold_sharpe')} | "
f"blend25 full {b25.get('full')} (upl {b25.get('uplift_full')}) "
f"hold {b25.get('hold')} (upl {b25.get('uplift_hold')}) DD {b25.get('dd')}")
print(f" hedge-check: uplift TP01-up {m.get('uplift_tp01_up')} / TP01-down {m.get('uplift_tp01_down')} "
f"yearly-corr {m.get('hedge_yearly_corr')}")
print("\n" + "=" * 92)
print(" ESEGUIBILITA' a ~$600 (Deribit options min size)")
print("=" * 92)
pxB = _load("BTC")[0][-1]; pxE = _load("ETH")[0][-1]
for a, p, csz, minc in (("BTC", pxB, 1.0, 0.1), ("ETH", pxE, 1.0, 0.1)):
notion = p * csz * minc
print(f" {a}: spot ${p:,.0f} | contratto {csz} {a}, min {minc} {a} -> notional minimo "
f"${notion:,.0f} ({'OK' if notion < 600 else 'OLTRE i $600 -> NON eseguibile'})")
if __name__ == "__main__":
main()
+159
View File
@@ -0,0 +1,159 @@
"""TP01 × DVOL — la vol IMPLICITA (forward-looking) migliora il risk-sizing di TP01? (ESEGUIBILE)
A differenza degli sleeve diversificatori (XS01/VRP01/carry = STAT-MODE, non eseguibili a $600),
questo TOCCA il book live: TP01 è BTC/ETH perp su Deribit, già armato. Oggi vol-targeta sulla vol
REALIZZATA 30g (backward-looking). Ipotesi: il DVOL (vol implicita 30g Deribit, forward-looking,
che spesso ANTICIPA i salti di vol realizzata) come denominatore del vol-target → de-risking più
tempestivo prima dei crash → hold-out migliore e/o DD più basso, SENZA peggiorare il FULL.
Onestà: DVOL parte 2021-03 → confronto TUTTE le varianti sulla FINESTRA COMUNE 2021-2026 (perdo
2018-2021, incluso il toro 2021 pre-DVOL). Baseline ricalcolato sulla stessa finestra. Hold-out 2025+.
Tutto causale (vol/segnale ≤ close[i]), fee 0.10% RT, long-flat, leva cap 2x — config CANONICA TP01.
VARIANTI (denominatore del vol-target):
REALIZED -> 30g realizzata (baseline canonica)
DVOL -> DVOL/100 (implicita)
BLEND -> 0.5·realizzata + 0.5·DVOL
MAX -> max(realizzata, DVOL) (sizing più difensivo: la più alta delle due)
DERISK -> realizzata, ma posizione ×0.5 quando DVOL > pctl espandente causale (gate crash)
uv run python scripts/research/tp01_dvol_overlay.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.data.downloader import load_data
from src.strategies.trend_portfolio import (
resample_1d, simple_returns, realized_vol, tsmom_blend, CANONICAL,
)
RAW = ROOT / "data" / "raw"
SQ = np.sqrt(365.25)
HOLDOUT = pd.Timestamp("2025-01-01", tz="UTC")
TGT_VOL = CANONICAL["target_vol"]; LEV = CANONICAL["leverage"]; FEE = CANONICAL["fee_side"]
HZ = CANONICAL["horizons_days"]; VW = CANONICAL["vol_win_days"]
def _components(asset: str):
df = resample_1d(load_data(asset, "1h"))
c = df["close"].values.astype(float)
idx = pd.to_datetime(df["datetime"])
if idx.dt.tz is None:
idx = idx.dt.tz_localize("UTC")
idx = pd.DatetimeIndex(idx) # tz-aware (UTC)
r = simple_returns(c)
rv = realized_vol(r, VW, 365.25) # 30g realizzata annualizzata (bpd=1)
direction = np.clip(tsmom_blend(c, HZ), 0, None) # long-flat
dv = pd.read_parquet(RAW / f"dvol_{asset.lower()}.parquet")
dser = pd.Series(dv["close"].values.astype(float) / 100.0,
index=pd.to_datetime(dv["timestamp"], unit="ms", utc=True)).sort_index()
dvol = dser.reindex(idx, method="ffill").values
return c, r, idx, rv, direction, dvol
def _net_returns(asset: str, mode: str, tvol: float = TGT_VOL) -> pd.Series:
c, r, idx, rv, direction, dvol = _components(asset)
derisk = np.ones(len(c))
if mode == "realized":
vol = rv
elif mode == "dvol":
vol = dvol
elif mode == "blend":
vol = 0.5 * rv + 0.5 * dvol
elif mode == "max":
vol = np.fmax(rv, dvol)
elif mode == "derisk":
vol = rv
# gate crash causale: DVOL sopra il suo percentile espandente (90%) -> dimezza l'esposizione
dd = pd.Series(dvol, index=idx)
rank = dd.expanding(min_periods=60).apply(lambda x: (x[:-1] < x[-1]).mean() if len(x) > 1 else 0.5, raw=True)
derisk = np.where(rank.values > 0.90, 0.5, 1.0)
else:
raise ValueError(mode)
with np.errstate(divide="ignore", invalid="ignore"):
scal = np.where((vol > 0) & np.isfinite(vol), tvol / vol, 0.0)
tgt = np.clip(direction * scal * derisk, -LEV, LEV)
tgt[~np.isfinite(tgt)] = 0.0
pos = np.zeros(len(tgt)); pos[1:] = tgt[:-1] # decisa a close[t-1], tenuta in t
gross = pos * r
turn = np.abs(np.diff(pos, prepend=0.0))
net = np.clip(gross - FEE * turn, -0.99, None); net[0] = 0.0
return pd.Series(net, index=idx)
def portfolio(mode: str, tvol: float = TGT_VOL) -> pd.Series:
b = _net_returns("BTC", mode, tvol); e = _net_returns("ETH", mode, tvol)
J = pd.concat({"B": b, "E": e}, axis=1, join="inner").fillna(0.0)
return 0.5 * J["B"] + 0.5 * J["E"]
def metrics(daily: pd.Series, lo=None) -> dict:
if lo is not None:
daily = daily[daily.index >= lo]
r = daily.values
sh = float(np.mean(r) / np.std(r) * SQ) if np.std(r) > 0 else 0.0
eq = np.cumprod(1.0 + r); pk = np.maximum.accumulate(eq)
dd = float(np.max((pk - eq) / pk)) if len(eq) else 0.0
yrs = (daily.index[-1] - daily.index[0]).days / 365.25 if len(daily) > 1 else 1.0
cagr = eq[-1] ** (1 / yrs) - 1 if yrs > 0 and len(eq) and eq[-1] > 0 else -1.0
s = pd.Series(eq, index=daily.index); yearly = {}
for y, g in s.groupby(s.index.year):
if len(g) > 1:
yearly[int(y)] = float(g.iloc[-1] / g.iloc[0] - 1)
return dict(sharpe=sh, dd=dd, cagr=cagr, tot=float(eq[-1] - 1) if len(eq) else 0.0, yearly=yearly)
def main():
modes = ["realized", "dvol", "blend", "max", "derisk"]
series = {m: portfolio(m) for m in modes}
# vero inizio DVOL (dove TUTTE le varianti hanno dati validi) — non il primo indice del prezzo
dstart = max(pd.read_parquet(RAW / f"dvol_{a.lower()}.parquet")["timestamp"].min() for a in ("BTC", "ETH"))
dstart = pd.Timestamp(dstart, unit="ms", tz="UTC") + pd.Timedelta(days=VW) # +warmup vol-win
series = {m: s[s.index >= dstart] for m, s in series.items()}
common = series["realized"].index
base = series["realized"]
print("=" * 96)
print(f" TP01 × DVOL — vol-target con denominatore di vol diverso. Finestra COMUNE "
f"{common[0].date()} -> {common[-1].date()} ({len(base)}g). Hold-out 2025+.")
print("=" * 96)
print(f" {'variante':<10} {'FULL Sh':>8} {'FULL DD':>8} {'CAGR':>7} | "
f"{'HOLD Sh':>8} {'HOLD ret':>9} {'HOLD DD':>8} | per-anno PnL")
for m in modes:
s = series[m]; f = metrics(s); h = metrics(s, lo=HOLDOUT)
ys = " ".join(f"{y}:{p*100:+.0f}" for y, p in sorted(f['yearly'].items()))
tag = " (baseline)" if m == "realized" else ""
print(f" {m:<10} {f['sharpe']:>+8.2f} {f['dd']*100:>7.1f}% {f['cagr']*100:>+6.0f}% | "
f"{h['sharpe']:>+8.2f} {h['tot']*100:>+8.1f}% {h['dd']*100:>7.1f}% | {ys}{tag}")
print("\n DELTA vs baseline (realized) sulla stessa finestra:")
bf = metrics(base); bh = metrics(base, lo=HOLDOUT)
for m in modes:
if m == "realized":
continue
f = metrics(series[m]); h = metrics(series[m], lo=HOLDOUT)
print(f" {m:<8}: ΔFULL Sh {f['sharpe']-bf['sharpe']:+.2f} ΔFULL DD {(f['dd']-bf['dd'])*100:+.1f}pp "
f"ΔHOLD Sh {h['sharpe']-bh['sharpe']:+.2f} ΔHOLD ret {(h['tot']-bh['tot'])*100:+.1f}pp")
print("\n CONTROLLO DECISIVO — il taglio di DD del DVOL è 'posizioni più piccole' o vero timing?")
print(" Confronto le varianti DVOL con il realized a target_vol RIDOTTO (stesso de-levering, ma")
print(" senza DVOL). Se realized-ridotto eguaglia/batte il DVOL a parità di DD → DVOL non aggiunge.")
for tv in (0.15, 0.13):
s = portfolio("realized", tvol=tv); s = s[s.index >= dstart]
f = metrics(s); h = metrics(s, lo=HOLDOUT)
print(f" realized @ vol-tgt {tv*100:.0f}%: FULL Sh {f['sharpe']:+.2f} DD {f['dd']*100:.1f}% "
f"CAGR {f['cagr']*100:+.0f}% | HOLD Sh {h['sharpe']:+.2f}")
mx = metrics(series["max"]); mxh = metrics(series["max"], lo=HOLDOUT)
print(f" (vs max-DVOL: FULL Sh {mx['sharpe']:+.2f} DD {mx['dd']*100:.1f}% "
f"CAGR {mx['cagr']*100:+.0f}% | HOLD Sh {mxh['sharpe']:+.2f})")
if __name__ == "__main__":
main()