9d586eeb58
Angolo ESEGUIBILE (tocca il book live): DVOL (vol implicita forward-looking) come denominatore del vol-target invece della realizzata. Finestra comune 2021-2026. Le varianti DVOL abbassano DD (12.3->9.2%) ma anche Sharpe FULL (0.75->0.70) e CAGR. Controllo decisivo: realized @ vol-tgt 15% eguaglia quel DD (9.4%) a Sharpe piu' alto (0.75) -> il taglio di DD del DVOL e' solo DE-LEVERING, replicabile meglio con un target_vol piu' basso. Hold-out +0.06 = single-window (storia DVOL <5y), sotto la soglia multi-cut. Gate DVOL-spike ridondante col trend (TP01 gia' flat nei crash). Lezione: per meno DD sul live la leva e' target_vol, non un overlay DVOL. - scripts/research/tp01_dvol_overlay.py (realized/dvol/blend/max/derisk + controllo target_vol) - tests/test_tp01_dvol_overlay.py - docs/diary/2026-06-26-tp01-dvol-overlay.md - CLAUDE.md Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
160 lines
7.6 KiB
Python
160 lines
7.6 KiB
Python
"""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()
|