Files
PythagorasGoal/tests/test_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

386 lines
19 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.
"""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