14522262e6
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>
245 lines
12 KiB
Python
245 lines
12 KiB
Python
"""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()
|