"""r0909 — IL LIBRO NEI CROLLI: revisione dei sistemi adottati, misurata invece che ricordata. DOMANDA DELL'OPERATORE (2026-09-09): "trova una soluzione per guadagnare anche nei crolli". Prima di cercare un meccanismo nuovo, questo script misura cosa fanno OGGI i due sleeve live (TP01 long-flat 75% + SKH01 long/short 25%) nei crolli — giorno per giorno e per episodio — e da dove viene, se c'e', il guadagno. La memoria dice tre cose che qui vengono RIPRODOTTE (M23: far riprodurre alla macchina il numero vecchio prima di pubblicarne uno nuovo): (a) beta del libro al sottostante +0,076 (§46) → "non si assicura un libro gia' piatto"; (b) nel sinistro TP01 va flat e SKH01 si gira short e guadagna (§26); (c) il peggior giorno del libro: §33 lo trova in uno short squeeze di SKH01 (−15,77% di sleeve, 2020-03-13) sulla lente HOURLY — su questa lente (canonica, sleeve giornalieri) NON si riproduce: §33 stesso nota che la canonica da' il 2025-10-10. Qui si stampa e si dichiara. LENTE, dichiarata: sleeve di ricerca `sleeves._tp01_returns` / `_skyhook_returns` (ancora canonica 00:00 UTC, fee 0,10% RT, funding NON modellato, come ogni numero del progetto). ⚠️ SKH01 e' MARCATO ALL'USCITA del trade (`harness.backtest_signals` muove l'equity alla chiusura): la serie giornaliera e' zero nell'87,7% dei giorni, e uno short aperto in una finestra e chiuso fuori e' accreditato FUORI. E' il motivo per cui la tabella per GIORNO mostra SKH01 ≈ 0 e quella per FINESTRA mostra il guadagno. NON e' la serie del libro live (armato dal 2026-06-20): i crolli del 2026 (5 febbraio −14%/−15%, 1-5 giugno −25%/−30%) sono PRIMA dell'arming, quindi qui sono backtest, non fill. DEFINIZIONI, scritte prima di guardare i numeri: - indice = 50/50 BTC/ETH, ribilanciato ogni giorno (la stessa geometria del libro); - giorno di crollo = indice ≤ −5% (severo: ≤ −10%); - episodio = le 12 peggiori finestre di 20 giorni NON sovrapposte dell'indice, piu' un elenco NOMINATO di episodi noti (COVID, maggio 2021, LUNA, FTX, agosto 2024, febbraio e giugno 2026); - classi ESCLUSIVE, in quest'ordine: "immune" = |libro| < 1/10 dell'indice (col segno stampato); altrimenti "POSITIVO" se libro > 0, "PERDE" se < 0. Si stampa anche il conteggio nudo libro > 0. (Prima stesura: POSITIVO provato prima di immune → 8/12; con l'ordine dichiarato qui 4/12 + 6 immune. L'ordine delle regole ERA il verdetto: corretto in revisione, si stampano entrambi.) - beta al ribasso = pendenza OLS del libro sull'indice nei soli giorni con indice < 0. PESI: 75/25 e' decisione vincolante (confermata 3 volte, §3). Il 37,5/62,5 di §26 compare qui SOLO come riga informativa negli stessi episodi — non e' una proposta: la riapre solo `weights_tilt_null`, che finora fallisce. Nessun file di produzione toccato. Nessun ordine. """ 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.backtest.harness import backtest_signals # noqa: E402 from src.data.downloader import load_data # noqa: E402 from src.live.book import W_SKH, W_TP01 # noqa: E402 (i pesi si IMPORTANO, non si ridichiarano: P1) from src.portfolio.sleeves import _skyhook_returns, _tp01_returns # noqa: E402 from src.strategies.skyhook import SKH01_V2_DD, build_frames, skyhook_entries # noqa: E402 from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d # noqa: E402 ASSETS = ("BTC", "ETH") SOGLIA_CROLLO = -0.05 SOGLIA_SEVERO = -0.10 FINESTRA = 20 N_EPISODI = 12 BETA_MEMORIA = 0.076 # §46, da riprodurre SULLA SUA FINESTRA (DVOL: 2021-03-24+) FINESTRA_46 = pd.Timestamp("2021-03-24", tz="UTC") TOL_BETA = 0.01 W_INFO = (0.375, 0.625) # §26: riga informativa, NON proposta EPISODI_NOMINATI = { "COVID 2020-03": ("2020-03-01", "2020-03-20"), "maggio 2021": ("2021-05-08", "2021-05-24"), "LUNA 2022-05": ("2022-05-04", "2022-05-14"), "giugno 2022": ("2022-06-06", "2022-06-19"), "FTX 2022-11": ("2022-11-06", "2022-11-22"), "agosto 2024": ("2024-07-28", "2024-08-06"), "febbraio 2025": ("2025-01-31", "2025-03-11"), "5 febbraio 2026": ("2026-01-28", "2026-02-07"), "1-5 giugno 2026": ("2026-05-28", "2026-06-06"), } # ------------------------------------------------------------------ serie def indice_5050() -> pd.Series: r = {} for a in ASSETS: d = resample_1d(load_data(a, "1h")) s = pd.Series(d["close"].values.astype(float), index=pd.to_datetime(d["datetime"], utc=True)) r[a] = s.pct_change() J = pd.concat(r, axis=1, join="inner").dropna() return 0.5 * J["BTC"] + 0.5 * J["ETH"] def tp01_esposizione() -> pd.Series: """Esposizione lorda media (BTC+ETH)/2 tenuta nel giorno (decisa a close[i-1]).""" tp = TrendPortfolio(**CANONICAL) e = {} for a in ASSETS: d = resample_1d(load_data(a, "1h")) tgt = tp.target_series(d) held = np.zeros(len(tgt)); held[1:] = tgt[:-1] e[a] = pd.Series(held, index=pd.to_datetime(d["datetime"], utc=True)) J = pd.concat(e, axis=1, join="inner") return 0.5 * J["BTC"] + 0.5 * J["ETH"] def skh_per_direzione() -> dict[str, pd.Series]: """SKH01-V2-DD 50/50 su griglia giornaliera, in tre versioni: intera, SOLO long, SOLO short. Le versioni per direzione filtrano gli entry-dict prima del backtest: stesso motore, stessi stop. In principio filtrare potrebbe cambiare il non-overlap; MISURATO (revisione 09/09): long + short == intera su 2735/2735 giorni (max|diff| 3,5e-18) — la scomposizione e' ESATTA, e `misura()` lo verifica con un assert (P1: la versione "tutto" e' confrontata bit-exact con `sleeves._skyhook_returns`). """ out = {k: {} for k in ("tutto", "long", "short")} for a in ASSETS: ltf, htf = build_frames(load_data(a, "5m")) ent = skyhook_entries(ltf, htf, SKH01_V2_DD) for k, keep in (("tutto", (1, -1)), ("long", (1,)), ("short", (-1,))): e2 = [x if (x is not None and x["dir"] in keep) else None for x in ent] m = backtest_signals(ltf, e2, fee_rt=0.001, leverage=1.0, asset=a, tf="230m") s = pd.Series(m.equity, index=pd.DatetimeIndex(pd.to_datetime(m.eq_index, utc=True))) out[k][a] = s.resample("1D").last().ffill().pct_change().dropna() res = {} for k in out: J = pd.concat(out[k], axis=1, join="inner").fillna(0.0) res[k] = 0.5 * J["BTC"] + 0.5 * J["ETH"] return res # ------------------------------------------------------------------ misure def sh(s: pd.Series) -> float: s = s.dropna() return float(s.mean() / s.std() * np.sqrt(365.0)) if len(s) > 5 and s.std() > 0 else float("nan") def maxdd(s: pd.Series) -> float: eq = (1 + s.fillna(0)).cumprod(); return float((eq / eq.cummax() - 1).min()) def cum(s: pd.Series) -> float: return float((1 + s.fillna(0)).prod() - 1) def finestre_peggiori(idx: pd.Series, n: int = N_EPISODI, w: int = FINESTRA) -> list[tuple]: """Le n peggiori finestre di w giorni NON sovrapposte dell'indice (greedy dal peggiore).""" roll = (1 + idx).rolling(w).apply(np.prod, raw=True) - 1 roll = roll.dropna().sort_values() prese, occupati = [], [] for end, v in roll.items(): start = end - pd.Timedelta(days=w - 1) if any(not (end < s or start > e) for s, e in occupati): continue prese.append((start, end, float(v))); occupati.append((start, end)) if len(prese) >= n: break return sorted(prese) def classifica(libro: float, indice: float) -> str: """Classi esclusive nell'ordine dichiarato: immune (col segno) → POSITIVO → PERDE.""" if abs(libro) < abs(indice) / 10: return "immune+" if libro > 0 else "immune-" return "POSITIVO" if libro > 0 else "PERDE" def beta_ribasso(libro: pd.Series, idx: pd.Series, soglia: float = 0.0) -> float: J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna() J = J[J["i"] < soglia] if len(J) < 20: return float("nan") x, y = J["i"].values, J["b"].values return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1)) def beta_pieno(libro: pd.Series, idx: pd.Series) -> float: J = pd.concat({"b": libro, "i": idx}, axis=1, join="inner").dropna() x, y = J["i"].values, J["b"].values return float(np.cov(x, y)[0, 1] / np.var(x, ddof=1)) def misura() -> dict: idx = indice_5050() tp = _tp01_returns() if tp.index.tz is None: tp.index = tp.index.tz_localize("UTC") skh = skh_per_direzione() esp = tp01_esposizione() J = pd.concat({"idx": idx, "tp": tp, "skh": skh["tutto"], "skl": skh["long"], "sks": skh["short"], "esp": esp}, axis=1, join="inner").dropna() ref = _skyhook_returns() if ref.index.tz is None: ref.index = ref.index.tz_localize("UTC") comune = J.index.intersection(ref.index) assert float((J.loc[comune, "skh"] - ref.loc[comune]).abs().max()) == 0.0, "SKH01 'tutto' non e' bit-exact col sleeve" assert float((J["skl"] + J["sks"] - J["skh"]).abs().max()) < 1e-12, "long + short != intera: la scomposizione non e' esatta" J["libro"] = W_TP01 * J["tp"] + W_SKH * J["skh"] J["info"] = W_INFO[0] * J["tp"] + W_INFO[1] * J["skh"] J["c_tp"] = W_TP01 * J["tp"] J["c_skh"] = W_SKH * J["skh"] J["c_sks"] = W_SKH * J["sks"] # giorni di crollo giorni = {} for nome, soglia in (("≤ −5%", SOGLIA_CROLLO), ("≤ −10%", SOGLIA_SEVERO)): g = J[J["idx"] <= soglia] giorni[nome] = dict(n=int(len(g)), idx=float(g["idx"].mean()), libro=float(g["libro"].mean()), tp=float(g["c_tp"].mean()), skh=float(g["c_skh"].mean()), sks=float(g["c_sks"].mean()), esp=float(g["esp"].mean()), libro_pos=float((g["libro"] > 0).mean()), per_anno={int(y): dict(n=int(len(gg)), idx=float(gg["idx"].sum()), libro=float(gg["libro"].sum()), sks=float(gg["c_sks"].sum())) for y, gg in g.groupby(g.index.year)}) # episodi: finestre peggiori + nominati def riga_ep(nome, s, e): w = J.loc[s:e] if len(w) < 3: return None return dict(nome=nome, da=str(w.index[0].date()), a=str(w.index[-1].date()), n=int(len(w)), idx=cum(w["idx"]), libro=cum(w["libro"]), tp=cum(w["c_tp"]), skh=cum(w["c_skh"]), sks=cum(w["c_sks"]), skl=cum(W_SKH * w["skl"]), esp=float(w["esp"].mean()), info=cum(w["info"]), classe=classifica(cum(w["libro"]), cum(w["idx"]))) ep_auto = [r for r in (riga_ep(f"peggiore #{i+1}", s, e) for i, (s, e, _) in enumerate(finestre_peggiori(J["idx"]))) if r] ep_nom = [r for r in (riga_ep(k, pd.Timestamp(a, tz="UTC"), pd.Timestamp(b, tz="UTC")) for k, (a, b) in EPISODI_NOMINATI.items()) if r] # peggior giorno del libro (riproduce §33: e' uno squeeze, non un crollo?) peggiori = J.nsmallest(5, "libro")[["idx", "libro", "c_tp", "c_skh"]] n_auto = len(ep_auto) guad = sum(r["classe"] == "POSITIVO" for r in ep_auto) imm = sum(r["classe"].startswith("immune") for r in ep_auto) pos = sum(r["libro"] > 0 for r in ep_auto) dal22 = [r for r in ep_auto if r["da"] >= "2022"] J46 = J[J.index >= FINESTRA_46] quota_short = (sum(r["sks"] for r in ep_auto if r["libro"] > 0) / max(1e-12, sum(r["libro"] for r in ep_auto if r["libro"] > 0))) return dict( finestra=(str(J.index[0].date()), str(J.index[-1].date()), int(len(J))), beta=beta_pieno(J["libro"], J["idx"]), beta_46=beta_pieno(J46["libro"], J46["idx"]), beta_giu=beta_ribasso(J["libro"], J["idx"]), beta_giu3=beta_ribasso(J["libro"], J["idx"], -0.03), sharpe=dict(libro=sh(J["libro"]), info=sh(J["info"]), tp=sh(J["tp"]), skh=sh(J["skh"]), sks=sh(J["sks"]), skl=sh(J["skl"])), maxdd=dict(libro=maxdd(J["libro"]), info=maxdd(J["info"]), idx=maxdd(J["idx"])), giorni=giorni, episodi=ep_auto, nominati=ep_nom, peggiori=[(str(t.date()), float(r.idx), float(r.libro), float(r.c_tp), float(r.c_skh)) for t, r in peggiori.iterrows()], sks_anno={int(y): cum(g) for y, g in J["c_sks"].groupby(J.index.year)}, sintesi=dict(n_episodi=n_auto, guadagna=guad, immune=imm, perde=n_auto - guad - imm, positivi=pos, dal22=(sum(r["libro"] > 0 for r in dal22), len(dal22)), giorni_pos=giorni["≤ −5%"]["libro_pos"], giorni_libro=giorni["≤ −5%"]["libro"], quota_short_nei_guadagni=float(quota_short), info_batte_live=sum(r["info"] > r["libro"] for r in ep_auto)), ) def verdetto(m: dict) -> str: """Tre affermazioni separate, perche' rispondono a tre domande diverse: (1) nel GIORNO del crollo (media sui giorni ≤ −5%); (2) per FINESTRA (classi esclusive); (3) da dove viene il guadagno. Un solo aggettivo ("guadagna"/"immune") sarebbe l'ordine delle regole.""" s = m["sintesi"] beta_ok = abs(m["beta_46"] - BETA_MEMORIA) < TOL_BETA giorno = ("PERDE" if s["giorni_libro"] < 0 else "GUADAGNA") if s["guadagna"] >= 2 * s["n_episodi"] / 3: finestra = "POSITIVO per finestra" elif s["guadagna"] + s["immune"] >= 2 * s["n_episodi"] / 3: finestra = "IMMUNE-O-POSITIVO per finestra" else: finestra = "PERDE per finestra" return (f"NEL GIORNO DEL CROLLO IL LIBRO {giorno} ({s['giorni_libro']*100:+.2f}%/g, positivo nel {s['giorni_pos']*100:.0f}% dei giorni ≤ −5%); " f"{finestra}: {s['guadagna']} POSITIVO / {s['immune']} immune / {s['perde']} PERDE su {s['n_episodi']} (libro > 0 in {s['positivi']}, " f"dal 2022 in {s['dal22'][0]}/{s['dal22'][1]}); beta {m['beta_46']:+.4f} sulla finestra di §46 " f"({'riproduce' if beta_ok else 'NON riproduce'} il +{BETA_MEMORIA:.3f}); " f"la gamba short di SKH01 (marcata all'uscita) fa il {s['quota_short_nei_guadagni']*100:.0f}% dei guadagni negli episodi con libro > 0") # ------------------------------------------------------------------ stampa def pct(x: float) -> str: return f"{x*100:+.2f}%" def main() -> None: print("=" * 100) print(" r0909 — IL LIBRO NEI CROLLI (TP01 75 / SKH01 25, lente di ricerca, ancora canonica)") print("=" * 100) m = misura() a, b, n = m["finestra"] print(f" finestra {a} → {b} ({n} giorni) pesi importati da src/live/book: TP01 {W_TP01} / SKH01 {W_SKH}") print("\n [1] BETA DEL LIBRO ALL'INDICE 50/50") print(f" pieno {m['beta']:+.3f} · sulla finestra di §46 (2021-03-24+) {m['beta_46']:+.4f} (memoria: +{BETA_MEMORIA:.3f}) " f"al ribasso (idx<0) {m['beta_giu']:+.3f} nei giorni ≤ −3% {m['beta_giu3']:+.3f}") print("\n [2] GIORNI DI CROLLO — media per giorno") print(f" {'soglia':<8}{'n':>5}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}{'SKH short':>11}{'esp TP01':>10}{'libro>0':>9}") for k, g in m["giorni"].items(): print(f" {k:<8}{g['n']:>5}{pct(g['idx']):>9}{pct(g['libro']):>9}{pct(g['tp']):>9}" f"{pct(g['skh']):>9}{pct(g['sks']):>11}{g['esp']:>10.3f}{g['libro_pos']*100:>8.0f}%") print("\n per anno (giorni ≤ −5%, SOMMA nel gruppo):") print(f" {'anno':<6}{'n':>4}{'indice':>10}{'libro':>10}{'SKH short':>11}") for y, g in m["giorni"]["≤ −5%"]["per_anno"].items(): print(f" {y:<6}{g['n']:>4}{pct(g['idx']):>10}{pct(g['libro']):>10}{pct(g['sks']):>11}") def tab(rows, titolo): print(f"\n {titolo}") print(f" {'episodio':<18}{'da':>11}{'a':>11}{'indice':>9}{'libro':>9}{'TP01':>8}{'SKH01':>8}" f"{'SKH-S':>8}{'SKH-L':>8}{'esp':>6}{'37/63':>8} classe") for r in rows: print(f" {r['nome']:<18}{r['da']:>11}{r['a']:>11}{pct(r['idx']):>9}{pct(r['libro']):>9}" f"{pct(r['tp']):>8}{pct(r['skh']):>8}{pct(r['sks']):>8}{pct(r['skl']):>8}{r['esp']:>6.2f}" f"{pct(r['info']):>8} {r['classe']}") tab(m["episodi"], f"[3] LE {N_EPISODI} PEGGIORI FINESTRE DI {FINESTRA} GIORNI DELL'INDICE (non sovrapposte)") tab(m["nominati"], "[4] EPISODI NOMINATI (elenco scritto prima di misurare)") print("\n [5] I 5 PEGGIORI GIORNI DEL LIBRO (§33 su lente hourly: squeeze SKH01 2020-03-13; qui lente canonica: NON si riproduce, e' un crollo con TP01 mezzo long)") print(f" {'giorno':<12}{'indice':>9}{'libro':>9}{'TP01':>9}{'SKH01':>9}") for t, i, l, tpv, sk in m["peggiori"]: print(f" {t:<12}{pct(i):>9}{pct(l):>9}{pct(tpv):>9}{pct(sk):>9}") print("\n [6] LA GAMBA SHORT DI SKH01 (peso 0,25 gia' applicato) — contributo per anno al libro") print(" " + " ".join(f"{y} {pct(v)}" for y, v in m["sks_anno"].items())) s = m["sharpe"]; d = m["maxdd"] print(f"\n Sharpe: libro {s['libro']:.2f} · TP01 {s['tp']:.2f} · SKH01 {s['skh']:.2f} " f"(solo long {s['skl']:.2f} / solo short {s['sks']:.2f}) maxDD libro {d['libro']*100:.1f}% · indice {d['idx']*100:.1f}%") print(f" riga INFORMATIVA 37,5/62,5 (§26, NON proposta): Sharpe {s['info']:.2f}, maxDD {d['info']*100:.1f}%, " f"batte il 75/25 in {m['sintesi']['info_batte_live']}/{m['sintesi']['n_episodi']} episodi") print("\n VERDETTO: " + verdetto(m)) print("=" * 100) if __name__ == "__main__": main()