#!/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()