Files
PythagorasGoal/Old/scripts/analysis/leverage_sweep.py
T
Adriano Dal Pastro 14522262e6 chore(reset): v2.0.0 — storico certificato Deribit mainnet, ripartenza pulita
Reset del progetto su fondamenta verificate dopo la scoperta che l'intera
libreria "validata OOS" era artefatto di feed contaminato (print fantasma del
feed Cerbero TESTNET + storico Binance/USDT).

- Storico ricostruito da Deribit MAINNET (ccxt pubblico, tokenless) e
  CERTIFICATO (certify_feed.py): BTC/ETH puliti su TUTTA la storia
  (mediana 2-6 bps vs Coinbase USD), integrita' OHLC + coerenza resample
  (maxΔ 0.00) + cross-venue OK. Alt esclusi (illiquidi/divergenti: LTC/DOGE
  50-82% barre flat; XRP/BNB non certificabili).
- Verdetto sul feed pulito: FADE / PAIRS / XS01 / TSM01 morti (ogni
  portafoglio Sharpe -2.3..-3.0, DD ~40%); solo SH01 e frammenti HONEST
  con segnale residuo, da ri-validare in isolamento.
- Cleanup "restart pulito": strategie, stack live (src/live, src/portfolio,
  runner/executor, yml, docker), ~100 script ricerca/gate, waste/games/
  portfolios, dati non certificati + cache e 60+ diari -> archiviati in Old/
  (preservati, non cancellati). Diario consolidato in un unico documento.
- Skeleton ricerca tenuto: Strategy ABC + indicatori + src/fractal +
  src/backtest/engine + load_data; tool dati certificati (rebuild_history,
  certify_feed, audit_feed, multi_source_check).
- Universo dati ATTIVO: solo BTC/ETH (5m/15m/1h); guardrail fisico
  (load_data su alt -> FileNotFoundError). Esecuzione DISABILITATA, conto flat.

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

245 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()