963776e5d2
Il test del gate sulla banda GTAA01 aveva smesso di passare senza che il codice fosse cambiato (data/raw/ e' gitignored, IB rivede ADJUSTED_LAST all'indietro ogni notte). Invece di allentare la soglia, misurata la risoluzione del criterio. `rank_in <= rank_oos` lo passano 14/30 celle (47%) PER COSTRUZIONE: la somma dei ranghi e' la stessa nelle due finestre. E sulla proposta si decideva su 0.00116 di Sharpe contro uno spread di griglia di 0.3124. Il 27/07 quel criterio passava, e passava per caso. Criterio sostituito con quello decidibile: la proposta e' una BANDA (la cadenza settimanale e' gia' produzione), quindi a cadenza fissa la banda scelta sui soli dati pre-2015 e' 25% = la proposta, margine +0.0151 (13x il vecchio); chi avesse scelto sull'hold-out avrebbe preso 40%. Controllo positivo incluso. Regge sull'universo coerente a 5 gambe. TROVATO PER STRADA: TLT parte dal 2016-02-03 invece che dalla quotazione (2002-07-22) → GTAA01 gira su CINQUE gambe prima del 2016, e quella assente e' la gamba obbligazionaria. Non e' di oggi (cosi' dal primo giro nel cron log del 24/06, prima della validazione) e non e' un fetch da rifare: una richiesta retro esplicita a IB ritorna 0 barre. Nessuna certificazione l'aveva visto perche' tutte guardano DENTRO la serie: una serie troncata e' integra, senza gap, senza spike, senza duplicati. Cablate due guardie in fetch_ib_equities.certify — TRONCATO (storia persa vs disco; il file NON viene sovrascritto ne' fuso, ADJUSTED_LAST e' ri-aggiustato all'indietro e il giunto creerebbe un salto) e STORIA-CORTA (parte dopo la quotazione, con distinzione dal tetto della richiesta 30Y). Controlli negativi obbligatori: giro normale, serie al cap, ETF giovane, simbolo fuori tabella. Produzione INVARIATA: REBAL_BAND_USD resta $50, GTAA01 resta non deployabile (PRIIPs). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
281 lines
16 KiB
Python
281 lines
16 KiB
Python
#!/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()
|