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")
+71 -12
View File
@@ -4,9 +4,19 @@ Congela i tre esiti del gate e — soprattutto — l'ANNUALIZZAZIONE. Una serie
passata a un annualizzatore a 365 esce con lo Sharpe ×1.20 e il CAGR ×1.45: e' l'errore del
25/07, ed e' l'unico difetto qui che produrrebbe numeri sbagliati ma plausibili.
`test_la_proposta_non_e_selezionata_sull_hold_out` e' il test di metodo: se un giorno la cella
proposta risultasse in rango MIGLIORE sull'hold-out che in-sample, sarebbe la firma della
selezione-sull'hold-out e la validazione andrebbe rifatta.
Il test di METODO e' `test_la_banda_proposta_e_quella_scelta_al_buio` (+ il suo controllo
positivo): se un giorno la banda che si sceglie sui soli dati pre-2015 non fosse piu' il 25%, la
validazione del 27/07 andrebbe rifatta su quella cella.
⚠️ 2026-08-07 — CRITERIO SOSTITUITO. Fino a oggi il gate (A) era congelato come
`rank_in <= rank_oos` sulla proposta, e ha iniziato a fallire (9ª in-sample, 8ª sull'hold-out)
senza che nessuno toccasse il codice: `data/raw/` e' gitignored e IB rivede `ADJUSTED_LAST`
all'indietro ogni notte. Misurato in `scripts/research/r0807_gtaa_gate_resolution.py`, quel
criterio non poteva reggere: lo passa ~meta' della griglia PER COSTRUZIONE (la somma dei ranghi
e' la stessa nelle due finestre) e sulla proposta si decideva su 0.001 di Sharpe contro uno
spread di griglia di 0.31. Il motivo del ritiro e' congelato in
`test_il_confronto_fra_ranghi_e_una_moneta_ed_e_per_questo_che_e_stato_RITIRATO` — non l'esito,
che dipende dai dati e si muove da solo.
"""
from __future__ import annotations
@@ -76,9 +86,8 @@ def test_la_produzione_usa_ancora_la_banda_ASSOLUTA():
# ===========================================================================
# (A) selezione — il gate di metodo
# ===========================================================================
def test_la_proposta_non_e_selezionata_sull_hold_out():
"""Rango in-sample <= rango hold-out: la proposta non migliora passando all'hold-out.
Il contrario sarebbe la firma della selezione-sull'hold-out."""
@pytest.fixture(scope="module")
def griglia_piena():
rows = []
for every in BG.EVERY_GRID:
for frac in BG.FRAC_GRID:
@@ -87,12 +96,62 @@ def test_la_proposta_non_e_selezionata_sull_hold_out():
sh_in=BG.met(s.loc[: BG.HOLDOUT])["sharpe"],
sh_oos=BG.met(s.loc[BG.HOLDOUT:])["sharpe"]))
G = pd.DataFrame(rows)
p = G[(G["every"] == BG.PROPOSTA[0]) & (G["frac"] == BG.PROPOSTA[1])].iloc[0]
rank_in = int((G["sh_in"] > p["sh_in"]).sum()) + 1
rank_oos = int((G["sh_oos"] > p["sh_oos"]).sum()) + 1
assert rank_in <= rank_oos, (
f"la proposta e' {rank_oos}a sull'hold-out ma {rank_in}a in-sample: "
"sta meglio dove non doveva essere guardata")
G["rank_in"] = G["sh_in"].rank(ascending=False, method="min").astype(int)
G["rank_oos"] = G["sh_oos"].rank(ascending=False, method="min").astype(int)
return G
def test_la_banda_proposta_e_quella_scelta_al_buio(griglia_piena):
"""Il gate (A), nella forma decidibile (2026-08-07).
La proposta del 27/07 e' una BANDA: la cadenza settimanale e' gia' `REBAL_EVERY=5` in
produzione e non era in discussione. Quindi la domanda sulla provenienza della scelta e'
«a cadenza di produzione, quale banda si sceglie guardando SOLO il pre-2015?».
"""
G = griglia_piena
riga = G[G["every"] == BG.PROPOSTA[0]]
blind = riga.sort_values("sh_in", ascending=False).iloc[0]
assert blind["frac"] == BG.PROPOSTA[1], (
f"al buio si sceglierebbe la banda {blind['frac']:.0%}, non {BG.PROPOSTA[1]:.0%}: "
"la validazione del 27/07 andrebbe rifatta su quella cella")
def test_chi_guardasse_l_hold_out_sceglierebbe_una_banda_DIVERSA(griglia_piena):
"""CONTROLLO POSITIVO del test precedente. Se le due selezioni coincidessero, «la proposta e'
la scelta in-sample» sarebbe vero anche per una proposta selezionata sull'hold-out, e il gate
non direbbe nulla. Un gate che non puo' fallire e' indistinguibile da uno rotto."""
G = griglia_piena
riga = G[G["every"] == BG.PROPOSTA[0]]
hold = riga.sort_values("sh_oos", ascending=False).iloc[0]
assert hold["frac"] != BG.PROPOSTA[1], (
"in-sample e hold-out scelgono la stessa banda: su questa griglia il gate (A) non ha "
"potenza e non va citato come validazione")
def test_il_margine_del_blind_non_e_un_arrotondamento(griglia_piena):
"""La ragione per cui questo criterio ha sostituito il confronto fra ranghi: decide su
~0.015 di Sharpe invece che su ~0.001. Se il margine scendesse sotto un centesimo, anche
questo criterio smetterebbe di essere una misura e andrebbe ripensato a sua volta."""
G = griglia_piena
riga = G[G["every"] == BG.PROPOSTA[0]]
blind = riga.sort_values("sh_in", ascending=False).iloc[0]
secondo = riga[riga["frac"] != blind["frac"]]["sh_in"].max()
assert blind["sh_in"] - secondo >= 0.01
def test_il_confronto_fra_ranghi_e_una_moneta_ed_e_per_questo_che_e_stato_RITIRATO(griglia_piena):
"""Congela il MOTIVO del ritiro, non l'esito (che dipende dai dati e cambia da solo).
Fino al 2026-08-07 il gate (A) asseriva `rank_in <= rank_oos` sulla proposta. Quel criterio
non puo' distinguere una proposta onesta da una selezionata sull'hold-out: la somma dei
ranghi e' la stessa nelle due finestre, quindi lo passa circa META' della griglia per
costruzione, qualunque cosa contenga. Se qualcuno lo rimettesse, questo test spiega perche' no.
"""
G = griglia_piena
passa = int((G["rank_in"] <= G["rank_oos"]).sum())
assert 0.3 * len(G) <= passa <= 0.7 * len(G), (
f"passa {passa}/{len(G)} celle: se fosse molto lontano da meta', il criterio "
"porterebbe informazione e questa motivazione andrebbe riletta")
def test_l_hold_out_usato_e_quello_documentato_di_gtaa01():