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:
@@ -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")
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user