"""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