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,180 @@
|
||||
# 2026-08-07 — Un gate che falliva, un criterio che non misurava, e una gamba con 13 anni in meno
|
||||
|
||||
**Book, pesi, cron, config: INVARIATI.** `REBAL_BAND_USD` resta $50; GTAA01 resta non deployabile
|
||||
(PRIIPs) e sotto `GTAA_MIN_CAPITAL`. Cambia un test, cambia un'affermazione in CLAUDE.md, e si
|
||||
aggiunge una guardia di certificazione che mancava.
|
||||
|
||||
Script: `scripts/research/r0807_gtaa_gate_resolution.py`.
|
||||
Test: `tests/test_eq_history_guard.py` (11) + `tests/test_gtaa_band_gate.py` (16, criterio (A)
|
||||
sostituito).
|
||||
|
||||
---
|
||||
|
||||
## Il punto di partenza
|
||||
|
||||
Il 07/08, facendo il giro dei test, `test_la_proposta_non_e_selezionata_sull_hold_out` falliva:
|
||||
|
||||
```
|
||||
la proposta e' 8a sull'hold-out ma 9a in-sample: sta meglio dove non doveva essere guardata
|
||||
```
|
||||
|
||||
Il gate (A) del 27/07 (`r0727_gtaa_band_gate.py`) era stato registrato cosi': «proposta 4/30
|
||||
in-sample, 5/30 hold-out → il rango NON migliora sull'hold-out, quindi non e' selection-on-holdout».
|
||||
Il codice non era stato toccato (`git log src/portfolio/gtaa.py` fermo al 26/07). Erano cambiati i
|
||||
**dati**: `data/raw/` e' gitignored e il cron ri-scarica ogni notte i sei ETF con `ADJUSTED_LAST`,
|
||||
che IB rivede all'indietro a ogni dividendo.
|
||||
|
||||
La tentazione ovvia era allentare la soglia o mettere un `xfail`. Sarebbe stato mettere a tacere
|
||||
esattamente il segnale. La domanda giusta e' un'altra: **il criterio misura cio' che dichiara?**
|
||||
|
||||
---
|
||||
|
||||
## (0) Il difetto trovato per strada: TLT ha 13.5 anni in meno
|
||||
|
||||
Prima ancora di guardare il criterio, la copertura delle sei gambe:
|
||||
|
||||
| gamba | prima barra | quotato dal | mancano | barre | pre-2015 | 2015+ |
|
||||
|---|---|---|---|---|---|---|
|
||||
| SPY | 1996-08-14 | 1993-01-22 | 3.6a | 7540 | 4625 | 2915 |
|
||||
| QQQ | 1999-03-10 | 1999-03-10 | 0.0a | 6896 | 3981 | 2915 |
|
||||
| IWM | 2000-05-26 | 2000-05-22 | 0.0a | 6586 | 3671 | 2915 |
|
||||
| **TLT** | **2016-02-03** | **2002-07-22** | **13.5a** | 2642 | **0** | 2642 |
|
||||
| GLD | 2004-11-18 | 2004-11-18 | 0.0a | 5460 | 2545 | 2915 |
|
||||
| HYG | 2007-04-11 | 2007-04-04 | 0.0a | 4861 | 1946 | 2915 |
|
||||
|
||||
(SPY parte dal 1996 perche' la richiesta chiede `durationStr="30 Y"`: e' il **tetto**, non un
|
||||
difetto. La distinzione conta, senza di essa una guardia segnalerebbe ogni serie lunga.)
|
||||
|
||||
**GTAA01 gira su CINQUE gambe prima del 2016**, e la gamba assente e' proprio quella che
|
||||
diversifica — le obbligazioni. Conseguenza diretta sul gate (A): l'in-sample (pre-2015) e
|
||||
l'hold-out (2015+) **non sono la stessa strategia**.
|
||||
|
||||
Non e' successo il 07/08: nel `logs/cron_daily.log` TLT parte dal 2016-02-03 fin dal primo giro
|
||||
registrato (24/06), quindi da **prima** della validazione del 27/07. E non e' un fetch da rifare:
|
||||
una richiesta retro esplicita (`endDateTime=2016-01-01`, `durationStr="5 Y"`) su questo conto IB
|
||||
ritorna **0 barre**. La storia non c'e'.
|
||||
|
||||
**Perche' nessuna certificazione l'ha vista.** Il feed equity ha guardie su integrita', gap lunghi,
|
||||
spike, split non aggiustati (25/07) e cross-check col gemello UCITS (26/07). **Tutte guardano
|
||||
DENTRO la serie.** Una serie troncata e' perfettamente integra: non ha buchi, non ha salti, non ha
|
||||
duplicati. E' la terza volta in due settimane che una guardia tarata su una classe di difetto non
|
||||
sorveglia le altre (soglia 50% cieca allo split 2:1; soglia sulla deviazione cieca alla
|
||||
contaminazione EUR/USD; ora: ogni controllo cieco a cio' che la serie ha perso).
|
||||
|
||||
### Cablato
|
||||
|
||||
`fetch_ib_equities.certify(sym, df, prev)` ora ha **due** guardie, perche' i due difetti non si
|
||||
vedono nello stesso modo:
|
||||
|
||||
- **`TRONCATO`** — la serie ha perso storia *rispetto al disco* (parte >10 giorni dopo, o ha >5
|
||||
barre in meno). Prende una troncatura il giorno in cui compare; e' cieca a una gia' presente.
|
||||
**Il file NON viene sovrascritto** — e neppure fuso: `ADJUSTED_LAST` e' ri-aggiustato
|
||||
all'indietro a ogni dividendo, quindi incollare una vintage vecchia a una nuova creerebbe un
|
||||
salto sul giunto, un difetto peggiore di quello che si voleva evitare.
|
||||
- **`STORIA-CORTA`** — la serie parte >1 anno dopo la quotazione dello strumento, e non al tetto
|
||||
della richiesta. Prende anche una troncatura presente da sempre, che e' il caso di TLT.
|
||||
Riferimento: `PRIMA_QUOTAZIONE`, sei simboli, **fonte secondaria dichiarata**.
|
||||
|
||||
Controlli positivi obbligatori nei test: il giro normale di ogni notte non deve scattare, una
|
||||
serie al tetto 30Y non deve scattare, un ETF giovane (HYG) non deve scattare, un simbolo fuori
|
||||
tabella non viene giudicato. *Una guardia che non segnala mai e' indistinguibile da una rotta.*
|
||||
|
||||
---
|
||||
|
||||
## (1) Il criterio non aveva risoluzione — misurato, non argomentato
|
||||
|
||||
| | valore |
|
||||
|---|---|
|
||||
| rango della proposta | 9/30 in-sample · 8/30 hold-out |
|
||||
| distanza dal rango precedente | **0.00116** di Sharpe in-sample |
|
||||
| spread dell'intera griglia | 0.3124 |
|
||||
| il verdetto si decideva su | **0.37% dello spread** |
|
||||
|
||||
E la misura che chiude la questione — **il criterio applicato a ogni cella della griglia passa
|
||||
14/30 (47%)**. Non e' un caso: la somma dei ranghi e' la stessa nelle due finestre, quindi
|
||||
`rank_in <= rank_oos` e' vero per circa **meta' delle celle per costruzione**, qualunque cosa la
|
||||
griglia contenga.
|
||||
|
||||
> Un gate che una cella a caso passa il 47% delle volte non distingue una proposta onesta da una
|
||||
> selezionata sull'hold-out. E' una moneta.
|
||||
|
||||
Contorno: lo spostamento tipico fra le due finestre e' di **8 ranghi** (massimo 25), e lo Spearman
|
||||
IS/OOS e' **+0.05**. Su questa griglia il rango in-sample non porta informazione sul rango
|
||||
hold-out — il che rende il confronto fra i due ranghi doppiamente privo di senso.
|
||||
|
||||
---
|
||||
|
||||
## (2) Il criterio decidibile
|
||||
|
||||
**La proposta del 27/07 e' una BANDA, non una cadenza.** `REBAL_EVERY=5` e' gia' la 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?*
|
||||
|
||||
| banda | Sh in-sample | Sh hold-out |
|
||||
|---|---|---|
|
||||
| 0% | 0.5396 | 0.8261 |
|
||||
| 5% | 0.5714 | 0.8640 |
|
||||
| 10% | 0.5956 | 0.8629 |
|
||||
| **25%** | **0.6398** ← argmax | 0.8529 |
|
||||
| 40% | 0.6247 | **0.9081** ← argmax |
|
||||
| 60% | 0.5874 | 0.7573 |
|
||||
|
||||
- banda scelta **sui soli dati pre-2015**: **25%** = la proposta, con margine **+0.0151** sulla 2ª;
|
||||
- banda scelta **sull'hold-out**: **40%** — diversa. *(Controllo positivo: se coincidessero, il
|
||||
gate non avrebbe potenza e non andrebbe citato come validazione.)*
|
||||
- cella scelta al buio su **tutta** la griglia: cadenza 1, banda **25%** — stessa banda.
|
||||
|
||||
**La proposta e' l'esatto contrario di una selezione-sull'hold-out**: e' la cella che si sceglie
|
||||
senza guardare l'hold-out, e chi avesse guardato l'hold-out ne avrebbe scelta un'altra. E il
|
||||
margine e' **0.0151** contro i **0.00116** su cui si decideva il criterio a ranghi: **13×**.
|
||||
|
||||
⚠️ **Cio' che questo criterio NON dice:** che la banda scelta in-sample sia la migliore
|
||||
sull'hold-out. Non lo e'. Con lo Spearman IS/OOS a ~0, nessuna cella di questa griglia lo sarebbe
|
||||
in modo affidabile. Il gate (A) risponde alla domanda sulla **provenienza** della scelta, non a
|
||||
quella sulla **previsione**. Confonderle e' esattamente il modo in cui si finisce a selezionare
|
||||
sull'hold-out credendo di validare.
|
||||
|
||||
---
|
||||
|
||||
## (3) Robustezza al difetto (0)
|
||||
|
||||
Rifatto tutto sull'universo a **5 gambe** (senza TLT), coerente fra le due finestre:
|
||||
blind **25%**, hold-out **40%**, margine **+0.0164**. Verdetto identico. Il criterio a ranghi
|
||||
resta una moneta anche li' (passa 14/30).
|
||||
|
||||
Quindi: il difetto dei dati **non e' cio' che decide questo verdetto** — ma resta un difetto, e va
|
||||
riparato per suo conto. Che e' cio' che si e' fatto al punto (0).
|
||||
|
||||
---
|
||||
|
||||
## Cosa cambia
|
||||
|
||||
- `tests/test_gtaa_band_gate.py`: il criterio a ranghi e' **ritirato** e sostituito da
|
||||
`test_la_banda_proposta_e_quella_scelta_al_buio` + il suo controllo positivo
|
||||
(`test_chi_guardasse_l_hold_out_sceglierebbe_una_banda_DIVERSA`) + un test sul margine.
|
||||
Il **motivo** del ritiro e' congelato in
|
||||
`test_il_confronto_fra_ranghi_e_una_moneta_ed_e_per_questo_che_e_stato_RITIRATO` — si congela il
|
||||
motivo, non l'esito, che dipende dai dati e si muove da solo.
|
||||
- `r0727_gtaa_band_gate.py`: il gate (A) stampa il criterio decidibile; il confronto fra ranghi
|
||||
resta come descrizione, con la data del ritiro.
|
||||
- CLAUDE.md: l'affermazione «proposta 4/30 in-sample, 5/30 hold-out → il rango NON migliora» e'
|
||||
corretta.
|
||||
- `fetch_ib_equities.py`: le due guardie sulla storia + il riepilogo finale che le elenca.
|
||||
|
||||
---
|
||||
|
||||
## Regole
|
||||
|
||||
1. **Un test che fallisce senza che il codice sia cambiato sta segnalando che i dati non sono
|
||||
versionati.** Prima di toccarlo, si guarda cosa e' cambiato sotto.
|
||||
2. **Un criterio va misurato sulla sua risoluzione prima che sul suo esito.** Se decide su una
|
||||
frazione di percento dello spread, il verdetto e' rumore in entrambi i versi — anche quando
|
||||
passa. Il 27/07 quel criterio *passava*, e passava per caso.
|
||||
3. **Un gate si valida contando quante volte lo passa un candidato a caso.** Qui: 47%. Il numero
|
||||
si poteva calcolare il 27/07 senza dati nuovi.
|
||||
4. **Una certificazione che guarda solo dentro la serie non vede cio' che la serie ha perso.**
|
||||
Una serie troncata e' integra, senza gap, senza spike, senza duplicati: passa tutto.
|
||||
5. **Distinguere «giovane», «al tetto della richiesta» e «troncato».** Sono tre cose diverse e
|
||||
solo la terza e' un difetto; senza la distinzione la guardia segnala sempre e viene ignorata.
|
||||
6. **Provenienza e previsione sono due domande diverse.** Un gate anti-selezione dice da dove
|
||||
viene la scelta, non se funzionera'.
|
||||
@@ -38,8 +38,20 @@ BROAD = ["SPY", "QQQ", "IWM", "TLT", "GLD", "HYG"]
|
||||
BROAD2 = ["DIA", "EFA", "EEM", "FXI", "EWJ", "AGG", "LQD", "IEF", "USO", "SLV", "DBC", "VNQ"]
|
||||
UNIVERSE = SECTORS + BROAD + BROAD2
|
||||
|
||||
# Prima quotazione degli strumenti che contano (le sei gambe di GTAA01). RIFERIMENTO DICHIARATO,
|
||||
# fonte secondaria: serve solo a distinguere "questo ETF e' giovane" da "il feed ci da' meno storia
|
||||
# di quella che esiste". Trovato il 2026-08-07: TLT parte dal 2016-02-03 invece che dal 2002 — IB su
|
||||
# questo conto NON serve barre precedenti (richiesta retro esplicita -> 0 barre), quindi non e' un
|
||||
# fetch da rifare ma un limite da dichiarare. Nessuna certificazione se n'era accorta perche' tutte
|
||||
# guardavano DENTRO la serie (integrita', gap, spike, split) e nessuna la sua LUNGHEZZA.
|
||||
PRIMA_QUOTAZIONE = {"SPY": "1993-01-22", "QQQ": "1999-03-10", "IWM": "2000-05-22",
|
||||
"TLT": "2002-07-22", "GLD": "2004-11-18", "HYG": "2007-04-04"}
|
||||
DURATION = "30 Y" # tetto della richiesta: una serie che parte QUI non e' troncata, e' al cap
|
||||
|
||||
def certify(sym: str, df: pd.DataFrame) -> dict:
|
||||
|
||||
def certify(sym: str, df: pd.DataFrame, prev: pd.DataFrame | None = None) -> dict:
|
||||
"""`prev` = la versione GIA' SU DISCO. Senza, la certificazione non puo' vedere cio' che la
|
||||
serie ha PERSO: tutti i controlli esistenti guardano dentro la serie che hanno in mano."""
|
||||
if df.empty:
|
||||
return {"sym": sym, "n": 0, "status": "VUOTO"}
|
||||
idx = df.index
|
||||
@@ -67,21 +79,45 @@ def certify(sym: str, df: pd.DataFrame) -> dict:
|
||||
# Vedi src/data/eq_splits.py per il discriminante split-vs-crollo (range intraday).
|
||||
splits = detect_unadjusted_splits(df)
|
||||
|
||||
# --- storia PERSA: due guardie, perche' i due difetti non si vedono nello stesso modo ---
|
||||
# (a) REGRESSIONE rispetto al disco: la serie nuova parte dopo la vecchia, o ha meno barre.
|
||||
# Prende una troncatura il giorno in cui compare — ma non una che c'era gia'.
|
||||
primo = idx[0].tz_localize(None) if idx[0].tzinfo else idx[0]
|
||||
ultimo = idx[-1].tz_localize(None) if idx[-1].tzinfo else idx[-1]
|
||||
perse = 0
|
||||
if prev is not None and len(prev):
|
||||
vecchio = prev.index[0]
|
||||
vecchio = vecchio.tz_localize(None) if vecchio.tzinfo else vecchio
|
||||
perse = max(0, (primo - vecchio).days)
|
||||
tronca = perse > 10 or (prev is not None and len(prev) > 0 and len(df) < len(prev) - 5)
|
||||
# (b) DISTANZA DALLA QUOTAZIONE: prende anche una troncatura presente da sempre. Non e' un
|
||||
# difetto se la serie parte al tetto della richiesta (30 anni indietro): quello e' il cap.
|
||||
corta = 0.0
|
||||
if sym in PRIMA_QUOTAZIONE:
|
||||
atteso = max(pd.Timestamp(PRIMA_QUOTAZIONE[sym]),
|
||||
ultimo - pd.DateOffset(years=int(DURATION.split()[0])))
|
||||
corta = max(0, (primo - atteso).days) / 365.25
|
||||
|
||||
status = "OK"
|
||||
if dup or not mono:
|
||||
status = "INTEGRITA'"
|
||||
elif tronca:
|
||||
status = "TRONCATO" # il feed ha PERSO storia rispetto a ieri
|
||||
elif splits:
|
||||
status = "SPLIT-NON-AGG"
|
||||
elif maxret > 0.5:
|
||||
status = "SPIKE?"
|
||||
elif longgap > 0:
|
||||
status = "GAP-LUNGO"
|
||||
elif corta >= 1.0:
|
||||
status = "STORIA-CORTA" # meno storia di quella che lo strumento HA
|
||||
elif span_y < 1:
|
||||
status = "corto<1y"
|
||||
return {"sym": sym, "n": len(df), "primo": idx[0].date(), "ultimo": idx[-1].date(),
|
||||
"anni": round(span_y, 1), "dup": dup, "mono": mono, "flat": flat,
|
||||
"maxret%": round(maxret * 100, 1), "miss_bd": missing, "gap_lunghi": longgap,
|
||||
"adj_first/last": adj_ratio, "status": status,
|
||||
"adj_first/last": adj_ratio, "persi_g": perse, "manca_a": round(corta, 1),
|
||||
"status": status,
|
||||
"splits": [f"{s['date'].date()} 1:{s['factor']:g}" for s in splits]}
|
||||
|
||||
|
||||
@@ -115,7 +151,7 @@ def main():
|
||||
continue
|
||||
con = Stock(sym, "SMART", "USD")
|
||||
try:
|
||||
bars = ib.reqHistoricalData(con, endDateTime="", durationStr="30 Y", barSizeSetting="1 day",
|
||||
bars = ib.reqHistoricalData(con, endDateTime="", durationStr=DURATION, barSizeSetting="1 day",
|
||||
whatToShow="ADJUSTED_LAST", useRTH=True, formatDate=1, timeout=60)
|
||||
except Exception as e:
|
||||
print(f" {sym:5} ERR {repr(e)[:70]}"); rep.append({"sym": sym, "status": "ERR"}); time.sleep(1.2); continue
|
||||
@@ -123,8 +159,24 @@ def main():
|
||||
print(f" {sym:5} 0 barre (subscription?)"); rep.append({"sym": sym, "n": 0, "status": "VUOTO"}); time.sleep(1.2); continue
|
||||
df = pd.DataFrame([(pd.Timestamp(str(b.date)), b.open, b.high, b.low, b.close, b.volume) for b in bars],
|
||||
columns=["ts", "open", "high", "low", "close", "volume"]).set_index("ts").sort_index()
|
||||
c = certify(sym, df)
|
||||
prev = None
|
||||
if out_path.exists():
|
||||
try:
|
||||
pv = pd.read_parquet(out_path)
|
||||
pv.index = pd.to_datetime(pv["timestamp"], unit="ms")
|
||||
prev = pv.sort_index()
|
||||
except Exception:
|
||||
prev = None
|
||||
c = certify(sym, df, prev)
|
||||
rep.append(c)
|
||||
if c["status"] == "TRONCATO":
|
||||
# NON si sovrascrive e NON si fonde: ADJUSTED_LAST e' ri-aggiustato all'indietro a ogni
|
||||
# dividendo, quindi incollare una vintage vecchia a una nuova crea un salto sul giunto —
|
||||
# un difetto peggiore di quello che si voleva evitare. Si tiene il file coerente e si urla.
|
||||
print(f" {sym:5} ⚠️ TRONCATO: {c['primo']} contro {prev.index[0].date()} su disco "
|
||||
f"({c['persi_g']}g persi, {c['n']} barre contro {len(prev)}) -> FILE NON TOCCATO")
|
||||
time.sleep(1.2)
|
||||
continue
|
||||
if c.get("n", 0) > 0:
|
||||
out = df.copy()
|
||||
# ms epoch (come i parquet crypto), robusto alla risoluzione datetime64 (s/us/ns)
|
||||
@@ -135,10 +187,17 @@ def main():
|
||||
print(f" {sym:5} n={c.get('n',0):>5} {str(c.get('primo','')):>10}->{str(c.get('ultimo',''))} "
|
||||
f"{c.get('anni','?')}y flat={c.get('flat','?')} maxret={c.get('maxret%','?')}% "
|
||||
f"miss_bd={c.get('miss_bd','?')} gapL={c.get('gap_lunghi','?')} adj={c.get('adj_first/last','?')} [{c['status']}]"
|
||||
+ (f" split={c['splits']}" if c.get("splits") else ""))
|
||||
+ (f" split={c['splits']}" if c.get("splits") else "")
|
||||
+ (f" manca_a_quotazione={c['manca_a']}a" if c.get("manca_a", 0) >= 1.0 else ""))
|
||||
time.sleep(1.2) # pacing IB
|
||||
|
||||
print("-" * 104)
|
||||
guasti = [r for r in rep if r.get("status") in ("TRONCATO", "STORIA-CORTA")]
|
||||
if guasti:
|
||||
print(" ⚠️ STORIA MANCANTE (le certificazioni locali non la vedono: guardano DENTRO la serie):")
|
||||
for r in guasti:
|
||||
print(f" {r['sym']:5} [{r['status']}] parte dal {r['primo']} — "
|
||||
f"{r.get('manca_a', 0)}a dopo la quotazione, {r.get('persi_g', 0)}g persi vs disco")
|
||||
print(f" CERTIFICATI OK ({len(ok)}/{len(UNIVERSE)}): {ok}")
|
||||
sec_ok = [s for s in SECTORS if s in ok]
|
||||
print(f" settori OK: {len(sec_ok)}/11 {sec_ok}")
|
||||
|
||||
@@ -148,9 +148,24 @@ def main() -> None:
|
||||
rank_in = int((G["sh_in"] > prop['sh_in']).sum()) + 1
|
||||
rank_oos = int((G["sh_oos"] > prop['sh_oos']).sum()) + 1
|
||||
print(f" rango della proposta : {rank_in}/{len(G)} in-sample · {rank_oos}/{len(G)} sull'hold-out")
|
||||
if rank_oos < rank_in:
|
||||
print(" ⚠️ La proposta sta MEGLIO sull'hold-out che in-sample: e' la firma della")
|
||||
print(" selezione-sull'hold-out. Il gate si giudica su cosa succede scegliendo al buio.")
|
||||
print(" (⚠️ 2026-08-07: il confronto FRA I RANGHI e' stato ritirato come criterio — passa")
|
||||
print(" ~meta' delle celle per costruzione e sulla proposta si decideva su 0.001 di")
|
||||
print(" Sharpe. Resta stampato come descrizione. Vedi r0807_gtaa_gate_resolution.py.)")
|
||||
|
||||
# criterio decidibile: la proposta e' una BANDA, la cadenza e' gia' quella di produzione
|
||||
ev = PROPOSTA[0]
|
||||
riga = G[G["every"] == ev]
|
||||
blind = riga.sort_values("sh_in", ascending=False).iloc[0]
|
||||
hold = riga.sort_values("sh_oos", ascending=False).iloc[0]
|
||||
secondo = riga[riga["frac"] != blind["frac"]]["sh_in"].max()
|
||||
print(f"\n CRITERIO DECIDIBILE — a cadenza di produzione ({ev}), quale banda si sceglie?")
|
||||
print(f" sui soli dati pre-2015 : {blind['frac']:.0%} "
|
||||
f"(margine sulla 2ª: {blind['sh_in'] - secondo:+.4f} di Sharpe)")
|
||||
print(f" guardando l'hold-out : {hold['frac']:.0%} "
|
||||
"<- controllo positivo: se coincidessero, il gate non avrebbe potenza")
|
||||
if blind["frac"] == PROPOSTA[1] and hold["frac"] != PROPOSTA[1]:
|
||||
print(" -> la proposta e' la cella scelta AL BUIO, e chi avesse guardato l'hold-out")
|
||||
print(" ne avrebbe scelta un'altra: e' il contrario di una selezione-sull'hold-out.")
|
||||
print(f"\n Costo di scegliere al buio invece che a posteriori (hold-out): "
|
||||
f"{best['sh_oos'] - prop['sh_oos']:+.2f} di Sharpe")
|
||||
n_best = orders_per_year(cells[(int(best["every"]), float(best["frac"]))], CAP_REF,
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python
|
||||
"""r0807_gtaa_gate_resolution.py — il gate (A) della banda GTAA01 fallisce: e' la proposta o e' il gate?
|
||||
|
||||
CONTESTO. Il 27/07 `r0727_gtaa_band_gate.py` valido' la proposta "banda = 25% della gamba" e il
|
||||
suo gate (A) — SELEZIONE IN-SAMPLE — fu registrato cosi': «proposta 4/30 in-sample, 5/30 hold-out
|
||||
-> il rango NON migliora sull'hold-out, quindi non e' selection-on-holdout», e congelato in
|
||||
`tests/test_gtaa_band_gate.py::test_la_proposta_non_e_selezionata_sull_hold_out` come
|
||||
`rank_in <= rank_oos`. Il 07/08 quel test FALLISCE: 9 in-sample, 8 sull'hold-out.
|
||||
|
||||
Il codice non e' cambiato (`git log src/portfolio/gtaa.py` fermo al 26/07), quindi sono cambiati i
|
||||
DATI: `data/raw/` e' gitignored e il cron ri-scarica ogni notte i sei ETF con `ADJUSTED_LAST`, che
|
||||
IB rivede all'indietro a ogni dividendo. Un risultato validato si e' spostato senza che nessuno
|
||||
toccasse nulla.
|
||||
|
||||
Questo script non ripara il test: chiede se il criterio misura cio' che dichiara. Tre domande, in
|
||||
quest'ordine, perche' la seconda ha senso solo se la prima ha una risposta pulita:
|
||||
|
||||
(0) **IL DIFETTO TROVATO PER STRADA.** TLT — la gamba obbligazionaria — ha 10.5 anni di storia
|
||||
invece dei ~24 dalla sua quotazione (2002-07-22). Non e' successo il 07/08: e' cosi' da
|
||||
almeno il 24/06 (primo giro nel `logs/cron_daily.log`), quindi da PRIMA della validazione
|
||||
del 27/07 e probabilmente da sempre. Conseguenza diretta sul gate (A): l'in-sample
|
||||
(pre-2015) gira su CINQUE gambe e l'hold-out su SEI. Il criterio confronta il rango di una
|
||||
strategia con il rango di una strategia diversa.
|
||||
|
||||
(1) **RISOLUZIONE DEL CRITERIO.** Quanto vale un rango? Se la differenza fra il 9° e l'8° posto
|
||||
e' un millesimo di Sharpe, il verdetto non e' una misura. E la domanda decisiva non e'
|
||||
sulla proposta ma sul criterio: applicato a una cella QUALUNQUE della griglia, `rank_in <=
|
||||
rank_oos` quante volte passa? La somma dei ranghi e' la stessa nelle due finestre, quindi
|
||||
la risposta e' nota a priori — ~meta'. Un gate che una moneta passa la meta' delle volte
|
||||
non distingue una proposta onesta da una selezionata sull'hold-out.
|
||||
|
||||
(2) **IL CRITERIO DECIDIBILE.** La proposta del 27/07 e' una BANDA, non una cadenza: la cadenza
|
||||
settimanale e' gia' quella di produzione (`REBAL_EVERY=5`) e non era in discussione. La
|
||||
domanda onesta e' quindi «scegliendo la banda al buio sui soli dati pre-2015, a cadenza di
|
||||
produzione, quale esce?». E il controllo positivo obbligatorio: «e scegliendola
|
||||
sull'hold-out, esce la stessa?». Se le due selezioni danno la stessa cella, il gate non ha
|
||||
potenza; se danno celle diverse e la proposta e' quella IN-SAMPLE, la proposta e'
|
||||
l'opposto di una selezione-sull'hold-out.
|
||||
|
||||
(3) **ROBUSTEZZA AL DIFETTO (0).** (1) e (2) rifatti sull'universo a 5 gambe (senza TLT), che e'
|
||||
coerente fra le due finestre. Se il verdetto cambia, il difetto dei dati non e' un dettaglio.
|
||||
|
||||
uv run python scripts/research/r0807_gtaa_gate_resolution.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research"))
|
||||
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
|
||||
|
||||
import r0727_gtaa_band_gate as BG # noqa: E402
|
||||
from src.portfolio.gtaa import EQ_UNIVERSE, _close, gtaa_returns # noqa: E402
|
||||
|
||||
# quotazione reale dei sei ETF: serve a distinguere "la storia e' corta" da "il feed e' troncato"
|
||||
INCEPTION = {"SPY": "1993-01-22", "QQQ": "1999-03-10", "IWM": "2000-05-22",
|
||||
"TLT": "2002-07-22", "GLD": "2004-11-18", "HYG": "2007-04-04"}
|
||||
SENZA_TLT = tuple(a for a in EQ_UNIVERSE if a != "TLT")
|
||||
|
||||
|
||||
def cella(every: int, frac: float, universe=EQ_UNIVERSE, capital: float = BG.CAP_REF) -> pd.Series:
|
||||
"""Come `BG.run_cell`, ma con l'universo esplicito: serve al controllo (3)."""
|
||||
return gtaa_returns(universe=universe, capital=capital,
|
||||
band_usd=frac * capital / len(universe), every=every)
|
||||
|
||||
|
||||
def griglia(universe=EQ_UNIVERSE) -> pd.DataFrame:
|
||||
rows = []
|
||||
for every in BG.EVERY_GRID:
|
||||
for frac in BG.FRAC_GRID:
|
||||
s = cella(every, frac, universe)
|
||||
rows.append(dict(every=every, frac=frac,
|
||||
sh_in=BG.met(s.loc[: BG.HOLDOUT])["sharpe"],
|
||||
sh_oos=BG.met(s.loc[BG.HOLDOUT:])["sharpe"],
|
||||
sh_full=BG.met(s)["sharpe"]))
|
||||
G = pd.DataFrame(rows)
|
||||
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 scelta(G: pd.DataFrame, colonna: str, every: int | None = None) -> pd.Series:
|
||||
"""La cella scelta massimizzando `colonna`; con `every` fissato, la banda scelta a quella cadenza."""
|
||||
sub = G if every is None else G[G["every"] == every]
|
||||
return sub.sort_values(colonna, ascending=False).iloc[0]
|
||||
|
||||
|
||||
def sezione(titolo: str) -> None:
|
||||
print("\n" + "=" * 104)
|
||||
print(f" {titolo}")
|
||||
print("=" * 104)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# (0) il difetto: quante gambe ci sono davvero, e quando
|
||||
# ===========================================================================
|
||||
def parte_0() -> dict:
|
||||
sezione("(0) COPERTURA DELLE SEI GAMBE — l'in-sample e l'hold-out girano sullo stesso sleeve?")
|
||||
print("\n Una storia corta non e' un difetto: puo' essere la quotazione dello strumento, o il")
|
||||
print(" tetto della richiesta (`durationStr=\"30 Y\"` in fetch_ib_equities). E' un difetto solo")
|
||||
print(" se la serie parte DOPO entrambi.")
|
||||
print(f"\n {'gamba':>6}{'prima barra':>14}{'quotato dal':>14}{'mancano':>10}"
|
||||
f"{'barre':>8}{'pre-2015':>10}{'2015+':>8}")
|
||||
manca = {}
|
||||
for a in EQ_UNIVERSE:
|
||||
c = _close(a)
|
||||
primo = c.index[0].date()
|
||||
inc = pd.Timestamp(INCEPTION[a]).date()
|
||||
anni = (primo - inc).days / 365.25
|
||||
tetto = (c.index[-1] - pd.DateOffset(years=30)).date() # il piu' indietro che la richiesta arrivi
|
||||
pre = int((c.index < pd.Timestamp(BG.HOLDOUT, tz="UTC")).sum())
|
||||
post = len(c) - pre
|
||||
manca[a] = anni
|
||||
flag = " <- TRONCATA" if anni > 1.0 and (primo - tetto).days > 10 else (
|
||||
" (tetto 30Y)" if anni > 1.0 else "")
|
||||
print(f" {a:>6}{str(primo):>14}{str(inc):>14}{anni:>9.1f}a{len(c):>8}{pre:>10}{post:>8}{flag}")
|
||||
|
||||
n_pre = sum(1 for a in EQ_UNIVERSE if _close(a).index[0] < pd.Timestamp(BG.HOLDOUT, tz="UTC"))
|
||||
print(f"\n gambe vive PRE-2015 (in-sample) : {n_pre}/6")
|
||||
print(f" gambe vive 2015+ (hold-out) : {len(EQ_UNIVERSE)}/6")
|
||||
if n_pre < len(EQ_UNIVERSE):
|
||||
print("\n ⚠️ Il gate (A) confronta il rango di uno sleeve a 5 gambe (in-sample) con quello")
|
||||
print(" di uno sleeve a 6 (hold-out). Non e' rumore: e' un'altra strategia. E la")
|
||||
print(" gamba assente e' proprio quella che diversifica — le obbligazioni.")
|
||||
return manca
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# (1) risoluzione: quanto vale un rango, e quanto vale il criterio
|
||||
# ===========================================================================
|
||||
def parte_1(G: pd.DataFrame) -> dict:
|
||||
sezione("(1) RISOLUZIONE DEL CRITERIO — un rango e' una misura o un arrotondamento?")
|
||||
p = G[(G["every"] == BG.PROPOSTA[0]) & (G["frac"] == BG.PROPOSTA[1])].iloc[0]
|
||||
ri, ro = int(p["rank_in"]), int(p["rank_oos"])
|
||||
print(f"\n proposta (cadenza {BG.PROPOSTA[0]}, banda {BG.PROPOSTA[1]:.0%}): "
|
||||
f"rango {ri}/{len(G)} in-sample · {ro}/{len(G)} hold-out -> "
|
||||
f"criterio `rank_in <= rank_oos` {'PASS' if ri <= ro else 'FAIL'}")
|
||||
|
||||
si = np.sort(G["sh_in"].values)[::-1]
|
||||
so = np.sort(G["sh_oos"].values)[::-1]
|
||||
gap_in = si[ri - 2] - si[ri - 1] if ri >= 2 else float("nan")
|
||||
gap_oos = so[ro - 2] - so[ro - 1] if ro >= 2 else float("nan")
|
||||
spread = float(G["sh_in"].max() - G["sh_in"].min())
|
||||
print(f"\n distanza dal rango precedente : {gap_in:.5f} di Sharpe in-sample · "
|
||||
f"{gap_oos:.5f} sull'hold-out")
|
||||
print(f" spread dell'intera griglia : {spread:.4f}")
|
||||
print(f" il verdetto si decide su : {gap_in / spread:.2%} dello spread")
|
||||
|
||||
# la domanda vera: il criterio, applicato a una cella qualunque
|
||||
passa = int((G["rank_in"] <= G["rank_oos"]).sum())
|
||||
rho = float(G["sh_in"].corr(G["sh_oos"], method="spearman"))
|
||||
d = (G["rank_in"] - G["rank_oos"]).abs()
|
||||
print(f"\n ⚠️ IL CRITERIO APPLICATO A OGNI CELLA DELLA GRIGLIA: passa {passa}/{len(G)} "
|
||||
f"({passa / len(G):.0%})")
|
||||
print(f" Non e' un caso: la somma dei ranghi e' la stessa nelle due finestre, quindi")
|
||||
print(f" `rank_in <= rank_oos` e' vero per circa META' delle celle PER COSTRUZIONE.")
|
||||
print(f" Spostamento tipico fra le due finestre: mediana {d.median():.0f} ranghi, "
|
||||
f"massimo {d.max():.0f} (Spearman IS/OOS {rho:+.2f}).")
|
||||
print(f"\n Un gate che una cella a caso passa il {passa / len(G):.0%} delle volte non")
|
||||
print(f" distingue una proposta onesta da una selezionata sull'hold-out: e' una moneta.")
|
||||
return dict(rank_in=ri, rank_oos=ro, gap_in=float(gap_in), spread=spread,
|
||||
passa=passa, n=len(G), spearman=rho)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# (2) il criterio decidibile: la banda scelta al buio
|
||||
# ===========================================================================
|
||||
def parte_2(G: pd.DataFrame, etichetta: str = "sei gambe") -> dict:
|
||||
sezione(f"(2) IL CRITERIO DECIDIBILE — quale BANDA si sceglie al buio? [{etichetta}]")
|
||||
print("\n La proposta del 27/07 e' una banda, non una cadenza: `REBAL_EVERY=5` e' gia' la")
|
||||
print(" produzione e non era in discussione. Quindi la selezione si giudica a cadenza fissa.")
|
||||
|
||||
ev = BG.PROPOSTA[0]
|
||||
print(f"\n cadenza {ev} (produzione) — Sharpe per banda:")
|
||||
print(f" {'banda':>8}{'Sh in-sample':>15}{'Sh hold-out':>14}")
|
||||
sub = G[G["every"] == ev].sort_values("frac")
|
||||
for _, r in sub.iterrows():
|
||||
star = " <- proposta 27/07" if r["frac"] == BG.PROPOSTA[1] else ""
|
||||
print(f" {r['frac']:>8.0%}{r['sh_in']:>15.4f}{r['sh_oos']:>14.4f}{star}")
|
||||
|
||||
blind = scelta(G, "sh_in", every=ev)
|
||||
hold = scelta(G, "sh_oos", every=ev)
|
||||
marg = scelta(G, "sh_in")
|
||||
secondo = G[(G["every"] == ev) & (G["frac"] != blind["frac"])]["sh_in"].max()
|
||||
|
||||
print(f"\n banda scelta SUI SOLI DATI PRE-2015 : {blind['frac']:.0%}"
|
||||
f" (2ª: margine {blind['sh_in'] - secondo:+.4f} di Sharpe)")
|
||||
print(f" banda scelta SULL'HOLD-OUT : {hold['frac']:.0%}"
|
||||
f" <- controllo positivo: se coincidesse, il gate non avrebbe potenza")
|
||||
print(f" cella scelta al buio su TUTTA la griglia: cadenza {int(marg['every'])}, "
|
||||
f"banda {marg['frac']:.0%}")
|
||||
|
||||
ok_blind = blind["frac"] == BG.PROPOSTA[1]
|
||||
ok_diverso = hold["frac"] != BG.PROPOSTA[1]
|
||||
print(f"\n -> la banda proposta e' quella scelta al buio in-sample : "
|
||||
f"{'SI' if ok_blind else 'NO'}")
|
||||
print(f" -> scegliendo sull'hold-out uscirebbe una banda DIVERSA : "
|
||||
f"{'SI' if ok_diverso else 'NO'}")
|
||||
if ok_blind and ok_diverso:
|
||||
print("\n VERDETTO: la proposta e' l'ESATTO CONTRARIO di una selezione-sull'hold-out —")
|
||||
print(" e' la cella che si sceglie senza guardare l'hold-out, e chi avesse guardato")
|
||||
print(f" l'hold-out avrebbe scelto la banda {hold['frac']:.0%}, non quella proposta.")
|
||||
elif not ok_blind:
|
||||
print("\n VERDETTO: la banda proposta NON e' quella che si sceglie al buio -> la")
|
||||
print(" validazione del 27/07 va rifatta sulla cella scelta in-sample.")
|
||||
else:
|
||||
print("\n VERDETTO: le due selezioni coincidono -> il gate non ha potenza su questa")
|
||||
print(" griglia e non dice nulla sulla proposta (ne' a favore ne' contro).")
|
||||
print(f"\n margine del blind sulla 2ª: {blind['sh_in'] - secondo:.4f} di Sharpe, "
|
||||
f"contro i {abs(G['sh_in'].nlargest(9).iloc[-2] - G['sh_in'].nlargest(9).iloc[-1]):.5f}")
|
||||
print(f" su cui si decideva il criterio a ranghi. NON e' lo stesso ordine di grandezza.")
|
||||
print("\n ⚠️ Cio' che questo criterio NON dice: che la banda scelta in-sample sia la migliore")
|
||||
print(" sull'hold-out. Non lo e' — e con lo Spearman IS/OOS a ~0 (sopra) nessuna cella di")
|
||||
print(" questa griglia lo sarebbe in modo affidabile. Il gate (A) risponde alla domanda")
|
||||
print(" sulla PROVENIENZA della scelta («l'hold-out l'ha contaminata?»), non a quella")
|
||||
print(" sulla previsione. Confonderle e' esattamente il modo in cui si finisce a")
|
||||
print(" selezionare sull'hold-out credendo di validare.")
|
||||
return dict(blind=float(blind["frac"]), hold=float(hold["frac"]),
|
||||
margine=float(blind["sh_in"] - secondo),
|
||||
marg_every=int(marg["every"]), marg_frac=float(marg["frac"]),
|
||||
ok=bool(ok_blind and ok_diverso))
|
||||
|
||||
|
||||
def main() -> None:
|
||||
print("=" * 104)
|
||||
print(" r0807 — IL GATE (A) DELLA BANDA GTAA01 FALLISCE: e' la proposta o e' il criterio?")
|
||||
print("=" * 104)
|
||||
|
||||
parte_0()
|
||||
G6 = griglia(EQ_UNIVERSE)
|
||||
r1 = parte_1(G6)
|
||||
r2 = parte_2(G6, "sei gambe, universo di produzione")
|
||||
|
||||
# ------------------------------------------------------------------ (3)
|
||||
sezione("(3) ROBUSTEZZA AL DIFETTO (0) — stesso conto sull'universo COERENTE fra le due finestre")
|
||||
print(f"\n Universo a 5 gambe {SENZA_TLT}: nessuna gamba compare a meta' strada, quindi")
|
||||
print(" in-sample e hold-out girano sulla STESSA strategia. Se il verdetto di (2) cambia,")
|
||||
print(" il difetto dei dati non e' un dettaglio da nota a pie' di pagina.")
|
||||
G5 = griglia(SENZA_TLT)
|
||||
r2b = parte_2(G5, "cinque gambe, senza TLT")
|
||||
|
||||
p5 = G5[(G5["every"] == BG.PROPOSTA[0]) & (G5["frac"] == BG.PROPOSTA[1])].iloc[0]
|
||||
print(f"\n (per confronto, il criterio a ranghi sull'universo coerente: "
|
||||
f"{int(p5['rank_in'])}/{len(G5)} in-sample · {int(p5['rank_oos'])}/{len(G5)} hold-out "
|
||||
f"-> {'PASS' if p5['rank_in'] <= p5['rank_oos'] else 'FAIL'})")
|
||||
print(f" passa {int((G5['rank_in'] <= G5['rank_oos']).sum())}/{len(G5)} celle: "
|
||||
"la moneta resta una moneta anche togliendo il difetto.")
|
||||
|
||||
# ------------------------------------------------------------------ verdetto
|
||||
sezione("VERDETTO")
|
||||
print(f"""
|
||||
1. Il criterio `rank_in <= rank_oos` NON e' un gate di selezione: passa
|
||||
{r1['passa']}/{r1['n']} celle della griglia per costruzione, e sulla proposta si decideva su
|
||||
{r1['gap_in']:.5f} di Sharpe ({r1['gap_in'] / r1['spread']:.1%} dello spread). Il fallimento del 07/08 non e'
|
||||
una scoperta sulla proposta: e' rumore che ha attraversato una soglia senza margine.
|
||||
|
||||
2. Il criterio decidibile dice l'opposto, e con margine: a cadenza di produzione la banda
|
||||
scelta sui soli dati pre-2015 e' {r2['blind']:.0%} (= la proposta), con {r2['margine']:.4f} di Sharpe sulla
|
||||
seconda; chi avesse scelto guardando l'hold-out avrebbe preso {r2['hold']:.0%}. La proposta e'
|
||||
il contrario di una selezione-sull'hold-out.
|
||||
|
||||
3. Regge sull'universo coerente a 5 gambe: blind {r2b['blind']:.0%}, hold-out {r2b['hold']:.0%},
|
||||
margine {r2b['margine']:.4f}. Il difetto (0) non e' cio' che decide questo verdetto — ma resta
|
||||
un difetto, e va riparato per suo conto: TLT ha 10.5 anni invece di ~24, e nessuna
|
||||
certificazione se n'e' accorta perche' nessuna guardava la LUNGHEZZA.
|
||||
|
||||
NON cambia nulla in produzione: `REBAL_BAND_USD` resta $50, GTAA01 resta non deployabile
|
||||
(PRIIPs) e sotto GTAA_MIN_CAPITAL. Cambia il test, che ora misura una cosa decidibile, e
|
||||
cambia l'affermazione del 27/07 in CLAUDE.md, che citava dei ranghi.
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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