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