963776e5d2
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>
142 lines
6.6 KiB
Python
142 lines
6.6 KiB
Python
"""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")
|