Files
PythagorasGoal/scripts/research/r0909_libro_nei_crolli.py
T
Adriano Dal Pastro bb40e87d13 crolli: il libro perde nel giorno e chiude > 0 in 8/8 finestre dal 2022 (SKH01 short); crollo catturato a vol bassa, XRP diluisce come SOL
Quattro misure (r0909_*), nessun cambio a libro/pesi/config:
- libro nei crolli: −0,48%/g nei 160 giorni ≤ −5%, positivo per finestra dal 2022
  per la gamba short di SKH01 (108% dei guadagni); beta 0,0769 riprodotto (§46);
  il peso di SKH01 resta chiuso dal gate (in-sample).
- crollo catturato 1-5/06/2026 a quote vere: f_net 0,74 = rally; put δ−0,10
  1,92× il modello; a vol bassa e fuori dal gate di VRP01 → §3 non si riapre.
- XRP terza gamba (harness r0822_sol_leg): hold-out −0,169 in 0/24, un anno buono.
- universo Deribit: liquidi solo BTC/ETH/XRP/SOL; XS01 13/19; BTCDVOL future
  non negoziabile; PAXG non misurato.
Revisione fable: due conclusioni smontate (MTM 2,6× era un artefatto di quote;
«8/12 guadagna» era l'ordine delle classi). Debito §5.18: cblib.spot_series
+ asof guarda un'ora avanti (feed 1h etichettato all'apertura).
Test Opus: 92 nuovi, suite 1008/1008; due difetti del verdetto XRP corretti.
Docs: diario, RESULTS §74-77, CLAUDE.md, memoria 20/50, README; journal 07-08/09.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017zJjHUS7mf4pnGE6pq9RTt
2026-09-09 16:33:54 +00:00

339 lines
18 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.
"""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()