"""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 # ⚠️ 2026-08-28 — SECONDO CRITERIO RITIRATO, e per la stessa ragione del primo. # `test_la_banda_proposta_e_quella_scelta_al_buio` asseriva che la banda scelta sui soli dati # pre-2015 fosse la proposta. Ha smesso di passare senza che nessuno toccasse il codice: al buio # esce il 60%, con margine 0.0074 — sotto il pavimento di 0.01 che il test stesso si era dato. # Misurato in `r0828_gtaa_band_phase.py`: NON e' un fatto sulla banda, e' un fatto sul criterio. # · IB serve una finestra ROTOLANTE di 30 anni e SPY e' al muro: la sua prima barra avanza di # un giorno di borsa ogni notte. Il pre-2015 — che il progetto tratta come congelato — perde # il giorno piu' vecchio ogni notte. # · `gtaa._gated_returns` ribilancia su `i % every == 0`, cioe' sulla POSIZIONE nell'array: # se la prima barra scivola, si ri-fasa ogni decisione di trent'anni. La fase canonica cambia # da sola. # · Esito: su 10 notti consecutive la scelta al buio e' 25% / 40% / 60%, e il margine sta sotto # il pavimento in 4 notti su 10. A dato FISSO, muovendo la sola fase, il verdetto cambia lo # stesso (25% su 3 fasi, 60% su 2). # Come il 07/08 si congela il MOTIVO e non l'esito. La riparazione (ancorare la fase al calendario) # e la decisione su GTAA01 sono rinviate alla soglia di $15k di book — sotto quella soglia lo # sleeve non e' deployabile e la decisione non ha conseguenze (`journal.SOGLIE_CAPITALE`). def test_il_gate_A_non_ha_RISOLUZIONE_ed_e_per_questo_che_e_stato_RITIRATO(griglia_piena): """Congela il MOTIVO del secondo ritiro: il criterio decide su un margine piu' piccolo del rumore che lo scuote. Non asserisce QUALE cella vinca — quella si muove da sola ogni notte, ed e' esattamente il punto.""" G = griglia_piena riga = G[G["every"] == BG.PROPOSTA[0]] ordinata = riga.sort_values("sh_in", ascending=False) margine = float(ordinata["sh_in"].iloc[0] - ordinata["sh_in"].iloc[1]) spread = float(riga["sh_in"].max() - riga["sh_in"].min()) # il margine su cui il criterio decideva e' una FRAZIONE dello spread della griglia: # a quella scala la scelta non e' una misura, e' l'ordine in cui il rumore si e' posato. assert margine < 0.25 * spread, ( f"il margine ({margine:.4f}) e' tornato grande rispetto allo spread di griglia " f"({spread:.4f}): se il rumore si fosse ridotto, il gate (A) andrebbe RIABILITATO " "invece di restare ritirato — ed e' un fatto da guardare, non da ignorare") def test_la_fase_di_ribilanciamento_e_ancora_ancorata_alla_POSIZIONE(griglia_piena): """La guardia sulla CAUSA. Se un giorno `_gated_returns` ancorasse la fase al calendario, questo test si rompe e va riletto tutto: il gate (A) potrebbe tornare decidibile.""" import inspect src = inspect.getsource(BG.gtaa_returns.__globals__["_gated_returns"]) assert "i % every" in src, ( "la fase non e' piu' ancorata alla posizione nell'array: la ragione del ritiro del " "gate (A) e' venuta meno, rileggere r0828_gtaa_band_phase.py") 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") # `test_il_margine_del_blind_non_e_un_arrotondamento` (2026-08-07) chiedeva margine >= 0.01 e # diceva, testualmente: «Se il margine scendesse sotto un centesimo, anche questo criterio # smetterebbe di essere una misura e andrebbe ripensato a sua volta». Il 28/08 e' sceso a 0.0074. # Il test aveva ragione e si e' auto-ritirato: la sua condizione e' ora dentro il test sul MOTIVO # qui sopra, dove misura il margine CONTRO lo spread di griglia invece che contro un assoluto. 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"])