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