research(gtaa): criterio (C) — la mediana delle 5 fasi regge come strumento, e sceglie il 60%

Analisi richiesta prima di decidere che fare del gate (A). Il criterio: a cadenza
di produzione, la banda si sceglie sulla MEDIANA delle 5 fasi di ribilanciamento
invece che sulla fase capitata (M7: su ancore appaiate la statistica e' la
mediana). Toglie la componente di FASE; resta quella di DATO.

⚠️ DICHIARATO IN TESTA ALLO SCRIPT: (C) e' stato scelto DOPO aver visto la
tabella delle fasi di r0828_gtaa_band_phase. Sceglierne uno nuovo guardando
l'esito del vecchio e' selezione. Quindi (C) misura lo STRUMENTO — risoluzione
e potenza — e NON valida la proposta del 27/07. Pavimento tenuto a 0.01, quello
che il test si diede il 07/08: il metro non si ritocca sul risultato.

ESITO, coi tre criteri calcolati a runtime:
  (i)   scelta stabile su 10 notti : SI — ['60%'] in tutte e dieci
  (ii)  margine sempre >= 0.01     : SI — minimo 0.0197 (fase singola: 0.0026,
        e sotto il pavimento in 4 notti su 10)
  (iii) potenza (in-sample ≠ hold-out) : SI — al buio 60%, sull'hold-out 40%
Lo strumento funziona: l'escursione dello Sharpe in-sample su 10 notti cala del
74-99% per ogni banda (60%: da 0.1120 a 0.0006).

MA LA CELLA CHE SCEGLIE NON E' LA PROPOSTA. Al buio esce il **60%**; il 25%
vince **0 notti su 10**. Da tenere accanto: sull'hold-out il 60% e' penultimo
(0.7430 contro 0.9315 del 40%), coerente con lo Spearman IS/OOS ~0 misurato il
07/08 — la scelta in-sample non predice, e non va letta come "la banda giusta".

CONTORNO che vale piu' del verdetto: le 5 serie di fase correlano **0.988**,
quindi N_eff = 1.01. La mediana di 5 fasi e' UNA osservazione: toglie
l'artefatto, non compra precisione. Va citata come robustezza alla fase, mai
come campione (§2: «positivo in N/N ancore NON e' N osservazioni»).

Nessuna decisione presa qui: il gate (A) non e' toccato e i due test restano
rossi in attesa della scelta dell'operatore.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Adriano Dal Pastro
2026-08-28 08:51:43 +00:00
parent 31f82e1730
commit c21a0d4058
@@ -0,0 +1,231 @@
#!/usr/bin/env python
"""r0828_gtaa_band_median_phase.py — criterio (C): la banda giudicata sulla MEDIANA delle 5 fasi.
⚠️ DA LEGGERE PRIMA DI CITARE QUALUNQUE NUMERO DI QUI.
Questo criterio e' stato scelto **DOPO** aver visto la tabella delle fasi di
`r0828_gtaa_band_phase.py`, che mostra il criterio a fase singola cambiare verdetto da una notte
all'altra. Sceglierne uno nuovo guardando l'esito del vecchio e' selezione, esattamente come
scegliere una cella guardando l'hold-out. Quindi:
· questo script NON valida la proposta del 27/07 e NON puo' essere citato come sua conferma;
· l'unica domanda che puo' porre onestamente e' quella sullo STRUMENTO — «un criterio costruito
cosi' avrebbe risoluzione e potenza?» — che si risponde senza guardare se la cella che vince
e' quella che ci piace;
· se la risposta e' si', il criterio va poi DICHIARATO e la validazione RIFATTA da capo su di
esso, con la sua data. Il verdetto di oggi sulla banda resta *non decidibile*.
L'IDEA. Il difetto misurato in `r0828_gtaa_band_phase.py` e' che `gtaa._gated_returns` fasa il
ribilanciamento sulla POSIZIONE nell'array (`i % every == 0`), e la prima barra di SPY scivola di
un giorno ogni notte perche' IB serve una finestra rotolante di 30 anni. La fase canonica quindi
cambia da sola. La mediana sulle 5 fasi toglie di mezzo la componente di FASE — e' la stessa
mossa che il progetto fa gia' in `r0726_loo_deluck` (M7: su ancore appaiate la statistica e' la
mediana). Resta la componente di DATO: le barre perse. La domanda e' se basti.
I CRITERI, DICHIARATI QUI PRIMA DI GUARDARE I NUMERI. Il pavimento resta **0.01**, quello che il
test si diede il 07/08: non lo si ritocca dopo aver visto l'esito, o si sta tarando il metro sul
risultato. Il criterio (C) ha risoluzione e potenza se e solo se, sulla statistica mediana:
(i) la banda scelta al buio e' LA STESSA in tutte le NOTTI notti simulate;
(ii) il margine sulla seconda resta >= 0.01 in tutte;
(iii) POTENZA — la banda scelta sull'hold-out e' DIVERSA da quella scelta al buio. Se
coincidono, il gate lo passerebbe anche una proposta selezionata sull'hold-out, e non
distingue niente (controllo positivo, come il 07/08).
E una lettura di contorno che vale piu' del verdetto: la mediana di 5 fasi non e' una mediana di
5 osservazioni. Si stampa la correlazione media fra le serie di fase e l'N_eff che ne segue
(§2 del CLAUDE.md: «positivo in N/N ancore NON e' N osservazioni»).
uv run python scripts/research/r0828_gtaa_band_median_phase.py
"""
from __future__ import annotations
import sys
from functools import lru_cache
from pathlib import Path
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
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
import src.portfolio.gtaa as G # noqa: E402
NOTTI = 10
MARGINE_MINIMO = 0.01 # invariato dal 07/08: il metro non si ritocca sul risultato
EVERY = BG.PROPOSTA[0]
FASI = tuple(range(EVERY))
PROPOSTA_BANDA = BG.PROPOSTA[1]
ROTOLANTE = ("SPY",) # l'unica gamba al muro dei 30 anni (misurato in r0828_gtaa_band_phase)
@lru_cache(maxsize=None)
def _serie(sym: str, taglio: int):
"""(prezzi, esposizione) di una gamba, tagliata in testa di `taglio` barre. In cache: e' il
pezzo caro (l'esposizione e' un doppio ciclo su 30 anni) e non dipende da banda ne' fase."""
close = G._close(sym)
if taglio:
close = close.iloc[taglio:]
ex = np.nan_to_num(np.asarray(G._exposure(close).values, float))
return close, ex
def _leg(sym: str, cap_leg: float, band_usd: float, ph: int, taglio: int) -> pd.Series:
close, ex = _serie(sym, taglio)
px = close.values.astype(float)
ret = np.zeros(len(px))
ret[1:] = px[1:] / px[:-1] - 1.0
held = np.empty(len(ex))
comm = np.zeros(len(ex))
cur = 0.0
for i in range(len(ex)):
if i % EVERY == ph:
notional = abs(ex[i] - cur) * cap_leg
if notional >= max(band_usd, G.IB_MIN_TRADE_USD):
comm[i] = G.ib_commission(notional, px[i]) / cap_leg
cur = ex[i]
held[i] = cur
pos = np.zeros(len(held))
pos[1:] = held[:-1]
net = pos * ret - comm
net[0] = 0.0
return pd.Series(net, index=close.index)
def cella(frac: float, ph: int, taglio: int) -> pd.Series:
cap_leg = BG.CAP_REF / len(G.EQ_UNIVERSE)
band = frac * BG.CAP_REF / len(G.EQ_UNIVERSE)
cols = {a: _leg(a, cap_leg, band,
ph if a in ROTOLANTE else 0,
taglio if a in ROTOLANTE else 0)
for a in G.EQ_UNIVERSE}
return pd.concat(cols, axis=1, sort=True).sort_index().mean(axis=1, skipna=True).dropna()
def sh(frac: float, ph: int, taglio: int, finestra: str) -> float:
s = cella(frac, ph, taglio)
s = s.loc[: BG.HOLDOUT] if finestra == "in" else s.loc[BG.HOLDOUT:]
return BG.met(s)["sharpe"]
def mediana_fasi(frac: float, taglio: int, finestra: str = "in") -> float:
return float(np.median([sh(frac, ph, taglio, finestra) for ph in FASI]))
def scelta(valori: dict) -> tuple[float, float]:
best = max(valori, key=valori.get)
return best, valori[best] - max(v for f, v in valori.items() if f != best)
def sanity() -> bool:
mia = cella(PROPOSTA_BANDA, 0, 0)
vera = BG.run_cell(EVERY, PROPOSTA_BANDA, BG.CAP_REF)
j = pd.concat({"m": mia, "v": vera}, axis=1, join="outer")
d = float((j["m"] - j["v"]).abs().max())
ok = bool(np.isfinite(d) and d < 1e-12 and len(j) == len(vera))
print(f"[SANITY] replica (fase 0, taglio 0) vs produzione: max|Δ| = {d:.2e} -> {'OK' if ok else 'DIVERGE'}")
return ok
def main() -> int:
print(__doc__.split("\n\n")[0])
print("\n⚠️ criterio scelto DOPO aver visto l'esito del precedente: misura lo STRUMENTO, "
"non valida la banda.\n")
if not sanity():
return 2
# ---------------------------------------------------------------- contorno: quante osservazioni
print("=" * 100)
print("CONTORNO — la mediana di 5 fasi, quante osservazioni sono davvero?")
print("=" * 100)
serie = {ph: cella(PROPOSTA_BANDA, ph, 0).loc[: BG.HOLDOUT] for ph in FASI}
M = pd.concat(serie, axis=1, join="inner").dropna()
C = M.corr().values
rho = float((C.sum() - len(FASI)) / (len(FASI) * (len(FASI) - 1)))
n_eff = len(FASI) / (1.0 + (len(FASI) - 1) * rho)
print(f" correlazione media fra le 5 serie di fase (banda {PROPOSTA_BANDA:.0%}, in-sample): {rho:.3f}")
print(f" N_eff = {len(FASI)} / (1 + {len(FASI)-1}·ρ) = {n_eff:.2f}")
print(" -> la mediana media su ~2 osservazioni indipendenti, non su 5: toglie la fase,")
print(" non compra precisione. Va citata come robustezza alla fase, mai come campione.")
# ---------------------------------------------------------------- (i) e (ii)
print("\n" + "=" * 100)
print(f"(C) LA MEDIANA DELLE 5 FASI — banda scelta al buio, su {NOTTI} notti di finestra rotolante")
print("=" * 100)
spy = G._close("SPY")
righe, righe_singola = [], []
for k in range(NOTTI):
med = {f: mediana_fasi(f, k) for f in BG.FRAC_GRID}
b, m = scelta(med)
righe.append(dict(notte=k, inizio_SPY=str(spy.index[k].date()), scelta=f"{b:.0%}",
margine=round(m, 4), **{f"{f:.0%}": round(med[f], 4) for f in BG.FRAC_GRID}))
sing = {f: sh(f, 0, k, "in") for f in BG.FRAC_GRID}
bs, ms = scelta(sing)
righe_singola.append(dict(notte=k, scelta=f"{bs:.0%}", margine=round(ms, 4)))
D = pd.DataFrame(righe)
pd.set_option("display.width", 250)
print(D.to_string(index=False))
scelte = set(D.scelta)
quota = float((D.scelta == f"{PROPOSTA_BANDA:.0%}").mean())
print(f"\n celle distinte scelte: {sorted(scelte)} | la proposta ({PROPOSTA_BANDA:.0%}) vince {quota:.0%} delle notti")
print(f" margine: min {D.margine.min():.4f} mediana {D.margine.median():.4f} max {D.margine.max():.4f}")
print(f" notti sotto il pavimento {MARGINE_MINIMO}: {int((D.margine < MARGINE_MINIMO).sum())}/{NOTTI}")
S = pd.DataFrame(righe_singola)
print("\n confronto diretto col criterio a FASE SINGOLA (quello che fallisce oggi):")
print(f" fase singola : celle {sorted(set(S.scelta))} "
f"| sotto pavimento {int((S.margine < MARGINE_MINIMO).sum())}/{NOTTI} "
f"| margine min {S.margine.min():.4f}")
print(f" mediana fasi : celle {sorted(scelte)} "
f"| sotto pavimento {int((D.margine < MARGINE_MINIMO).sum())}/{NOTTI} "
f"| margine min {D.margine.min():.4f}")
esc_s = {f: 0.0 for f in BG.FRAC_GRID}
for f in BG.FRAC_GRID:
vs = [sh(f, 0, k, "in") for k in range(NOTTI)]
vm = [D[f"{f:.0%}"].iloc[k] for k in range(NOTTI)]
esc_s[f] = (max(vs) - min(vs), max(vm) - min(vm))
print("\n escursione dello Sharpe in-sample su 10 notti, per banda (fase singola -> mediana):")
for f in BG.FRAC_GRID:
a, b = esc_s[f]
print(f" banda {f:>4.0%}: {a:.4f} -> {b:.4f} ({'' if b < a else '+'}{abs(b-a)/a*100:4.0f}%)")
# ---------------------------------------------------------------- (iii) potenza
print("\n" + "=" * 100)
print("(iii) POTENZA — chi guardasse l'HOLD-OUT sceglierebbe la stessa banda?")
print("=" * 100)
med_in = {f: mediana_fasi(f, 0, "in") for f in BG.FRAC_GRID}
med_oos = {f: mediana_fasi(f, 0, "oos") for f in BG.FRAC_GRID}
b_in, m_in = scelta(med_in)
b_oos, m_oos = scelta(med_oos)
T = pd.DataFrame([dict(banda=f"{f:.0%}", med_in_sample=round(med_in[f], 4),
med_hold_out=round(med_oos[f], 4)) for f in BG.FRAC_GRID])
print(T.to_string(index=False))
print(f"\n scelta al buio (pre-2015): {b_in:.0%} (margine {m_in:.4f})")
print(f" scelta sull'hold-out : {b_oos:.0%} (margine {m_oos:.4f})")
potenza = b_in != b_oos
print(f" -> {'DIVERSE: il gate ha potenza' if potenza else 'UGUALI: il gate NON ha potenza'}")
# ---------------------------------------------------------------- verdetto
print("\n" + "=" * 100)
print("VERDETTO — coi criteri dichiarati in testa, calcolati adesso")
print("=" * 100)
stabile = len(scelte) == 1
sopra = bool(D.margine.min() >= MARGINE_MINIMO)
print(f" (i) scelta stabile su {NOTTI} notti : {'SI' if stabile else 'NO'}{sorted(scelte)}")
print(f" (ii) margine sempre >= {MARGINE_MINIMO} : {'SI' if sopra else 'NO'} — minimo {D.margine.min():.4f}")
print(f" (iii) potenza (in ≠ hold-out) : {'SI' if potenza else 'NO'}")
if stabile and sopra and potenza:
print("\n -> (C) e' uno strumento utilizzabile. NON e' una validazione della banda:")
print(" va DICHIARATO come criterio, con la sua data, e la validazione va RIFATTA su di esso.")
return 0
manca = [n for n, v in (("i", stabile), ("ii", sopra), ("iii", potenza)) if not v]
print(f"\n -> (C) NON basta: cade su ({'), ('.join(manca)}).")
print(" La mediana toglie la FASE ma non le barre perse: se il verdetto si muove ancora,")
print(" l'instabilita' e' nel DATO, e nessun criterio la aggira. Si ripara la fonte.")
return 1
if __name__ == "__main__":
raise SystemExit(main())