feat(analysis): leverage_sweep — sweep leva 1-10, stress 2022, leva max raccomandata

Risponde a "se porto a leva 10 cosa succede?". Sweep leva 1->10 su PORT06
(modello daily lineare == live) FULL/OOS + anno per anno (2021-2026), con le
non-linearita' che il modello daily NASCONDE: volatility drag/Kelly, rovina
intraday vs daily. Stress con shock REALI 2022 (BTC/ETH storici: LUNA -29/-36%,
3AC-giugno -44/-52%, FTX -26/-32%) sull'esposizione netta del book -> leva di
rovina per net-long beta. Verdetto: leva 10 NON sopravvive a un 2022-repeat
(rovina gia' a ~5-9x se net-long). Leva MAX raccomandata ~3x (DD<=50% recuperabile,
beta=0.6) == config live attuale. Non modifica nulla del live.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-06-18 09:48:33 +00:00
parent c8b956ab56
commit 57758ffcb1
+244
View File
@@ -0,0 +1,244 @@
"""LEVERAGE SWEEP — cosa succede a PORT06 con leva 1..10 nei vari scenari (2026-06-18).
Domanda utente: "se porto a leva 10 cosa succede?". Estende lev_frontier() di
accel50_research.py da 1-6 a 1-10 e su PIU' scenari, e soprattutto aggiunge le
NON-LINEARITA' che il modello daily-lineare NASCONDE e che mordono a leva alta:
1) DRAWDOWN: nel modello daily scala ~lineare con la leva (DD_L ~ DD_1 * L) fino
alla rovina. Ma e' un DD su daily AGGREGATI: nasconde le escursioni intraday.
2) VOLATILITY DRAG: la crescita GEOMETRICA (CAGR) e' concava nella leva: cresce,
ha un massimo (~leva di Kelly), poi CROLLA. Oltre Kelly piu' leva = MENO ritorno
E piu' rischio. Con questa serie ad altissimo Sharpe il picco e' lontano -> il
modello fa sembrare la leva "gratis": e' l'illusione da smontare.
3) ROVINA / LIQUIDAZIONE: equity non puo' andare sotto zero. A leva L un ritorno di
periodo r da' (1 + L*r); se L*r <= -1 -> conto azzerato. Il modello daily lo
vede solo sul worst-day aggregato (docile); la realta' e' INTRADAY (gap, flash
crash, maintenance-margin Deribit) -> la leva di rovina REALE e' molto piu' bassa.
Modello base: scala lineare dei daily return canonici (== live, parita' validata),
`base` corrisponde a leva 2 (come in accel50). Per leva L: r_L = base * (L/2).
uv run python scripts/analysis/leverage_sweep.py
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
import pandas as pd
PROJECT_ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(PROJECT_ROOT))
def _load_base() -> tuple[pd.Series, int]:
"""Daily return PORT06 canonici (== live a leva 2) + indice di split OOS."""
from scripts.portfolios._defs import PORTFOLIOS
from scripts.analysis.combine_portfolio import port_returns, SPLIT
from src.portfolio.sleeves import all_sleeve_equities, sleeve_returns_df
p = PORTFOLIOS["PORT06"]
eq = all_sleeve_equities()
members = {sid: eq[sid] for sid in p.sleeve_ids}
w = p.weight_vector(sleeve_returns_df(p.sleeve_ids))
return port_returns(members, w), SPLIT
def _metrics(r: pd.Series) -> dict:
"""CAGR geometrica, maxDD, Sharpe, worst-day, equity finale e flag rovina.
Clip a -100%/giorno: oltre, il conto e' azzerato (rovina) e resta a zero."""
ruined = bool((r <= -1.0).any())
rc = r.clip(lower=-1.0) # un -100% azzera; non si recupera da zero
curve = (1 + rc).cumprod()
if ruined: # congela a zero dal primo azzeramento
first = int(np.argmax(rc.values <= -1.0))
curve.iloc[first:] = 0.0
years = len(r) / 365.0
final = float(curve.iloc[-1])
cagr = (final ** (1 / years) - 1) * 100 if final > 0 else -100.0
peak = curve.cummax()
dd = float(((curve - peak) / peak).min() * 100)
sharpe = float(r.mean() / r.std() * np.sqrt(365)) if r.std() > 0 else 0.0
simple = (final - 1) * 100 if final > 0 else -100.0 # ritorno totale intra-periodo
return {"cagr": cagr, "cagr_simple": simple, "dd": dd, "sharpe": sharpe,
"worst": float(r.min() * 100), "final": final, "ruined": ruined}
def sweep(base: pd.Series, split: int) -> None:
full, oos = base, base.iloc[split:]
print(f"PORT06 daily — FULL {base.index[0].date()}{base.index[-1].date()} "
f"({len(full)}g) | OOS {base.index[split].date()}→ ({len(oos)}g, regime CALMO)")
print(f" worst-day base(leva2) = {base.min()*100:+.2f}% | Sharpe leva-invariante "
f"(modello lineare): FULL {_metrics(full).get('sharpe'):.2f} / OOS {_metrics(oos).get('sharpe'):.2f}")
print()
hdr = ("leva CAGR_full% DD_full% x_full CAGR_oos% DD_oos% "
"worstday% anni→€50/g(2k) rovina?")
print(hdr); print("-" * len(hdr))
for L in range(1, 11):
f = L / 2.0
mf, mo = _metrics(full * f), _metrics(oos * f)
# anni a €50/g da €2000, su CAGR OOS geometrica (come accel50)
co = mo["cagr"]
if co > 0 and mo["final"] > 0:
k = 2020 * (50 / (2020 * ((1 + co/100) ** (1/365) - 1))) # capitale per €50/g
anni = np.log(k / 2020) / np.log(1 + co / 100)
anni_s = f"{max(anni,0):.1f}"
else:
anni_s = ""
flag = "💀 RUIN" if (mf["ruined"] or mo["ruined"]) else ("⚠ alto" if mf["dd"] < -25 else "ok")
print(f"{L:>3} {mf['cagr']:>9.0f} {mf['dd']:>8.1f} {mf['final']:>7.1f}x "
f"{mo['cagr']:>9.0f} {mo['dd']:>8.1f} {mf['worst']:>8.2f} "
f"{anni_s:>12} {flag}")
def kelly_and_ruin(base: pd.Series, split: int) -> None:
"""Leva di Kelly (picco crescita geometrica) e leve di rovina sotto shock realistici."""
print("\n=== NON-LINEARITA' che il modello daily nasconde ===\n")
# Kelly: f* (in unita' di leva) che massimizza E[log(1+ (L/2)*base)]
grid = np.linspace(0.1, 60, 600)
def glog(L, r):
rr = (r * (L / 2.0)).clip(lower=-0.999)
return np.mean(np.log1p(rr))
for label, r in [("FULL", base), ("OOS-calmo", base.iloc[split:])]:
gl = [glog(L, r) for L in grid]
kstar = grid[int(np.argmax(gl))]
print(f" Kelly {label:9}: leva ottimale ~{kstar:.0f}x "
f"(oltre, piu' leva = MENO crescita geometrica). "
f"Half-Kelly prudente ~{kstar/2:.0f}x")
print("\n -> Sharpe altissimo + regime OOS calmo spingono Kelly a leve assurde:")
print(" e' l'ARTEFATTO del backtest, NON un via libera. Il drag morde tardi.")
print("\n=== ROVINA / LIQUIDAZIONE (il rischio VERO, intraday) ===")
mday = base.min() / 2.0 # worst-day a leva 1
print(f" worst-DAY storico (leva1) = {mday*100:+.2f}% -> rovina daily a leva "
f"{-1/mday:.0f}x (irrealistico: aggregato/diversificato)")
# shock INTRADAY realistici sull'esposizione NETTA del book reale
# 7 sleeve equal-weight, position_size 0.5 -> esposizione lorda per leva = L*0.5
# (frazione del capitale a mercato); un crash correlato colpisce quella frazione.
ps = 0.5
print(f" Config live: 7 sleeve EW, position_size={ps} -> esposizione ~ leva×{ps} "
f"del capitale (a leva 3 = 1.5x; a leva 10 = 5x).")
for shock in (0.10, 0.20, 0.40):
Lruin = 1.0 / (shock * ps)
print(f" crash correlato intraday {shock*100:>4.0f}% sull'esposizione: "
f"azzera il conto a leva ~{Lruin:.0f}x "
f"(disaster-SL on-book a -30%/pos mitiga ma NON in un gap-through)")
print("\n NB: il backtest e' su daily AGGREGATI di un paniere con stop in regime "
"calmo. Sottostima la coda intraday, i gap, lo slippage a size grande e la "
"maintenance-margin Deribit (che liquida PRIMA del -100% del modello).")
def per_year(base: pd.Series) -> None:
"""Sweep leva 1→10 ANNO PER ANNO: la media FULL nasconde l'anno peggiore.
Per ogni anno civile: ritorno geometrico e maxDD INTRA-anno ad ogni leva."""
years = sorted({d.year for d in base.index})
levs = list(range(1, 11))
print("\n=== ANNO PER ANNO (ritorno % geometrico intra-anno) ===")
print("anno g wDay@L1 " + "".join(f"L{L:>2} " for L in levs))
print("-" * (24 + 9 * len(levs)))
worst_dd = {}
for y in years:
r = base[base.index.year == y]
cells = []
for L in levs:
m = _metrics(r * (L / 2.0))
cells.append("RUIN" if m["ruined"] else f"{m['cagr_simple']:+.0f}")
worst_dd.setdefault(L, []).append((y, _metrics(r * (L / 2.0))["dd"]))
wd = r.min() / 2.0 * 100
tag = " <calmo" if y >= 2025 else ""
print(f"{y} {len(r):>3} {wd:>6.2f}% " + "".join(f"{c:>7} " for c in cells) + tag)
print("\n=== ANNO PER ANNO (maxDD % intra-anno) ===")
print("anno g wDay@L1 " + "".join(f"L{L:>2} " for L in levs))
print("-" * (24 + 9 * len(levs)))
for y in years:
r = base[base.index.year == y]
cells = []
for L in levs:
m = _metrics(r * (L / 2.0))
cells.append("RUIN" if m["ruined"] else f"{m['dd']:.1f}")
wd = r.min() / 2.0 * 100
tag = " <calmo" if y >= 2025 else ""
print(f"{y} {len(r):>3} {wd:>6.2f}% " + "".join(f"{c:>7} " for c in cells) + tag)
# anno peggiore per DD ad ogni leva
print("\n=== ANNO PEGGIORE per drawdown, ad ogni leva ===")
for L in levs:
yw, ddw = min(worst_dd[L], key=lambda t: t[1])
print(f" leva {L:>2}: worst-anno {yw} maxDD {ddw:.1f}%"
+ (" 💀 RUIN in qualche anno" if any(_metrics(base[base.index.year == yy] * (L/2.0))["ruined"] for yy in years) else ""))
def shock_2022() -> None:
"""STRESS con gli shock REALI 2022 (LUNA/3AC/FTX) sull'esposizione netta del book.
Smonta il fill-al-livello del backtest daily: le fade+DIP01 COMPRANO i dip -> in un
crollo sono net-LONG (catturate dalla parte sbagliata). Perdita = netta×shock; rovina
a L_ruin = 1/(beta·ps·shock). I dati sono BTC/ETH 1h storici nostri, anno 2022."""
from src.data.downloader import load_data
print("\n=== STRESS SHOCK REALE 2022 (BTC/ETH storici, fill al GAP non al livello) ===")
res = {}
for a in ("BTC", "ETH"):
df = load_data(a, "1h").set_index("datetime")
d = df[(df.index >= "2022-01-01") & (df.index < "2023-01-01")]
g = d.resample("1D").agg(open=("open", "first"), high=("high", "max"),
low=("low", "min"), close=("close", "last")).dropna()
res[a] = {"c2c": g["close"].pct_change().min(),
"o2l": (g["low"] / g["open"] - 1).min(),
"h1": d["close"].pct_change().min()}
print(f" {a}: worst-day {res[a]['c2c']*100:+.1f}% | worst intraday "
f"{res[a]['o2l']*100:+.1f}% | worst 1h-candle {res[a]['h1']*100:+.1f}%")
print(" crolli multi-giorno (close→low): LUNA BTC-29%/ETH-36% · 3AC-giu BTC-44%/ETH-52%"
" · FTX BTC-26%/ETH-32%")
ps = 0.5
scen = {"ETH intraday (-27%)": 0.267, "BTC stretch giu (-44%)": 0.44,
"ETH stretch giu (-52%)": 0.52}
print("\n LEVA DI ROVINA per net-exposure beta (ps=0.5):")
print(f" {'scenario':24}" + "".join(f" b={b}" for b in (0.3, 0.5, 0.7, 1.0)))
for name, dlt in scen.items():
row = f" {name:24}"
for b in (0.3, 0.5, 0.7, 1.0):
L = 1.0 / (b * ps * dlt)
row += f" {L:>4.1f}" + ("!" if L < 10 else " ")
print(row)
print(" ('!' = conto azzerato a leva <10). Le fade fadano = net-long nel crollo "
"(beta alto proprio quando è pericoloso); il disaster-SL -30% NON scatta sotto "
"-30% di mossa singola -> 2022 (max 1h -22%) il book mangia tutto.")
def recommended_leverage() -> None:
"""Leva MAX raccomandata data-driven: quella che tiene il DD di un 2022-repeat sotto
una soglia recuperabile, dato un net-long realistico. Book-shock pesato sulla
composizione reale (4 sleeve BTC + 3 ETH). Vincolo = capitulation peggiore (giugno)."""
from src.data.downloader import load_data
wB, wE, ps = 4 / 7, 3 / 7, 0.5
def stretch(a, s, e):
df = load_data(a, "1h").set_index("datetime")
g = df[(df.index >= s) & (df.index <= e)]
return g["low"].min() / g["open"].iloc[0] - 1
june = wB * stretch("BTC", "2022-06-10", "2022-06-19") + wE * stretch("ETH", "2022-06-10", "2022-06-19")
print("\n=== LEVA MAX RACCOMANDATA (vincolo: sopravvivi a un 2022-repeat) ===")
print(f" book-shock pesato (BTC {wB:.0%}/ETH {wE:.0%}), capitulation giugno = {june*100:.0f}%")
print(f" {'tolleranza DD nel 2022-repeat':30} beta=0.5 beta=0.6 beta=0.7")
for label, dd in [("30% conservativo", .30), ("50% recuperabile", .50),
("70% pre-liquidazione", .70)]:
row = f" {label:30}"
for b in (0.5, 0.6, 0.7):
row += f" {dd/(b*ps*abs(june)):6.1f}x"
print(row)
rec = 0.50 / (0.6 * ps * abs(june))
print(f"\n >>> RACCOMANDATA ~{rec:.1f}x (beta=0.6, DD<=50% recuperabile) -> PRUDENTE {int(rec)}x.")
print(f" A leva 3 (attuale): DD 2022-repeat ~{0.6*ps*3*abs(june)*100:.0f}% (recuperabile). "
f"Leva 5 ~{0.6*ps*5*abs(june)*100:.0f}% (pre-liquidazione). Leva 10 = rovina.")
if __name__ == "__main__":
base, split = _load_base()
print("=== SWEEP LEVA 1→10 su PORT06 (modello daily lineare, == live) ===\n")
sweep(base, split)
per_year(base)
kelly_and_ruin(base, split)
shock_2022()
recommended_leverage()