Files
PythagorasGoal/tests/test_gtaa_band_gate.py
T
Adriano Dal Pastro 418f523be7 gtaa: la decisione tenere/bloccare va a $15k — gate (A) ritirato, soglia sorvegliata
Decisione dell'operatore: GTAA01 NON si blocca oggi, si decide quando il book
arriva a $15k. Il motivo registrato non e' «non contribuisce» — la misura dice
il contrario (+0,095/+0,124 di Sharpe a iso-rischio secondo la fase, positivo in
tutte e cinque, hold-out +0,247, 6 anni su 8, correlazione col resto +0,087).
Il motivo e' che sotto $15k di book lo sleeve NON E' ACCENDIBILE:
GTAA_MIN_CAPITAL e' $3.000 allocati, che al peso 20% fa $15.000 contro i $2.068
attuali. Finora era manutenzione senza beneficio incassabile.

Registrate anche le due ragioni contro il blocco, che restano valide: N7 (uno
sleeve difensivo si giudica sul sinistro, e questa finestra non lo contiene) e
N4 (togliendolo il portafoglio di ricerca diventa 100% cripto su un venue solo).
E il costo che sarebbe andato pagato: GTAA01 e' uno dei quattro nomi di
W_DEPLOY, da cui escono i $313k del muro.

PERCHE' LA DECISIONE NON SI DIMENTICHI (N9). Una decisione parcheggiata su un
numero che nessuno sorveglia e' parcheggiata per sempre. `journal.SOGLIE_CAPITALE`
legge l'equity ogni giorno — il giornale lo fa comunque — e il giorno che supera
la soglia la voce dice quale decisione si sblocca e perche'. Seconda riga: $20k,
dove si riapre «100% Deribit fino a $20k». Una riga si TOGLIE quando la decisione
e' presa. 5 test, incluso «equity non leggibile non e' una soglia superata» (P5).

GATE (A) RITIRATO come pass/fail, congelando il MOTIVO e non l'esito — come il
07/08 col confronto fra ranghi, e per la stessa ragione: il criterio decide su un
margine piu' piccolo del rumore che lo scuote. In piu' una guardia sulla CAUSA
(`test_la_fase_di_ribilanciamento_e_ancora_ancorata_alla_POSIZIONE`) che si rompe
il giorno che qualcuno ancorasse la fase al calendario: quel giorno il gate
potrebbe tornare decidibile, ed e' un fatto da guardare, non da ignorare.
Il test sul margine assoluto si e' auto-ritirato: diceva «se scendesse sotto un
centesimo questo criterio smetterebbe di essere una misura», ed e' sceso a 0,0074.

CLAUDE.md: §3 nuova riga · §5.2 RISOLTO (trasporto allarmi) · §5.4 riscritto (la
domanda fiscale (a) ha una risposta alla fonte: derivati in c-quater al 26%, non
c-sexies al 33% — Circolare AdE 30/E del 27/10/2023, citata verbatim) · §5.13
nuovo debito, la fase che ruota — quantificata e dichiarata INNOCUA oggi (D5):
0,029 di Sharpe a livello di portafoglio, dentro la banda [1,81-2,12].

Diario: docs/diary/2026-08-28-gtaa-fase-e-allarmi.md
Suite: 789 passati, 0 falliti.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 12:54:08 +00:00

251 lines
13 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
# ⚠️ 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"])