Files
Adriano Dal Pastro 963776e5d2 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>
2026-08-07 19:30:34 +00:00

276 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
"""r0727_gtaa_band_gate.py — la banda al 25% della gamba passa i gate, o e' una cella scelta bene?
CONTESTO. Il 27/07 e' stata proposta una configurazione d'esecuzione nuova per GTAA01 — banda =
**25% della gamba** invece dei $50 assoluti, cadenza settimanale invariata — perche' la banda in
dollari assoluti degenera al variare del capitale (a $3.000 una banda da $400 e' l'80% della
gamba: 3 ordini l'anno e a mercato il 45% del tempo invece del 66%). La proposta e' stata
dichiarata esplicitamente **non validata**: "tarata su questa finestra, non passata per
`study_family_honest` ne' per un deflated-Sharpe".
Questo script fa quella validazione. NON e' un gate sui pesi (quello resta `weights_tilt_null`):
la banda e' un parametro di **esecuzione**, non di allocazione. Ma un parametro d'esecuzione
scelto guardando il risultato e' selezione come qualunque altra, e va trattato come tale.
TRE GATE, e uno e' specifico di questo tipo di parametro:
(A) **SELEZIONE IN-SAMPLE** — la cella si sceglie con i soli dati PRE-2015 e si legge sul
2015+, l'hold-out equity documentato di GTAA01 (validazione 2026-06-22) e indipendente
dall'hold-out crypto. Se la cella scelta al buio non e' quella proposta, la proposta e'
selection-on-holdout.
(B) **DEFLATED SHARPE** su TUTTA la griglia cercata (Bailey & Lopez de Prado, PASS >= 0.95),
con `dpy=252`: e' una serie su giorni di BORSA, e passarla a 365 gonfia lo Sharpe di
×1.20 (l'errore di annualizzazione del 25/07).
(C) **NULL DE-LEVERING**, che qui indossa la veste "meno costi" invece di "meno drawdown":
una banda larga puo' migliorare i numeri semplicemente smettendo di seguire il target.
Il controllo non e' lo Sharpe ma quanto la serie bandata **assomiglia ancora** a quella
non bandata (correlazione e rapporto di volatilita' contro il riferimento banda=0,
cadenza=1). E' la 4a occorrenza dello stesso null in questo progetto.
⚠️ Il controllo (C) qui e' in coordinate diverse da quello del 27/07 (che misurava la quota di
tempo a mercato sulla finestra UCITS corta, 66% vs 45%): la API pubblica di `gtaa.py` non espone
le posizioni tenute, e la correlazione col riferimento misura la stessa cosa — se lo sleeve si
congela, smette di somigliare a se' stesso. Le due letture sono complementari, non alternative.
uv run python scripts/research/r0727_gtaa_band_gate.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" / "alt"))
from altlib import deflated_sharpe # noqa: E402
from src.portfolio.gtaa import ( # noqa: E402
EQ_UNIVERSE, GTAA_MIN_CAPITAL, REBAL_BAND_USD, REBAL_EVERY, gtaa_returns,
)
DPY = 252.0 # giorni di BORSA: annualizzare a 365 gonfia Sharpe ×1.20
HOLDOUT = "2015-01-01" # hold-out equity documentato di GTAA01 (validazione 22/06)
CAPITALS = (3_000.0, 10_000.0, 50_000.0)
CAP_REF = 10_000.0
EVERY_GRID = (1, 5, 10, 21, 42)
FRAC_GRID = (0.0, 0.05, 0.10, 0.25, 0.40, 0.60)
PROPOSTA = (5, 0.25) # cadenza settimanale, banda = 25% della gamba
CANONICO = (REBAL_EVERY, REBAL_BAND_USD)
MIN_CORR = 0.95 # sotto: la banda non filtra piu', congela
def met(s: pd.Series) -> dict:
v = s.dropna().values.astype(float)
if len(v) < 30 or v.std() == 0:
return dict(sharpe=float("nan"), cagr=float("nan"), maxdd=float("nan"), vol=float("nan"))
eq = (1.0 + v).cumprod()
return dict(sharpe=float(v.mean() / v.std() * np.sqrt(DPY)),
cagr=float(eq[-1] ** (DPY / len(v)) - 1.0),
maxdd=float((1.0 - eq / np.maximum.accumulate(eq)).max()),
vol=float(v.std() * np.sqrt(DPY)))
def run_cell(every: int, frac: float, capital: float) -> pd.Series:
"""La banda e' una FRAZIONE della gamba: cosi' il parametro non dipende dal capitale."""
band = frac * capital / len(EQ_UNIVERSE)
return gtaa_returns(capital=capital, band_usd=band, every=every)
def orders_per_year(s: pd.Series, capital: float, every: int, frac: float) -> float:
"""Ordini l'anno stimati: il conteggio esatto vive dentro `_gated_returns`, qui si usa il
piano pubblico su una griglia di controlli — proporzionale e sufficiente al confronto."""
from src.portfolio.gtaa import _close, _exposure
band = frac * capital / len(EQ_UNIVERSE)
n = 0
for sym in EQ_UNIVERSE:
e = _exposure(_close(sym)).dropna()
held = 0.0
for i, tgt in enumerate(e.values):
if i % every:
continue
if abs(tgt - held) * (capital / len(EQ_UNIVERSE)) >= max(band, 1.0):
held = float(tgt)
n += 1
span = max((s.index[-1] - s.index[0]).days / 365.25, 1e-9)
return n / span
def main() -> None:
print("=" * 104)
print(" r0727 — GATE ONESTO SULLA BANDA GTAA01 (proposta: 25% della gamba, settimanale)")
print("=" * 104)
ref = run_cell(1, 0.0, CAP_REF) # riferimento: nessuna banda, controllo giornaliero
print(f" storia {ref.index[0].date()} -> {ref.index[-1].date()} "
f"({len(ref) / DPY:.1f} anni di borsa) · annualizzazione √{DPY:.0f} · "
f"capitale di riferimento ${CAP_REF:,.0f}")
print(f" canonico in produzione: ogni {CANONICO[0]} barre, banda ${CANONICO[1]:.0f} "
f"(= {CANONICO[1] / (CAP_REF / 6):.0%} della gamba a ${CAP_REF:,.0f})")
# ---------------------------------------------------------------- griglia
cells: dict[tuple[int, float], pd.Series] = {}
rows = []
for every in EVERY_GRID:
for frac in FRAC_GRID:
s = run_cell(every, frac, CAP_REF)
cells[(every, frac)] = s
full = met(s)
ins = met(s.loc[: HOLDOUT])
oos = met(s.loc[HOLDOUT:])
j = pd.concat([s, ref], axis=1, join="inner").dropna()
corr = float(j.iloc[:, 0].corr(j.iloc[:, 1]))
rows.append(dict(every=every, frac=frac, sh_full=full["sharpe"], sh_in=ins["sharpe"],
sh_oos=oos["sharpe"], cagr=full["cagr"], vol=full["vol"],
corr=corr, volratio=full["vol"] / met(ref)["vol"]))
G = pd.DataFrame(rows)
print("\n" + "=" * 104)
print(" GRIGLIA — Sharpe in-sample (pre-2015) / hold-out (2015+) / full, e somiglianza al riferimento")
print("=" * 104)
print(f"\n {'cadenza':>8}{'banda%':>8}{'Sh IS':>8}{'Sh OOS':>9}{'Sh FULL':>9}"
f"{'CAGR':>8}{'vol':>7}{'corr rif':>10}{'vol/rif':>9}")
for _, r in G.iterrows():
star = " <- proposta" if (r['every'], r['frac']) == PROPOSTA else ""
print(f" {int(r['every']):>8}{r['frac']:>8.0%}{r['sh_in']:>8.2f}{r['sh_oos']:>9.2f}{r['sh_full']:>9.2f}"
f"{r['cagr']:>8.2%}{r['vol']:>7.1%}{r['corr']:>10.3f}{r['volratio']:>9.2f}{star}")
# ---------------------------------------------------------------- (A) selezione in-sample
print("\n" + "=" * 104)
print(" (A) SELEZIONE IN-SAMPLE — quale cella si sceglie guardando SOLO il pre-2015?")
print("=" * 104)
best = G.sort_values("sh_in", ascending=False).iloc[0]
prop = G[(G["every"] == PROPOSTA[0]) & (G["frac"] == PROPOSTA[1])].iloc[0]
print(f"\n cella scelta al buio : cadenza {int(best['every'])}, banda {best['frac']:.0%} "
f"-> IS {best['sh_in']:.2f} · OOS {best['sh_oos']:.2f} · FULL {best['sh_full']:.2f}")
print(f" cella proposta 27/07 : cadenza {PROPOSTA[0]}, banda {PROPOSTA[1]:.0%} "
f"-> IS {prop['sh_in']:.2f} · OOS {prop['sh_oos']:.2f} · FULL {prop['sh_full']:.2f}")
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")
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,
int(best["every"]), float(best["frac"]))
n_prop = orders_per_year(cells[PROPOSTA], CAP_REF, *PROPOSTA)
print(f"\n ⚠️ La cella scelta al buio controlla il target OGNI GIORNO ({n_best:.0f} ordini/anno)")
print(f" contro la cadenza settimanale della proposta ({n_prop:.0f} ordini/anno). Il conto")
print(f" di esecuzione (Degiro/Revolut) NON ha API: 250 controlli manuali l'anno non sono")
print(f" una configurazione, sono un'ipotesi. La proposta costa "
f"{prop['sh_full'] - best['sh_full']:+.2f} di Sharpe FULL e")
print(" compra una cadenza eseguibile a mano. E' un vincolo dichiarato, non una scoperta.")
# ---------------------------------------------------------------- (B) deflated Sharpe
print("\n" + "=" * 104)
print(f" (B) DEFLATED SHARPE sulla griglia intera ({len(G)} celle), dpy={DPY:.0f}")
print("=" * 104)
all_sr = list(G["sh_full"].values)
for label, cell in (("proposta 27/07", PROPOSTA),
("scelta in-sample", (int(best['every']), float(best['frac'])))):
s = cells[cell]
dsr, null_max = deflated_sharpe(met(s)["sharpe"], all_sr, s.dropna().values, dpy=DPY)
print(f"\n {label:<18} cadenza {cell[0]}, banda {cell[1]:.0%} · "
f"Sharpe {met(s)['sharpe']:.2f} vs massimo atteso sotto il nullo {null_max:.2f}")
print(f" {'':<18} DSR = {dsr:.3f} -> {'PASS' if dsr >= 0.95 else 'FAIL'} (soglia 0.95)")
# ---------------------------------------------------------------- (C) null de-levering
print("\n" + "=" * 104)
print(" (C) NULL DE-LEVERING — la banda filtra, o congela?")
print("=" * 104)
print(f"\n Riferimento = banda 0%, controllo giornaliero. Una banda che FILTRA lascia la")
print(f" serie quasi identica (corr >= {MIN_CORR}); una che CONGELA la stacca.")
print(f"\n {'banda%':>8}{'corr rif':>11}{'vol/rif':>10}{'Sh FULL':>10} verdetto")
for frac in FRAC_GRID:
r = G[(G["every"] == PROPOSTA[0]) & (G["frac"] == frac)].iloc[0]
v = "OK" if r['corr'] >= MIN_CORR else "❌ FUORI TRACCIA"
print(f" {frac:>8.0%}{r['corr']:>11.3f}{r['volratio']:>10.2f}{r['sh_full']:>10.2f} {v}")
print("\n ⚠️ Il modo di fallire NON e' quello atteso, e va detto: allargando la banda la")
print(" volatilita' NON scende (vol/rif resta 0.99-1.10), quindi qui il null de-levering")
print(" classico non morde. Cio' che si rompe e' il TRACKING: la serie smette di")
print(" somigliare a se' stessa perche' tiene posizioni vecchie. E i due segnali")
print(" CONCORDANO — oltre il 25% la correlazione cala E lo Sharpe smette di migliorare")
print(" (0.70 -> 0.67 -> 0.59): non c'e' una zona in cui il numero premia il congelamento.")
print(f" Ma il 25% e' AL BORDO della regione (corr {G[(G['every'] == 5) & (G['frac'] == 0.25)].iloc[0]['corr']:.3f} "
f"contro una soglia di {MIN_CORR}): non e' un plateau centrale.")
# ---------------------------------------------------------------- invarianza
print("\n" + "=" * 104)
print(" INVARIANZA AL CAPITALE — il motivo per cui la proposta esiste")
print("=" * 104)
print(f"\n {'capitale':>10}{'banda 25%':>12}{'ord/anno':>11}{'Sharpe':>9}{'corr rif':>10}"
f" | {'banda $50 fissa':>16}{'ord/anno':>11}{'Sharpe':>9}")
for cap in CAPITALS:
s_f = run_cell(PROPOSTA[0], PROPOSTA[1], cap)
s_a = gtaa_returns(capital=cap, band_usd=CANONICO[1], every=CANONICO[0])
n_f = orders_per_year(s_f, cap, PROPOSTA[0], PROPOSTA[1])
n_a = orders_per_year(s_a, cap, CANONICO[0], CANONICO[1] / (cap / 6))
j = pd.concat([s_f, ref], axis=1, join="inner").dropna()
print(f" ${cap:>9,.0f}{PROPOSTA[1] * cap / 6:>11,.0f}${n_f:>11.0f}{met(s_f)['sharpe']:>9.2f}"
f"{float(j.iloc[:, 0].corr(j.iloc[:, 1])):>10.3f} | "
f"{CANONICO[1]:>15,.0f}${n_a:>11.0f}{met(s_a)['sharpe']:>9.2f}")
print(f"\n (sotto ${GTAA_MIN_CAPITAL:,.0f} lo sleeve non e' deployabile comunque: "
"GTAA_MIN_CAPITAL)")
# ---------------------------------------------------------------- impatto sul book
print("\n" + "=" * 104)
print(" IMPATTO SUL BOOK — il numero che decide se toccare la produzione")
print("=" * 104)
from src.portfolio.portfolio import combine_outer
from src.portfolio.sleeves import GTAA_BOOK_ACTIVATION, active_sleeves
def as_book(gtaa_series: pd.Series | None) -> pd.Series:
cols, w = {}, {}
for sl in active_sleeves():
s = sl.daily()
if sl.name == "GTAA01_eq_trend" and gtaa_series is not None:
g = gtaa_series.dropna().sort_index()
if g.index.tz is None:
g.index = g.index.tz_localize("UTC")
days = pd.date_range(g.index.min().normalize(), g.index.max().normalize(),
freq="1D", tz="UTC")
s = g.reindex(days).fillna(0.0)
s = s[s.index >= GTAA_BOOK_ACTIVATION]
cols[sl.name], w[sl.name] = s, sl.weight
return combine_outer(cols, w)
def bk(s: pd.Series) -> dict:
v = s.dropna().values.astype(float)
eq = (1.0 + v).cumprod()
return dict(sharpe=float(v.mean() / v.std() * np.sqrt(365.0)),
maxdd=float((1.0 - eq / np.maximum.accumulate(eq)).max()))
b_can = bk(as_book(None))
b_new = bk(as_book(run_cell(PROPOSTA[0], PROPOSTA[1], CAP_REF)))
print(f"\n book 5-sleeve, banda CANONICA $50 : Sharpe FULL {b_can['sharpe']:.3f} · "
f"maxDD {b_can['maxdd']:.2%}")
print(f" book 5-sleeve, banda PROPOSTA 25% : Sharpe FULL {b_new['sharpe']:.3f} · "
f"maxDD {b_new['maxdd']:.2%}")
print(f" delta : {b_new['sharpe'] - b_can['sharpe']:+.3f} Sharpe · "
f"{(b_new['maxdd'] - b_can['maxdd']) * 100:+.2f} pt di maxDD")
print("\n ⚠️ Il book si annualizza a 365 (griglia giornaliera piena, weekend = 0), lo sleeve")
print(" a 252 (giorni di borsa): i due Sharpe NON sono confrontabili fra loro, solo")
print(" ciascuno con se' stesso fra le due configurazioni.")
if __name__ == "__main__":
main()