diff --git a/scripts/analysis/leverage_sweep.py b/scripts/analysis/leverage_sweep.py new file mode 100644 index 0000000..b8b25cd --- /dev/null +++ b/scripts/analysis/leverage_sweep.py @@ -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 = " = 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 = " = 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()