diff --git a/docs/diary/2026-08-07-gate-gtaa-e-storia-troncata.md b/docs/diary/2026-08-07-gate-gtaa-e-storia-troncata.md new file mode 100644 index 0000000..bc3f4ae --- /dev/null +++ b/docs/diary/2026-08-07-gate-gtaa-e-storia-troncata.md @@ -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'. diff --git a/scripts/research/fetch_ib_equities.py b/scripts/research/fetch_ib_equities.py index d0827a2..c030e95 100644 --- a/scripts/research/fetch_ib_equities.py +++ b/scripts/research/fetch_ib_equities.py @@ -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}") diff --git a/scripts/research/r0727_gtaa_band_gate.py b/scripts/research/r0727_gtaa_band_gate.py index 41328b0..0d2cbf8 100644 --- a/scripts/research/r0727_gtaa_band_gate.py +++ b/scripts/research/r0727_gtaa_band_gate.py @@ -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, diff --git a/scripts/research/r0807_gtaa_gate_resolution.py b/scripts/research/r0807_gtaa_gate_resolution.py new file mode 100644 index 0000000..1c1b12c --- /dev/null +++ b/scripts/research/r0807_gtaa_gate_resolution.py @@ -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() diff --git a/tests/test_eq_history_guard.py b/tests/test_eq_history_guard.py new file mode 100644 index 0000000..e693672 --- /dev/null +++ b/tests/test_eq_history_guard.py @@ -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") diff --git a/tests/test_gtaa_band_gate.py b/tests/test_gtaa_band_gate.py index 1cd3522..1465f76 100644 --- a/tests/test_gtaa_band_gate.py +++ b/tests/test_gtaa_band_gate.py @@ -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():