"""Test di `scripts/research/r0909_xrp_terza_gamba.py` (2026-09-09) — XRP come terza gamba. Tre cose vanno protette, e nessuna e' il numero finale: 1. **Il paginatore.** `_fetch_1h` esiste nella forma che ha perche' Coinbase ha un BUCO su XRP (delistato 2021-01, rilistato 2023-07 dopo la sentenza SEC): un batch vuoto NON deve fermare il ciclo. Un paginatore che si ferma al primo vuoto avrebbe certificato XRP su mezzo campione — e la certificazione decide QUALE lente si usa, cioe' decide il risultato. 2. **La regola L-PULITA.** "Dal primo anno in cui tutti i successivi restano sotto soglia": un anno pulito ISOLATO non e' un regime. E' la regola che sceglie la finestra, ed e' dichiarata prima di guardare l'esito — quindi va congelata prima che qualcuno la ritocchi. 3. **I tre rami del verdetto**, coi loro AND: AGGIUNGE richiede hold-out E de-levering E anni. Piu' il guardrail del namespace: XRP vive in `alt_xrp_*`, e `load_data("XRP")` deve continuare a fallire (il "solo dati certi" di CLAUDE.md non e' codice, e' l'assenza del file). ⚠️ NESSUN test va in rete: il riferimento Coinbase/Bitstamp o e' in cache o il test si salta. """ from __future__ import annotations 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_xrp_terza_gamba as M # noqa: E402 RAW = ROOT / "data" / "raw" ORA_MS = 3_600_000 T0 = pd.Timestamp("2026-01-01", tz="UTC") # =========================================================================================== # (a) _fetch_1h — un batch vuoto NON ferma il ciclo # =========================================================================================== class ExFinto: """Exchange finto e DETERMINISTICO: restituisce un batch vuoto alle chiamate elencate in `vuote` (indici 0-based), altrove `limit` barre orarie a partire da `since`.""" def __init__(self, vuote=(), righe_prima_di_since=False): self.vuote = set(vuote) self.righe_prima_di_since = righe_prima_di_since self.since_visti: list[int] = [] def fetch_ohlcv(self, symbol, tf, since=None, limit=None): n = len(self.since_visti) self.since_visti.append(since) assert tf == "1h" and symbol and limit if n in self.vuote: return [] base = since - 10 * ORA_MS if self.righe_prima_di_since else since return [[base + i * ORA_MS, 1.0, 1.0, 1.0, 10.0 + n + i, 1.0] for i in range(limit)] @pytest.fixture() def orologio_fermo(monkeypatch): """`_fetch_1h` chiude il ciclo su `time.time()`: qui lo si ferma a T0+72h, cosi' il numero di pagine e' un fatto e non il meteo della macchina. (Il patch vive solo dentro il test.)""" fine = T0 + pd.Timedelta(hours=72) monkeypatch.setattr(M.time, "time", lambda: fine.timestamp()) return fine def test_fetch_1h_AVANZA_dopo_un_batch_vuoto(orologio_fermo): """IL test di questo file. Coinbase su XRP restituisce vuoto per due anni e mezzo: se il ciclo si fermasse (break) o non facesse avanzare `since` (loop infinito), la certificazione girerebbe su un campione mutilato — e la lente L-PULITA la sceglie la certificazione. DEVE FALLIRE SE: qualcuno sostituisce `since += limit*3_600_000; continue` con un `break`.""" ex = ExFinto(vuote={0}) s = M._fetch_1h(ex, "XRP/USD", T0, 24) assert len(ex.since_visti) >= 2, "il ciclo si e' fermato al primo batch vuoto" assert ex.since_visti[1] == ex.since_visti[0] + 24 * ORA_MS, ( "dopo un batch vuoto `since` deve avanzare di un'intera pagina") assert len(s) == 48 assert s.index.min() == T0 + pd.Timedelta(hours=24), "la prima pagina era il buco" assert s.index.is_monotonic_increasing and s.index.is_unique def test_fetch_1h_scavalca_un_buco_IN_MEZZO_e_riprende(orologio_fermo): """Il buco vero di XRP e' in mezzo (2021-01 → 2023-07), non in testa: il dato PRIMA e quello DOPO devono stare tutti e due nella serie, col buco che resta un buco (D2: una riga assente non si inventa).""" ex = ExFinto(vuote={1}) s = M._fetch_1h(ex, "XRP/USD", T0, 24) assert len(s) == 48 assert s.index.min() == T0 assert s.index.max() == T0 + pd.Timedelta(hours=71) buco = s[(s.index >= T0 + pd.Timedelta(hours=24)) & (s.index < T0 + pd.Timedelta(hours=48))] assert len(buco) == 0, "il buco e' stato riempito con qualcosa: non c'era niente da riempire" def test_fetch_1h_scarta_le_righe_PRIMA_di_since_e_avanza_lo_stesso(orologio_fermo): """Alcuni venue ignorano `since` e ritornano sempre la stessa pagina: il filtro `x[0] >= since` la svuota, e a quel punto vale la stessa regola del batch vuoto.""" ex = ExFinto(righe_prima_di_since=True) s = M._fetch_1h(ex, "XRP/USD", T0, 24) # le prime 10 barre di ogni pagina cadono prima di `since` e vengono scartate assert len(ex.since_visti) >= 2 assert ex.since_visti[1] > ex.since_visti[0], "il ciclo si e' impantanato" assert (s.index >= T0).all() def test_fetch_1h_non_si_impianta_su_una_pagina_da_una_riga(monkeypatch): """La guardia `since = nxt if nxt > since else since + pagina` esiste per non girare a vuoto: qui ogni chiamata rende UNA barra a `since`, e il ciclo deve comunque terminare.""" fine = T0 + pd.Timedelta(hours=5) monkeypatch.setattr(M.time, "time", lambda: fine.timestamp()) class UnaRiga: def __init__(self): self.n = 0 def fetch_ohlcv(self, symbol, tf, since=None, limit=None): self.n += 1 assert self.n < 50, "ciclo infinito" return [[since, 1.0, 1.0, 1.0, 10.0, 1.0]] s = M._fetch_1h(UnaRiga(), "XRP/USD", T0, 24) assert len(s) == 5 assert list(s.index) == list(pd.date_range(T0, periods=5, freq="h")) # =========================================================================================== # (b) la regola L-PULITA dentro certifica() # =========================================================================================== ANNI = (2022, 2023, 2024, 2025, 2026) def _finto_feed(sporchi: dict[int, int], n: int = 700): """Feed sintetico: per ogni anno `n` barre orarie, di cui `sporchi[y]` a +2% dal riferimento (cioe' sopra la soglia >1% che definisce una barra sporca).""" dd, rr = [], [] for y in ANNI: idx = pd.date_range(f"{y}-01-01", periods=n, freq="h", tz="UTC") rif = np.full(n, 100.0) mio = rif.copy() mio[:sporchi.get(y, 0)] = 102.0 dd.append(pd.DataFrame(dict(datetime=idx, close=mio, high=mio + 1.0, low=mio - 1.0))) rr.append(pd.DataFrame(dict(ts=idx, close=rif, fonte="CB"))) D = pd.concat(dd, ignore_index=True) R = pd.concat(rr, ignore_index=True).set_index("ts") return D, R def _certifica(monkeypatch, sporchi: dict[int, int], n: int = 700) -> dict: D, R = _finto_feed(sporchi, n) monkeypatch.setattr(M, "dati", lambda asset, tf: D) monkeypatch.setattr(M, "riferimento_1h", lambda start: R) return M.certifica() def test_L_PULITA_parte_dal_primo_anno_con_TUTTI_i_successivi_puliti(monkeypatch): """Lo scenario vero: 2022 e 2023 sporchi (0,57% nel 2023), 2024-2026 puliti → 2024-01-01.""" c = _certifica(monkeypatch, {2022: 8, 2023: 4}) assert c["per_anno"][2023]["q_1pct"] == pytest.approx(4 / 700, abs=1e-6) assert c["per_anno"][2022]["q_1pct"] > M.SOGLIA_PULITA assert c["puliti"] == [2024, 2025, 2026] assert c["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC") def test_L_PULITA_ignora_un_anno_pulito_ISOLATO(monkeypatch): """La regressione che la regola esiste per impedire: 2022 pulito, 2023 sporco, 2024+ puliti. Un anno pulito isolato NON e' un regime — la lente parte dalla coda pulita finale (2024), non dal 2022. DEVE FALLIRE SE: qualcuno semplifica in `min(puliti)`.""" c = _certifica(monkeypatch, {2023: 8}) assert c["puliti"] == [2022, 2024, 2025, 2026], "il 2022 e' pulito, e va detto" assert c["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC"), ( "la lente non puo' partire dal 2022: il 2023 in mezzo e' sporco") def test_L_PULITA_e_tutta_la_storia_se_non_c_e_niente_di_sporco(monkeypatch): c = _certifica(monkeypatch, {}) assert c["puliti"] == list(ANNI) assert c["pulita_da"] == pd.Timestamp("2022-01-01", tz="UTC") def test_L_PULITA_e_NONE_se_nessun_anno_e_pulito(monkeypatch): """P5: "non c'e' una finestra pulita" ha uno stato suo. `misura()` allora non aggiunge la lente e `verdetto()` ripiega sul 2024+ dichiarato (la lente di SOL).""" c = _certifica(monkeypatch, {y: 8 for y in ANNI}) assert c["puliti"] == [] assert c["pulita_da"] is None def test_la_soglia_di_pulizia_e_INCLUSIVA_e_vale_SOGLIA_PULITA(monkeypatch): """Il confronto e' `<=`: esattamente in soglia l'anno e' pulito. Con n=1000 barre la soglia 0,5% cade su un intero (5 barre), quindi il confine e' asseribile.""" c = _certifica(monkeypatch, {2022: 5, 2023: 5, 2024: 5, 2025: 5, 2026: 5}, n=1000) assert c["per_anno"][2022]["q_1pct"] == pytest.approx(M.SOGLIA_PULITA) assert c["puliti"] == list(ANNI) c2 = _certifica(monkeypatch, {2022: 6, 2023: 5, 2024: 5, 2025: 5, 2026: 5}, n=1000) assert 2022 not in c2["puliti"] assert c2["pulita_da"] == pd.Timestamp("2023-01-01", tz="UTC") def test_certifica_riporta_per_ogni_anno_le_misure_che_la_tabella_stampa(monkeypatch): c = _certifica(monkeypatch, {2022: 8}) for y, r in c["per_anno"].items(): assert set(r) == {"n", "q_1pct", "med_bps", "fonte", "flat_1h", "flat_5m"} assert r["n"] == 700 assert r["flat_1h"] == 0.0, "high != low per costruzione" # =========================================================================================== # (c) verdetto() — i tre rami, tutti con AND # =========================================================================================== NOME = "L-PULITA (2024+)" def _m(hold, sh3, sh2k, anni_dsh, nome: str = NOME, k: float = 0.827) -> dict: h = np.asarray(hold, float) z = np.zeros(len(h)) return dict(lenti={nome: dict(sh=z, hold=h, dd=z, cagr=z)}, delev={nome: dict(k=k, sh3=sh3, sh2k=sh2k, cagr3=0.146, cagr2k=0.134)}, anno={2024 + i: dict(dsh=float(v)) for i, v in enumerate(anni_dsh)}) def test_verdetto_AGGIUNGE_solo_se_passano_tutte_e_tre_le_condizioni(): v = M.verdetto(_m([0.10] * 24, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1])) assert v.startswith("XRP AGGIUNGE") def test_verdetto_DILUISCE_quando_cadono_hold_out_E_de_levering(): v = M.verdetto(_m([-0.169] * 24, sh3=1.54, sh2k=1.53, anni_dsh=[0.54, -0.44, -0.25])) assert v.startswith("XRP DILUISCE") def test_verdetto_PARI_quando_le_condizioni_si_contraddicono(): """N5/M8: quando una condizione passa e l'altra no, il verdetto non sceglie la piu' comoda — dichiara che non e' selezionabile.""" solo_hold = M.verdetto(_m([0.10] * 24, sh3=1.50, sh2k=1.60, anni_dsh=[0.5, 0.4, -0.1])) assert solo_hold.startswith("XRP: PARI / NON SELEZIONABILE") solo_delev = M.verdetto(_m([-0.10] * 24, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1])) assert solo_delev.startswith("XRP: PARI / NON SELEZIONABILE") def test_verdetto_chiede_il_75_percento_delle_ancore_non_la_sola_mediana(): """M7/§2: la banda d'ancora NON e' un intervallo di confidenza, ma la robustezza alla scelta dell'ancora e' una condizione a se'. Mediana > 0 con solo il 70% delle ancore positive non basta. DEVE FALLIRE SE: qualcuno lascia solo la mediana.""" hold = [0.10] * 16 + [-0.10] * 8 # mediana +0,10, ma >0 solo nel 67% delle ancore v = M.verdetto(_m(hold, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1])) assert not v.startswith("XRP AGGIUNGE") hold_ok = [0.10] * 18 + [-0.10] * 6 # esattamente 75% assert M.verdetto(_m(hold_ok, sh3=1.60, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1])).startswith("XRP AGGIUNGE") def test_verdetto_il_de_levering_chiede_un_margine_di_0_02(): """M5: il null del de-levering non si passa "per un pelo" — un vantaggio dentro 0,02 di Sharpe e' de-levering travestito, ed e' esattamente il caso di XRP (1,54 contro 1,53).""" quasi = M.verdetto(_m([0.10] * 24, sh3=1.52, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1])) assert not quasi.startswith("XRP AGGIUNGE") appena = M.verdetto(_m([0.10] * 24, sh3=1.521, sh2k=1.50, anni_dsh=[0.5, 0.4, -0.1])) assert appena.startswith("XRP AGGIUNGE") def test_verdetto_ripiega_sulla_lente_di_SOL_se_non_c_e_una_coda_pulita(): """Quando `pulita_da` e' None, `misura()` non crea la lente L-PULITA: il verdetto deve leggere quella dichiarata di riserva, non la prima che capita.""" m = _m([-0.10] * 24, sh3=1.50, sh2k=1.60, anni_dsh=[-0.1, -0.2, -0.3], nome="2024+ (lente di SOL)") m["lenti"]["L-FULL"] = dict(sh=np.zeros(24), hold=np.full(24, 9.0), dd=np.zeros(24), cagr=np.zeros(24)) m["delev"]["L-FULL"] = dict(k=1.0, sh3=9.0, sh2k=0.0, cagr3=0.0, cagr2k=0.0) v = M.verdetto(m) assert "lente 2024+ (lente di SOL)" in v assert v.startswith("XRP DILUISCE"), "ha letto la L-FULL invece della lente dichiarata" def test_verdetto_riproduce_ALLA_LETTERA_la_riga_pubblicata_il_09_09(): """M23: i valori sono quelli usciti dalla misura a 24 ancore del 09/09.""" m = _m([-0.169] * 24, sh3=1.54, sh2k=1.53, anni_dsh=[0.54, -0.44, -0.25]) assert M.verdetto(m) == ( "XRP DILUISCE — lente L-PULITA (2024+): dSharpe hold-out mediano -0.169 " "(>0 nel 0% di 24 ancore); de-levering: Sh 3 gambe 1.54 vs 2 gambe×0.827 1.53; " "anni 2024+ con dSh>0: 1/3") # =========================================================================================== # (d) il GUARDRAIL del namespace # =========================================================================================== def test_il_divieto_su_data_raw_xrp_e_gia_DERIVATO_dal_codice(): """`tests/test_sol_leg.py::test_nessun_asset_fuori_universo_nel_namespace_NUDO` deriva gli asset vietati da `rebuild_history.DERIBIT_INSTR`: finche' XRP e' li' dentro, quel test copre gia' `data/raw/xrp_*.parquet` e qui NON si duplica. Questo test congela la premessa — se qualcuno togliesse XRP dal dict, la guardia sparirebbe in silenzio.""" from scripts.analysis.rebuild_history import DERIBIT_INSTR assert "XRP" in DERIBIT_INSTR, ( "XRP fuori da DERIBIT_INSTR: il divieto su data/raw/xrp_*.parquet non e' piu' derivato " "da nessuna parte — riscrivere la guardia in test_sol_leg.py prima di toglierlo") def test_load_data_XRP_continua_a_FALLIRE(): """L'altra meta': il dato XRP dello studio vive in `alt_xrp_*` e il feed attivo non lo vede. Se un giorno `load_data("XRP")` funzionasse, il cron (`--asset BTC ETH`) non lo rinfrescherebbe comunque -> dato stantio che sembra attivo.""" from src.data.downloader import load_data with pytest.raises(FileNotFoundError): load_data("XRP", "1h") def test_lo_studio_legge_il_namespace_di_RICERCA(): """`dati()` importata da r0822_sol_leg instrada XRP su `alt_xrp_*`: se non c'e' il file, l'errore deve dire come rigenerarlo, non fingere che l'asset non esista.""" assert M.ASSET == "XRP" and M.TRE == ("BTC", "ETH", "XRP") if (RAW / "alt_xrp_1h.parquet").exists(): d = M.dati("XRP", "1h") assert {"close", "datetime"} <= set(d.columns) and len(d) > 0 # =========================================================================================== # (e) INTEGRAZIONE (opzionale: salta senza i dati o senza la cache del riferimento) # =========================================================================================== def test_integrazione_xrp_diluisce_anche_a_due_ancore(monkeypatch): """~2 s. Non riproduce i numeri a 24 ancore (2 ancore sono un'altra misura): riproduce la DIREZIONE, che e' la cosa che decide — dSharpe hold-out mediano negativo e verdetto "XRP DILUISCE", cioe' l'ipotesi a priori confermata.""" if not all((RAW / f"alt_xrp_{tf}.parquet").exists() for tf in ("1h", "5m")): pytest.skip("namespace di ricerca alt_xrp_* assente (rigenerabile, gitignored)") if not all((RAW / f"{a}_{tf}.parquet").exists() for a in ("btc", "eth") for tf in ("1h", "5m")): pytest.skip("feed certificato BTC/ETH assente") if not M.CACHE.exists(): pytest.skip(f"cache del riferimento assente ({M.CACHE}): il test NON va in rete") # cintura: anche se la cache sparisse a meta' test, nessuno tocca la rete def _vietato(*a, **k): raise AssertionError("il test ha provato ad andare in rete (_fetch_1h)") monkeypatch.setattr(M, "_fetch_1h", _vietato) m = M.misura(2) assert m["n_anchors"] == 2 assert m["cert"]["pulita_da"] == pd.Timestamp("2024-01-01", tz="UTC") nome = ([n for n in m["lenti"] if n.startswith("L-PULITA")] or ["2024+ (lente di SOL)"])[0] assert np.median(m["lenti"][nome]["hold"]) < 0, "il dSharpe hold-out ha cambiato segno" assert M.verdetto(m).startswith("XRP DILUISCE") assert m["corr"]["L-FULL"] < 0.74, "la correlazione col book supera la soglia del 19/06"