Files
PythagorasGoal/scripts/research/r0828_gtaa_band_median_phase.py
T
Adriano Dal Pastro c21a0d4058 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>
2026-08-28 08:51:43 +00:00

232 lines
11 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.
#!/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())