research(gtaa): il gate (A) non misurava cio' che dichiarava — e TLT ha 13.5 anni in meno

Il test del gate sulla banda GTAA01 aveva smesso di passare senza che il codice fosse
cambiato (data/raw/ e' gitignored, IB rivede ADJUSTED_LAST all'indietro ogni notte).
Invece di allentare la soglia, misurata la risoluzione del criterio.

`rank_in <= rank_oos` lo passano 14/30 celle (47%) PER COSTRUZIONE: la somma dei ranghi
e' la stessa nelle due finestre. E sulla proposta si decideva su 0.00116 di Sharpe contro
uno spread di griglia di 0.3124. Il 27/07 quel criterio passava, e passava per caso.

Criterio sostituito con quello decidibile: la proposta e' una BANDA (la cadenza settimanale
e' gia' produzione), quindi a cadenza fissa la banda scelta sui soli dati pre-2015 e' 25%
= la proposta, margine +0.0151 (13x il vecchio); chi avesse scelto sull'hold-out avrebbe
preso 40%. Controllo positivo incluso. Regge sull'universo coerente a 5 gambe.

TROVATO PER STRADA: TLT parte dal 2016-02-03 invece che dalla quotazione (2002-07-22) →
GTAA01 gira su CINQUE gambe prima del 2016, e quella assente e' la gamba obbligazionaria.
Non e' di oggi (cosi' dal primo giro nel cron log del 24/06, prima della validazione) e non
e' un fetch da rifare: una richiesta retro esplicita a IB ritorna 0 barre.

Nessuna certificazione l'aveva visto perche' tutte guardano DENTRO la serie: una serie
troncata e' integra, senza gap, senza spike, senza duplicati. Cablate due guardie in
fetch_ib_equities.certify — TRONCATO (storia persa vs disco; il file NON viene sovrascritto
ne' fuso, ADJUSTED_LAST e' ri-aggiustato all'indietro e il giunto creerebbe un salto) e
STORIA-CORTA (parte dopo la quotazione, con distinzione dal tetto della richiesta 30Y).
Controlli negativi obbligatori: giro normale, serie al cap, ETF giovane, simbolo fuori
tabella.

Produzione INVARIATA: REBAL_BAND_USD resta $50, GTAA01 resta non deployabile (PRIIPs).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-07 19:30:34 +00:00
parent ba5ea2f5c3
commit 963776e5d2
6 changed files with 754 additions and 20 deletions
+141
View File
@@ -0,0 +1,141 @@
"""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")