"""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"])