"""Guardie sulla STORIA del feed equity (scripts/research/fetch_ib_equities.py::certify). Trovate il 2026-08-07 indagando un test di GTAA01 che aveva smesso di passare: TLT — una delle sei gambe dello sleeve — parte dal 2016-02-03 invece che dalla sua quotazione (2002-07-22), e nessuna certificazione se n'era accorta in sei settimane. Il motivo e' strutturale e vale come regola: **tutti i controlli esistenti guardano DENTRO la serie** (integrita', gap, spike, split, cross-check col gemello) e nessuno guarda cio' che la serie NON contiene. Una serie troncata e' perfettamente integra. Due guardie, perche' i due difetti non si vedono nello stesso modo: - `TRONCATO` — la serie ha perso storia RISPETTO AL DISCO. Prende la troncatura il giorno in cui compare; e' cieca a una che c'era gia'. - `STORIA-CORTA`— la serie parte molto dopo la quotazione dello strumento. Prende anche una troncatura presente da sempre — che e' il caso di TLT. Controlli positivi obbligatori: una guardia che non segnala mai e' indistinguibile da una rotta. """ 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 fetch_ib_equities as F # noqa: E402 def serie(primo: str, ultimo: str, seed: int = 0) -> pd.DataFrame: """OHLCV pulito su giorni lavorativi: nessun difetto tranne quello che il test inietta.""" idx = pd.bdate_range(primo, ultimo) rng = np.random.default_rng(seed) c = 100.0 * np.exp(np.cumsum(rng.normal(0.0002, 0.008, len(idx)))) return pd.DataFrame({"open": c, "high": c * 1.004, "low": c * 0.996, "close": c, "volume": 1e6}, index=idx) # =========================================================================== # TRONCATO — regressione rispetto alla versione su disco # =========================================================================== def test_una_serie_che_perde_storia_e_TRONCATA(): vecchia = serie("2002-07-22", "2026-08-06") nuova = serie("2016-02-03", "2026-08-06") assert F.certify("TLT", nuova, vecchia)["status"] == "TRONCATO" def test_una_serie_che_perde_barre_in_mezzo_e_TRONCATA(): """Stesso inizio, ma meno barre: la troncatura non e' sempre in testa.""" vecchia = serie("2010-01-04", "2026-08-06") nuova = vecchia.iloc[:-400].copy() # stessa prima barra, 400 barre in meno assert F.certify("SPY", nuova, vecchia)["status"] == "TRONCATO" def test_il_giro_normale_di_ogni_notte_NON_e_una_troncatura(): """Controllo negativo: la serie si allunga di un giorno, come ogni notte.""" vecchia = serie("2010-01-04", "2026-08-05") nuova = serie("2010-01-04", "2026-08-06") assert F.certify("SPY", nuova, vecchia)["status"] != "TRONCATO" def test_qualche_barra_in_meno_non_fa_scattare_la_guardia(): """Tolleranza dichiarata: IB restituisce ogni tanto una manciata di barre in meno senza che sia una perdita di storia. La soglia e' 5 barre; se serve cambiarla, si cambia qui.""" vecchia = serie("2010-01-04", "2026-08-06") nuova = vecchia.iloc[:-3].copy() assert F.certify("SPY", nuova, vecchia)["status"] != "TRONCATO" def test_senza_versione_su_disco_la_guardia_non_puo_scattare(): """Retrocompatibilita': `certify(sym, df)` deve continuare a funzionare — ma va detto che senza `prev` questa guardia e' cieca per costruzione.""" nuova = serie("2016-02-03", "2026-08-06") c = F.certify("SPY", nuova) assert c["status"] != "TRONCATO" assert c["persi_g"] == 0 # =========================================================================== # STORIA-CORTA — distanza dalla quotazione dello strumento # =========================================================================== def test_TLT_dal_2016_e_STORIA_CORTA(): """Il caso reale: 13.5 anni di storia che esistono e che il feed non da'.""" c = F.certify("TLT", serie("2016-02-03", "2026-08-06")) assert c["status"] == "STORIA-CORTA" assert c["manca_a"] == pytest.approx(13.5, abs=0.2) def test_una_serie_al_tetto_della_richiesta_NON_e_corta(): """Controllo negativo decisivo: SPY e' quotato dal 1993 ma la richiesta chiede 30 anni, quindi partire dal 1996 e' il CAP, non un difetto. Senza questa distinzione la guardia segnalerebbe ogni serie lunga e verrebbe ignorata.""" c = F.certify("SPY", serie("1996-08-14", "2026-08-06")) assert c["status"] == "OK" assert c["manca_a"] == 0.0 def test_uno_strumento_senza_riferimento_non_viene_giudicato(): """`PRIMA_QUOTAZIONE` copre le sei gambe di GTAA01. Su un simbolo fuori tabella la guardia tace invece di inventarsi una data.""" assert "XLK" not in F.PRIMA_QUOTAZIONE c = F.certify("XLK", serie("2016-01-04", "2026-08-06")) assert c["status"] == "OK" assert c["manca_a"] == 0.0 def test_la_quotazione_di_un_ETF_giovane_non_e_un_difetto(): """HYG parte dal 2007 perche' e' del 2007: la guardia non deve confondere giovane con troncato.""" c = F.certify("HYG", serie("2007-04-11", "2026-08-06")) assert c["status"] == "OK" def test_la_troncatura_ha_priorita_sulla_storia_corta(): """Se una serie e' ENTRAMBE le cose, quella che si riporta e' la piu' urgente: la perdita di oggi (che si puo' ancora non salvare su disco), non il limite storico.""" vecchia = serie("2002-07-22", "2026-08-06") nuova = serie("2016-02-03", "2026-08-06") assert F.certify("TLT", nuova, vecchia)["status"] == "TRONCATO" # =========================================================================== # lo stato REALE dello sleeve, congelato # =========================================================================== def test_GTAA01_gira_su_cinque_gambe_prima_del_2016(): """Non e' un test sul codice, e' un test sul FATTO — e sul suo effetto: l'hold-out equity di GTAA01 (2015+) contiene la gamba obbligazionaria e l'in-sample no, quindi ogni confronto in-sample/hold-out su questo sleeve confronta due strategie diverse. Se un giorno IB servisse la storia piena di TLT questo test si rompe: e' voluto. Va rilette la nota in CLAUDE.md e rifatto `r0807_gtaa_gate_resolution.py`, che qui e' documentato come misurato SU CINQUE GAMBE prima del 2016. """ from src.portfolio.gtaa import EQ_UNIVERSE, _close taglio = pd.Timestamp("2015-01-01", tz="UTC") vive = [a for a in EQ_UNIVERSE if _close(a).index[0] < taglio] assert len(vive) == 5 and "TLT" not in vive, ( f"gambe vive pre-2015: {vive} — se sono tornate 6, rileggi la nota GTAA01 in CLAUDE.md")