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>
224 lines
10 KiB
Python
224 lines
10 KiB
Python
"""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"])
|