Files
PythagorasGoal/tests/test_gtaa_band_gate.py
T
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

224 lines
10 KiB
Python
Raw 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.
"""Test del gate onesto sulla banda GTAA01 (scripts/research/r0727_gtaa_band_gate.py).
Congela i tre esiti del gate e — soprattutto — l'ANNUALIZZAZIONE. Una serie su giorni di borsa
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.
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
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"))
sys.path.insert(0, str(ROOT / "scripts" / "research" / "alt"))
import r0727_gtaa_band_gate as BG # noqa: E402
@pytest.fixture(scope="module")
def grid():
"""Griglia ridotta alla sola cadenza della proposta: basta per i gate (B) e (C) e tiene
il test veloce. La griglia piena vive nello script."""
ref = BG.run_cell(1, 0.0, BG.CAP_REF)
out = {}
for frac in BG.FRAC_GRID:
s = BG.run_cell(BG.PROPOSTA[0], frac, BG.CAP_REF)
j = pd.concat([s, ref], axis=1, join="inner").dropna()
out[frac] = dict(series=s, met=BG.met(s),
corr=float(j.iloc[:, 0].corr(j.iloc[:, 1])))
return ref, out
# ===========================================================================
# annualizzazione — l'errore che produce numeri plausibili e sbagliati
# ===========================================================================
def test_annualizza_a_giorni_di_borsa():
assert BG.DPY == 252.0
def test_lo_sharpe_e_calcolato_a_252_non_a_365():
idx = pd.bdate_range("2020-01-01", periods=1000)
r = pd.Series(np.linspace(0.0004, 0.0006, 1000), index=idx)
v = r.values
atteso = v.mean() / v.std() * np.sqrt(252.0) # std di popolazione, come nel resto del progetto
assert BG.met(r)["sharpe"] == pytest.approx(atteso, rel=1e-12)
assert BG.met(r)["sharpe"] != pytest.approx(v.mean() / v.std() * np.sqrt(365.0), rel=1e-6)
# ===========================================================================
# la parametrizzazione: e' questa la proposta
# ===========================================================================
def test_la_banda_e_una_frazione_della_gamba_non_un_importo():
"""E' l'intera ragione della proposta: un importo assoluto ha effetti diversi a capitali
diversi, una frazione no."""
from src.portfolio.gtaa import EQ_UNIVERSE
for cap in (3_000.0, 10_000.0, 50_000.0):
assert 0.25 * cap / len(EQ_UNIVERSE) == pytest.approx(0.25 * cap / 6)
def test_la_produzione_usa_ancora_la_banda_ASSOLUTA():
"""Il gate valida una proposta che NON e' ancora in produzione. Se un giorno la produzione
passasse alla banda frazionaria, questo test si rompe e va riletto tutto il confronto."""
from src.portfolio.gtaa import REBAL_BAND_USD, REBAL_EVERY
assert (REBAL_EVERY, REBAL_BAND_USD) == BG.CANONICO == (5, 50.0)
# ===========================================================================
# (A) selezione — il gate di metodo
# ===========================================================================
@pytest.fixture(scope="module")
def griglia_piena():
rows = []
for every in BG.EVERY_GRID:
for frac in BG.FRAC_GRID:
s = BG.run_cell(every, frac, BG.CAP_REF)
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"]))
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 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():
"""2015+: l'OOS equity della validazione 22/06, indipendente dall'hold-out crypto."""
assert BG.HOLDOUT == "2015-01-01"
# ===========================================================================
# (B) deflated Sharpe
# ===========================================================================
def test_la_proposta_sopravvive_alla_deflazione(grid):
from altlib import deflated_sharpe
ref, cells = grid
all_sr = [c["met"]["sharpe"] for c in cells.values()]
s = cells[BG.PROPOSTA[1]]["series"]
dsr, _ = deflated_sharpe(BG.met(s)["sharpe"], all_sr, s.dropna().values, dpy=BG.DPY)
assert dsr >= 0.95
# ===========================================================================
# (C) tracking — e il modo di fallire, che NON e' il de-levering
# ===========================================================================
def test_al_25_percento_lo_sleeve_segue_ancora_se_stesso(grid):
_, cells = grid
assert cells[0.25]["corr"] >= BG.MIN_CORR
def test_oltre_il_25_percento_il_tracking_si_rompe(grid):
_, cells = grid
assert cells[0.40]["corr"] < BG.MIN_CORR
assert cells[0.60]["corr"] < cells[0.40]["corr"]
def test_la_banda_larga_non_de_leva(grid):
"""Il null de-levering classico NON morde qui: allargando la banda la vol non scende.
Se un giorno scendesse, il fallimento sarebbe di un altro tipo e il verdetto va riscritto."""
ref, cells = grid
vref = BG.met(ref)["vol"]
assert cells[0.60]["met"]["vol"] / vref > 0.95
def test_il_25_percento_e_al_bordo_non_al_centro(grid):
"""Onesta' sul margine: la proposta passa, ma di poco. Congelarlo evita che in futuro si
citi come un plateau largo."""
_, cells = grid
assert BG.MIN_CORR <= cells[0.25]["corr"] < BG.MIN_CORR + 0.02
# ===========================================================================
# perche' la proposta esiste: invarianza al capitale
# ===========================================================================
def test_la_banda_frazionaria_batte_quella_fissa_a_capitale_piccolo():
from src.portfolio.gtaa import gtaa_returns
cap = 3_000.0
frazionaria = BG.met(BG.run_cell(BG.PROPOSTA[0], BG.PROPOSTA[1], cap))["sharpe"]
fissa = BG.met(gtaa_returns(capital=cap, band_usd=50.0, every=5))["sharpe"]
assert frazionaria > fissa
def test_a_capitale_grande_le_due_configurazioni_convergono():
"""A $50k la banda fissa smette di essere una tassa: il vantaggio della proposta e' al
capitale PICCOLO, ed e' li' che va citato."""
from src.portfolio.gtaa import gtaa_returns
cap = 50_000.0
frazionaria = BG.met(BG.run_cell(BG.PROPOSTA[0], BG.PROPOSTA[1], cap))["sharpe"]
fissa = BG.met(gtaa_returns(capital=cap, band_usd=50.0, every=5))["sharpe"]
assert abs(frazionaria - fissa) < abs(
BG.met(BG.run_cell(BG.PROPOSTA[0], BG.PROPOSTA[1], 3_000.0))["sharpe"]
- BG.met(gtaa_returns(capital=3_000.0, band_usd=50.0, every=5))["sharpe"])