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
This commit is contained in:
Adriano Dal Pastro
2026-09-09 16:33:54 +00:00
parent 9f55652d99
commit bb40e87d13
16 changed files with 3138 additions and 10 deletions
+385
View File
@@ -0,0 +1,385 @@
"""Test di `scripts/research/r0909_libro_nei_crolli.py` (2026-09-09) — il libro nei crolli.
Cosa protegge, in due strati:
* il MECCANISMO su dati SINTETICI, dove la risposta e' nota per costruzione — le classi
ESCLUSIVE col loro ordine, le finestre peggiori non sovrapposte, il beta con pendenza
imposta, i rami del verdetto. Qui un test rosso significa "il codice ha cambiato
comportamento", mai "il mercato ha cambiato idea".
* la RIPRODUZIONE del risultato pubblicato il 09/09 sui dati veri: 4 POSITIVO / 6 immune /
2 PERDE su 12 finestre, libro > 0 in 8 (dal 2022: 8/8), e beta +0,0769 sulla finestra di
§46 che riproduce il +0,076 della memoria. E' M23: prima di pubblicare un numero nuovo, la
macchina deve ristampare quello vecchio.
🚨 LA REGRESSIONE PIU' IMPORTANTE DI QUESTO FILE. `classifica()` prova **immune PRIMA di
POSITIVO**, e l'ordine non e' un dettaglio: con l'ordine opposto lo stesso identico dato dava
"8/12 episodi in utile" invece di "4 POSITIVO / 6 immune". *L'ordine delle regole ERA il
verdetto* (corretto in revisione il 09/09). Un test lo congela.
⚠️ P1: i pesi del libro NON si ridichiarano — un test congela l'identita' fra i pesi usati dallo
script e `src.live.book.W_TP01` / `W_SKH`.
"""
from __future__ import annotations
import inspect
import re
import sys
from pathlib import Path
import numpy as np
import pandas as pd
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "scripts" / "research"))
import r0909_libro_nei_crolli as M # noqa: E402
RAW = ROOT / "data" / "raw"
def _feed_certificato_presente() -> bool:
return all((RAW / f"{a}_{tf}.parquet").exists()
for a in ("btc", "eth") for tf in ("1h", "5m"))
def _giorni(n: int, inizio: str = "2021-01-01") -> pd.DatetimeIndex:
return pd.date_range(inizio, periods=n, freq="D", tz="UTC")
# ===========================================================================================
# classifica() — classi ESCLUSIVE, e l'ORDINE in cui si provano
# ===========================================================================================
def test_classifica_prova_IMMUNE_PRIMA_di_POSITIVO():
"""🚨 La regressione del 09/09. Sugli stessi numeri l'ordine "POSITIVO prima" dava 8/12
episodi in utile; l'ordine dichiarato (immune prima) da' 4 POSITIVO + 6 immune.
DEVE FALLIRE SE: qualcuno rimette il test del segno davanti alla banda di immunita'
non e' un refactor, e' un altro verdetto."""
# peggiore #8 del 09/09: +0,21% di libro contro 26,54% di indice. E' dentro un decimo.
assert M.classifica(0.0021, -0.2654) == "immune+"
# peggiore #5: +4,26% contro 31,61% -> fuori dalla banda, e allora e' POSITIVO
assert M.classifica(0.0426, -0.3161) == "POSITIVO"
def test_classifica_immune_porta_il_SEGNO_con_se():
""""immune" non e' una classe cieca: +0,21% e 0,52% su un crollo del 26% sono entrambi
immunita', ma non sono la stessa notizia. DEVE FALLIRE SE: le due si fondono in una."""
assert M.classifica(0.0021, -0.2654) == "immune+"
assert M.classifica(-0.0052, -0.2583) == "immune-"
assert set(M.classifica(x, -0.30) for x in (0.001, -0.001)) == {"immune+", "immune-"}
def test_classifica_fuori_dalla_banda_resta_il_segno():
assert M.classifica(+0.05, -0.30) == "POSITIVO"
assert M.classifica(-0.05, -0.30) == "PERDE"
assert M.classifica(-0.0637, -0.3411) == "PERDE" # peggiore #1 del 09/09
def test_classifica_il_confine_della_banda_e_STRETTO():
"""Esattamente un decimo NON e' immune: il confronto e' `<`, non `<=`.
DEVE FALLIRE SE: qualcuno lo rilassa e sposta episodi dentro la banda."""
assert M.classifica(-0.010, -0.10) == "PERDE"
assert M.classifica(-0.010000001, -0.10) == "PERDE"
assert M.classifica(-0.009999999, -0.10) == "immune-"
assert M.classifica(+0.010, -0.10) == "POSITIVO"
assert M.classifica(+0.009999999, -0.10) == "immune+"
def test_classifica_a_indice_zero_non_esiste_immunita():
"""Caso limite DICHIARATO (non un bug): con indice 0 la banda ha ampiezza 0, quindi nulla
e' immune. Non morde negli episodi (per costruzione hanno indice molto negativo), ma chi
riusa `classifica()` altrove deve saperlo."""
assert M.classifica(0.0, 0.0) == "PERDE"
assert M.classifica(+0.001, 0.0) == "POSITIVO"
def test_classifica_un_libro_esattamente_fermo_e_immune_col_segno_MENO():
"""Zero non e' positivo: `libro > 0` e' falso, quindi la classe e' "immune-".
E' una convenzione, e va scritta perche' non e' l'unica possibile."""
assert M.classifica(0.0, -0.20) == "immune-"
# ===========================================================================================
# finestre_peggiori() — non sovrapposte, ordinate, al piu' N, e la peggiore e' la peggiore
# ===========================================================================================
def _serie_con_crolli(crolli: list[tuple[int, float]], n: int = 300) -> pd.Series:
"""Serie giornaliera piatta con crolli piantati a posizioni note: ogni `(i0, r)` mette
20 giorni consecutivi a rendimento `r` a partire dalla posizione `i0`."""
r = pd.Series(0.0, index=_giorni(n))
for i0, val in crolli:
r.iloc[i0:i0 + M.FINESTRA] = val
return r
CROLLI = [(30, -0.010), (100, -0.005), (180, -0.002)]
def test_finestre_peggiori_non_si_sovrappongono_e_sono_ordinate():
"""DEVE FALLIRE SE: il greedy smette di marcare gli intervalli occupati — senza quel filtro
le 12 "peggiori finestre" sarebbero 12 traslazioni di UNO stesso episodio, e la riga
"4/12" conterebbe 12 volte lo stesso crollo."""
idx = _serie_con_crolli(CROLLI)
prese = M.finestre_peggiori(idx, n=3)
assert len(prese) == 3
assert prese == sorted(prese), "l'elenco esce ordinato per data d'inizio"
for (s0, e0, _), (s1, _, _) in zip(prese, prese[1:]):
assert s1 > e0, f"finestre sovrapposte: {e0} e {s1}"
for s, e, _ in prese:
assert e - s == pd.Timedelta(days=M.FINESTRA - 1)
def test_finestre_peggiori_trova_DAVVERO_la_peggiore():
"""Il primo episodio dev'essere il minimo globale del rolling, non un minimo locale.
DEVE FALLIRE SE: qualcuno sostituisce il greedy dal peggiore con una scansione in avanti."""
idx = _serie_con_crolli(CROLLI)
prese = M.finestre_peggiori(idx, n=3)
roll = (1 + idx).rolling(M.FINESTRA).apply(np.prod, raw=True) - 1
peggiore = min(prese, key=lambda t: t[2])
assert peggiore[2] == pytest.approx(float(roll.min()), abs=1e-12)
assert peggiore[2] == pytest.approx((1 - 0.010) ** M.FINESTRA - 1, abs=1e-12)
assert peggiore[0] == idx.index[30] and peggiore[1] == idx.index[49]
valori = sorted(v for _, _, v in prese)
attesi = sorted((1 + r) ** M.FINESTRA - 1 for _, r in CROLLI)
assert valori == pytest.approx(attesi, abs=1e-12)
def test_finestre_peggiori_rispetta_il_tetto_n():
idx = _serie_con_crolli(CROLLI)
assert len(M.finestre_peggiori(idx, n=2)) == 2
assert len(M.finestre_peggiori(idx, n=1)) == 1
assert len(M.finestre_peggiori(idx, n=100)) <= 100
# ===========================================================================================
# beta_pieno / beta_ribasso — pendenza NOTA per costruzione
# ===========================================================================================
def test_beta_pieno_ritrova_una_pendenza_nota():
rng = np.random.default_rng(909)
i = pd.Series(rng.normal(0.0, 0.03, 400), index=_giorni(400))
assert M.beta_pieno(0.30 * i, i) == pytest.approx(0.30, abs=1e-9)
assert M.beta_pieno(0.0 * i, i) == pytest.approx(0.0, abs=1e-12)
assert M.beta_pieno(-0.75 * i, i) == pytest.approx(-0.75, abs=1e-9)
def test_beta_ribasso_guarda_SOLO_i_giorni_giu():
"""Controllo POSITIVO: su una serie asimmetrica (0,50 in salita, 0,10 in discesa) il beta
pieno NON puo' rispondere alla domanda del crollo — il beta al ribasso si'.
DEVE FALLIRE SE: il filtro `idx < soglia` sparisce e le due misure collassano in una."""
rng = np.random.default_rng(46)
i = pd.Series(rng.normal(0.0, 0.03, 600), index=_giorni(600))
libro = pd.Series(np.where(i.values < 0, 0.10 * i.values, 0.50 * i.values), index=i.index)
assert M.beta_ribasso(libro, i) == pytest.approx(0.10, abs=1e-9)
assert M.beta_pieno(libro, i) > 0.20, "il beta pieno MEDIA i due regimi: e' un'altra domanda"
def test_beta_ribasso_usa_la_soglia_che_riceve():
"""La riga [1] stampa quattro beta (pieno, finestra §46, idx<0, idx≤−3%): sono lenti
diverse, non una."""
rng = np.random.default_rng(3)
i = pd.Series(rng.normal(0.0, 0.03, 800), index=_giorni(800))
forte = i.values < -0.03
libro = pd.Series(np.where(forte, 0.90 * i.values, 0.10 * i.values), index=i.index)
assert (i < -0.03).sum() >= 20, "campione insufficiente: il test non avrebbe potenza"
b3 = M.beta_ribasso(libro, i, -0.03)
b0 = M.beta_ribasso(libro, i, 0.0)
assert b3 == pytest.approx(0.90, abs=1e-9), "sotto 3% la pendenza e' quella imposta"
assert abs(b0 - b3) > 0.05, (
"la soglia non entra nel calcolo: le lenti darebbero lo stesso numero e la riga [1] "
"stamperebbe quattro volte la stessa cosa")
def test_beta_ribasso_e_NAN_sotto_venti_osservazioni():
"""P5: "non vedo" non e' "beta zero". DEVE FALLIRE SE: qualcuno toglie la guardia e
pubblica una pendenza stimata su tre giorni."""
i = pd.Series(np.linspace(-0.05, -0.01, 10), index=_giorni(10))
assert np.isnan(M.beta_ribasso(0.3 * i, i)), "10 giorni non fanno una pendenza"
i25 = pd.Series(np.linspace(-0.05, -0.01, 25), index=_giorni(25))
assert M.beta_ribasso(0.3 * i25, i25) == pytest.approx(0.30, abs=1e-9)
def test_la_finestra_di_46_e_dichiarata_e_non_e_tutta_la_storia():
"""§46 e' un numero NATO su una finestra che comincia col DVOL (2021-03-24): confrontarlo
col beta di tutta la storia sarebbe P7 al contrario (etichettare un numero con la finestra
sbagliata). Lo script tiene le due misure separate — questo test lo congela."""
assert M.FINESTRA_46 == pd.Timestamp("2021-03-24", tz="UTC")
assert M.BETA_MEMORIA == 0.076 and M.TOL_BETA == 0.01
# ===========================================================================================
# verdetto() — TRE affermazioni, tre domande diverse
# ===========================================================================================
def _m(guadagna=4, immune=6, n=12, beta_46=0.0769, giorni_libro=-0.0048,
giorni_pos=0.175, positivi=8, dal22=(8, 8), quota=1.08) -> dict:
return dict(beta_46=beta_46,
sintesi=dict(n_episodi=n, guadagna=guadagna, immune=immune,
perde=n - guadagna - immune, positivi=positivi, dal22=dal22,
giorni_pos=giorni_pos, giorni_libro=giorni_libro,
quota_short_nei_guadagni=quota))
def test_verdetto_separa_il_GIORNO_dalla_FINESTRA():
"""Le due domande hanno risposte OPPOSTE sugli stessi dati (nel giorno perde, per finestra
e' immune-o-positivo), ed e' il fatto centrale del 09/09: SKH01 e' marcato all'uscita, e
uno short aperto nel crollo viene accreditato dopo. Un solo aggettivo mentirebbe."""
v = M.verdetto(_m())
assert v.startswith("NEL GIORNO DEL CROLLO IL LIBRO PERDE")
assert "IMMUNE-O-POSITIVO per finestra" in v
assert M.verdetto(_m(giorni_libro=+0.0012)).startswith(
"NEL GIORNO DEL CROLLO IL LIBRO GUADAGNA")
def test_verdetto_i_tre_rami_della_FINESTRA():
"""Le soglie sono 2/3 sui POSITIVO e 2/3 su POSITIVO+immune.
DEVE FALLIRE SE: qualcuno le sposta guardando l'esito (selezione sull'hold-out)."""
assert "POSITIVO per finestra" in M.verdetto(_m(guadagna=8, immune=2))
assert "IMMUNE-O-POSITIVO per finestra" in M.verdetto(_m(guadagna=4, immune=6))
assert "PERDE per finestra" in M.verdetto(_m(guadagna=3, immune=2))
def test_verdetto_i_confini_delle_due_soglie():
v8 = M.verdetto(_m(guadagna=8, immune=0)) # 8 >= 8.0
assert "POSITIVO per finestra" in v8 and "IMMUNE-O-POSITIVO" not in v8
assert "IMMUNE-O-POSITIVO per finestra" in M.verdetto(_m(guadagna=7, immune=1))
assert "PERDE per finestra" in M.verdetto(_m(guadagna=7, immune=0))
def test_verdetto_dice_se_il_beta_RIPRODUCE_la_memoria_e_su_QUALE_finestra():
"""La tolleranza e' TOL_BETA e il beta confrontato e' quello della finestra di §46, non il
beta pieno: due numeri diversi (0,0769 contro 0,086), e solo il primo va confrontato."""
# ⚠️ il confine esatto non e' asseribile in virgola mobile: 0,076 + 0,01 vale 0,086 e la
# differenza torna 0,009999999999999995, cioe' DENTRO. Si prova un micron di qua e di la'.
dentro = M.BETA_MEMORIA + M.TOL_BETA - 1e-6
fuori = M.BETA_MEMORIA + M.TOL_BETA + 1e-6
assert "(riproduce il" in M.verdetto(_m(beta_46=dentro))
assert "(NON riproduce il" not in M.verdetto(_m(beta_46=dentro))
assert "(NON riproduce il" in M.verdetto(_m(beta_46=fuori))
assert "sulla finestra di §46" in M.verdetto(_m())
assert f"+{M.BETA_MEMORIA:.3f}" in M.verdetto(_m())
def test_verdetto_dichiara_che_SKH01_e_marcato_all_uscita():
"""P4: l'allerta (qui: la riga) risponde anche al "perche'". Senza quella nota, il lettore
vede SKH01 ≈ 0 nella tabella per giorno e il guadagno nella tabella per finestra, e non ha
modo di sapere che e' la stessa serie letta in due modi."""
assert "marcata all'uscita" in M.verdetto(_m())
def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09():
"""M23: la macchina deve ristampare il numero vecchio. Sono i valori esatti usciti da
`misura()` il 09/09 — se il testo cambia, cambia anche cio' che il diario cita, e va
cambiato nei due posti insieme."""
m = _m(guadagna=4, immune=6, n=12, beta_46=0.07691539529362904,
giorni_libro=-0.004841811670794324, giorni_pos=0.175, positivi=8, dal22=(8, 8),
quota=1.0793712686517833)
assert M.verdetto(m) == (
"NEL GIORNO DEL CROLLO IL LIBRO PERDE (-0.48%/g, positivo nel 18% dei giorni ≤ 5%); "
"IMMUNE-O-POSITIVO per finestra: 4 POSITIVO / 6 immune / 2 PERDE su 12 "
"(libro > 0 in 8, dal 2022 in 8/8); beta +0.0769 sulla finestra di §46 "
"(riproduce il +0.076); la gamba short di SKH01 (marcata all'uscita) fa il 108% "
"dei guadagni negli episodi con libro > 0")
# ===========================================================================================
# P1 — i pesi si IMPORTANO dal book live
# ===========================================================================================
def test_P1_i_pesi_sono_QUELLI_del_book_live_non_una_copia():
"""*Un controllo puntato su una configurazione diversa da quella che gira passa sempre.*
L'identita' (`is`) vale solo se i nomi sono legati allo STESSO oggetto dell'import: un
`W_TP01 = 0.75` riscritto nello script la romperebbe anche a valore uguale."""
import src.live.book as B
assert M.W_TP01 is B.W_TP01, "TP01: peso ridichiarato invece che importato da src/live/book"
assert M.W_SKH is B.W_SKH, "SKH01: peso ridichiarato invece che importato da src/live/book"
assert M.W_TP01 + M.W_SKH == pytest.approx(1.0)
src = inspect.getsource(M)
assert "from src.live.book import" in src
assert not re.search(r"^W_TP01\s*=", src, re.M), "peso TP01 ridichiarato a livello di modulo"
assert not re.search(r"^W_SKH\s*=", src, re.M), "peso SKH01 ridichiarato a livello di modulo"
def test_la_riga_informativa_37_5_62_5_NON_e_il_peso_del_libro():
"""§26 compare come riga informativa. DEVE FALLIRE SE: qualcuno la promuove a peso di
default — il 75/25 e' decisione vincolante, la riapre solo `weights_tilt_null`."""
assert M.W_INFO == (0.375, 0.625)
assert (M.W_TP01, M.W_SKH) != M.W_INFO
# ===========================================================================================
# INTEGRAZIONE sui dati veri
# ===========================================================================================
def test_la_scomposizione_di_SKH01_in_long_e_short_e_ESATTA():
"""~2 s. Due guardie, entrambe P1:
(a) la versione "tutto" dev'essere BIT-EXACT con `sleeves._skyhook_returns` — se non lo
fosse, l'attribuzione descriverebbe un altro sleeve;
(b) long + short == intera. In principio filtrare gli entry-dict potrebbe cambiare il
non-overlap (un long che non occupa piu' la barra lascia entrare uno short): la
misura dice che qui non succede, e il test lo tiene vero."""
if not _feed_certificato_presente():
pytest.skip("feed certificato BTC/ETH 5m/1h assente in data/raw (gitignored)")
from src.portfolio.sleeves import _skyhook_returns
d = M.skh_per_direzione()
assert set(d) == {"tutto", "long", "short"}
J = pd.concat(d, axis=1, join="inner")
assert float((J["long"] + J["short"] - J["tutto"]).abs().max()) < 1e-12, (
"long + short != intera: la scomposizione non e' un'attribuzione, e' un'altra misura")
ref = _skyhook_returns()
if ref.index.tz is None:
ref.index = ref.index.tz_localize("UTC")
comune = d["tutto"].index.intersection(ref.index)
assert len(comune) > 1000
assert float((d["tutto"].loc[comune] - ref.loc[comune]).abs().max()) == 0.0, (
"la versione 'tutto' non e' piu' bit-exact col sleeve di ricerca")
def test_integrazione_il_risultato_pubblicato_del_09_09_si_riproduce():
"""~2 s. Se questo test diventa rosso NON va "aggiornato": va capito se e' cambiato il
dato (rebuild del feed) o il codice, e la riga citata nel diario va rifatta.
I due fatti che decidono: (1) per FINESTRA il libro non perde quasi mai — 4 POSITIVO +
6 immune su 12, e libro > 0 in 8/8 episodi dal 2022; (2) NEL GIORNO del crollo perde
(0,48%/g, positivo solo nel 18% dei giorni ≤ 5%). Sono due risposte opposte alla stessa
domanda dell'operatore, e stanno insieme perche' SKH01 e' marcato all'uscita."""
if not _feed_certificato_presente():
pytest.skip("feed certificato BTC/ETH 5m/1h assente in data/raw (gitignored)")
m = M.misura()
assert m["beta"] == pytest.approx(0.086, abs=0.03), "beta pieno fuori dal valore pubblicato"
assert abs(m["beta_46"] - M.BETA_MEMORIA) < M.TOL_BETA, (
f"il beta sulla finestra di §46 ({m['beta_46']:+.4f}) non riproduce piu' il "
f"+{M.BETA_MEMORIA:.3f}: e' la riproduzione che giustifica la lente")
s = m["sintesi"]
assert s["n_episodi"] == 12
assert (s["guadagna"], s["immune"], s["perde"]) == (4, 6, 2)
assert s["guadagna"] + s["immune"] + s["perde"] == s["n_episodi"]
assert s["positivi"] == 8, "il conteggio nudo libro > 0 e' l'altra lettura, e va stampata"
assert s["dal22"] == (8, 8), "dal 2022 ogni episodio ha il libro in utile"
assert s["giorni_libro"] < 0, "nel GIORNO del crollo il libro perde: e' la meta' scomoda"
assert s["giorni_pos"] == pytest.approx(0.175, abs=0.05)
classi = [r["classe"] for r in m["episodi"]]
assert classi.count("POSITIVO") == s["guadagna"]
assert sum(c.startswith("immune") for c in classi) == s["immune"]
assert classi.count("PERDE") == s["perde"]
dal_2022 = [r for r in m["episodi"] if int(r["da"][:4]) >= 2022]
assert len(dal_2022) == 8
assert all(r["libro"] > 0 for r in dal_2022), (
"un episodio dal 2022 in poi non ha piu' il libro in utile: "
+ str([(r["nome"], r["da"], r["libro"]) for r in dal_2022]))
v = M.verdetto(m)
assert v.startswith("NEL GIORNO DEL CROLLO IL LIBRO PERDE")
assert "IMMUNE-O-POSITIVO per finestra" in v
assert "(riproduce il +0.076)" in v