research(gtaa): il gate (A) della banda e' una moneta — la finestra "congelata" rotola ogni notte
Il 28/08 falliscono `test_la_banda_proposta_e_quella_scelta_al_buio` e
`test_il_margine_del_blind_non_e_un_arrotondamento`, a codice fermo dal 07/08
(nessun commit su src/portfolio/gtaa.py ne' su r0727_gtaa_band_gate.py). Al buio
esce il 60% invece del 25%, con margine 0.0074 — sotto il pavimento di 0.01 che
il test si era dato. Questo script chiede, per la terza volta sullo stesso
parametro, se il criterio misura cio' che dichiara. Replica bit-exact della
produzione verificata a fase 0 / taglio 0 (max|Δ| = 0.00e+00 su 7.544 barre).
(0) LA FINESTRA IN-SAMPLE NON E' FERMA. IB serve una finestra ROTOLANTE di 30
anni. SPY e' l'unica gamba al muro (30.0 anni), e nel cron log la sua data
d'inizio avanza di un giorno di borsa ogni notte: 1996-07-02 il 24/06,
1996-09-04 oggi. Il pre-2015, che tutto il progetto tratta come finestra
congelata, perde il giorno piu' vecchio ogni notte. QQQ e IWM arriveranno
allo stesso muro fra 2,5 e 3,7 anni.
(1) L'AMPLIFICATORE E' LA FASE. `_gated_returns` ribilancia su `i % every == 0`,
indice di POSIZIONE nell'array: se la prima barra scivola, si ri-fasa ogni
decisione di trent'anni. Il progetto sapeva che la fase conta
(`r0726_loo_deluck.gtaa01_at(ph)`, 5 ancore) ma la assumeva fissa alla
canonica 0. Non lo e': la fase canonica cambia da sola ogni notte.
MISURE. Sulla finestra CHIUSA pre-2015, che non puo' acquisire dati nuovi, lo
Sharpe si e' mosso fino a 0.0760 fra il 07/08 e oggi. Su 10 notti consecutive
la banda scelta al buio e' 25% / 40% / 60% — la proposta vince il 40% delle
notti — e il margine sta sotto il pavimento in 4 notti su 10 (minimo 0.0026).
A dato FISSO, muovendo la sola fase, il verdetto cambia lo stesso (25% su 3
fasi, 60% su 2): l'imputato e' la fase, non le barre perse.
VERDETTO calcolato a runtime coi criteri dichiarati in testa (M13): il gate (A)
NON ha risoluzione. Il verdetto non e' una proprieta' della banda, e' una
proprieta' della notte in cui lo si legge. La banda al 25% non e' ne' confermata
ne' smentita: non e' decidibile cosi'.
Lo script misura e basta. Ritirare il criterio, come si fece il 07/08 col
confronto fra ranghi, e' una decisione dell'operatore e non e' presa qui.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python
|
||||
"""r0828_gtaa_band_phase.py — il gate (A) della banda GTAA01 fallisce di nuovo: e' la proposta, o e' ANCORA il gate?
|
||||
|
||||
CONTESTO. Il 27/07 fu proposta la banda al 25% della gamba. Il gate (A) — SELEZIONE IN-SAMPLE —
|
||||
la valido' due volte, con due criteri diversi, e li ha rotti entrambi senza che nessuno toccasse
|
||||
il codice:
|
||||
|
||||
· 27/07 criterio `rank_in <= rank_oos`. Rotto il 07/08. Motivo (r0807_gtaa_gate_resolution):
|
||||
lo passa ~meta' della griglia PER COSTRUZIONE, e sulla proposta decideva su 0.00116
|
||||
di Sharpe. Una moneta. RITIRATO.
|
||||
· 07/08 criterio sostitutivo: «a cadenza di produzione, la banda scelta guardando SOLO il
|
||||
pre-2015 e' la proposta». Passava col 25% e un margine di +0.0151 sulla seconda —
|
||||
13x il criterio precedente. Rotto il 28/08: al buio esce il 60%, margine 0.0074,
|
||||
SOTTO il pavimento di 0.01 che il test stesso si era dato.
|
||||
|
||||
Il codice e' fermo (`git log src/portfolio/gtaa.py scripts/research/r0727_gtaa_band_gate.py`
|
||||
non ha commit dopo il 07/08). Quindi si e' mosso il DATO. Questo script chiede, per la terza
|
||||
volta e sullo stesso parametro, se il criterio misura cio' che dichiara.
|
||||
|
||||
LE DUE COSE CHE SI SONO TROVATE
|
||||
-------------------------------
|
||||
(0) **LA FINESTRA IN-SAMPLE NON E' FERMA.** IB ritorna una finestra ROTOLANTE di 30 anni. SPY e'
|
||||
l'unica gamba al muro (quotato 1993, storia servita dal 1996), e nel `logs/cron_daily.log`
|
||||
la sua data d'inizio avanza di un giorno di borsa OGNI NOTTE: 1996-07-02 il 24/06,
|
||||
1996-09-04 oggi. Il pre-2015 — che tutto il progetto tratta come una finestra congelata —
|
||||
perde il suo giorno piu' vecchio ogni notte. (QQQ e IWM colpiranno lo stesso muro nel
|
||||
2029-2030: non e' un difetto di SPY, e' il muro che avanza.)
|
||||
|
||||
(1) **L'AMPLIFICATORE E' LA FASE.** `gtaa._gated_returns` ribilancia su `i % every == 0`: l'indice
|
||||
e' la POSIZIONE NELL'ARRAY, quindi la fase e' ancorata alla prima barra del file. Se la prima
|
||||
barra scivola di un giorno, si ri-fasa ogni decisione di ribilanciamento di trent'anni di
|
||||
storia. Il progetto sa gia' che la fase conta — `r0726_loo_deluck.gtaa01_at(ph)` la tratta
|
||||
come un'ancora a 5 valori e ci de-lucka sopra — ma la assumeva FISSA alla fase canonica 0.
|
||||
Non lo e': **la fase canonica cambia da sola ogni notte**.
|
||||
|
||||
I DUE ESPERIMENTI, in quest'ordine
|
||||
----------------------------------
|
||||
(A) **LE NOTTI.** Si taglia la testa di SPY di k=0..N-1 barre, che e' esattamente cio' che la
|
||||
finestra rotolante fa da sola in N notti, e si guarda la banda scelta al buio.
|
||||
(B) **LA FASE PURA.** A dato FISSO, si sposta la sola fase di ribilanciamento di SPY (ph=0..4).
|
||||
Serve ad attribuire: se il verdetto si muove anche qui, l'imputato e' la fase, non le barre
|
||||
perse. Col controllo su TUTTE le gambe accanto, che dice quanto sarebbe peggio quando anche
|
||||
QQQ e IWM arriveranno al muro.
|
||||
|
||||
CRITERIO DEL VERDETTO, dichiarato PRIMA (M13: un criterio si misura sulla sua RISOLUZIONE prima
|
||||
che sul suo esito). Il gate (A) ha risoluzione se e solo se:
|
||||
(i) la banda scelta al buio e' LA STESSA in tutte le notti e in tutte le fasi, e
|
||||
(ii) il margine sulla seconda resta sopra MARGINE_MINIMO in tutte.
|
||||
Se (i) o (ii) cadono, il criterio non distingue la proposta da una cella qualunque, e va ritirato
|
||||
come il suo predecessore — si congela il MOTIVO, non l'esito.
|
||||
|
||||
uv run python scripts/research/r0828_gtaa_band_phase.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
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 # notti di finestra rotolante da simulare
|
||||
MARGINE_MINIMO = 0.01 # il pavimento che il test si e' dato il 07/08, non ritoccato qui
|
||||
MURO_IB_ANNI = 30.0 # finestra massima servita da IB: chi la tocca rotola
|
||||
PROPOSTA_BANDA = BG.PROPOSTA[1]
|
||||
|
||||
# Tabella PUBBLICATA il 07/08 (docs/diary/2026-08-07-gate-gtaa-e-storia-troncata.md, sezione (2)).
|
||||
# Serve a misurare di quanto si e' mossa una finestra che NON puo' acquisire dati nuovi.
|
||||
SH_IN_0807 = {0.00: 0.5396, 0.05: 0.5714, 0.10: 0.5956, 0.25: 0.6398, 0.40: 0.6247, 0.60: 0.5874}
|
||||
|
||||
|
||||
def _leg(sym: str, cap_leg: float, band_usd: float, every: int, ph: int,
|
||||
taglio: int = 0) -> pd.Series:
|
||||
"""`gtaa._gated_returns` con due manopole: la FASE (`ph`) e il TAGLIO in testa alla serie.
|
||||
|
||||
Copia dichiarata, non riuso: la produzione non espone ne' l'una ne' l'altra. La replica a
|
||||
(ph=0, taglio=0) deve tornare bit-exact quella di produzione — lo verifica `sanity()`.
|
||||
"""
|
||||
close = G._close(sym)
|
||||
if taglio:
|
||||
close = close.iloc[taglio:]
|
||||
ex = np.nan_to_num(np.asarray(G._exposure(close).values, float))
|
||||
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 = 0, taglio: int = 0, tutte: bool = False) -> pd.Series:
|
||||
"""La cella (cadenza di produzione, banda `frac`). `ph`/`taglio` si applicano alla sola SPY
|
||||
salvo `tutte`: e' SPY l'unica gamba al muro dei 30 anni, e il resto della griglia e' fermo."""
|
||||
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, BG.PROPOSTA[0],
|
||||
ph if (tutte or a == "SPY") else 0,
|
||||
taglio if (tutte or a == "SPY") 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 riga(ph: int = 0, taglio: int = 0, tutte: bool = False) -> dict:
|
||||
sh = {f: BG.met(cella(f, ph, taglio, tutte).loc[: BG.HOLDOUT])["sharpe"] for f in BG.FRAC_GRID}
|
||||
best = max(sh, key=sh.get)
|
||||
secondo = max(v for f, v in sh.items() if f != best)
|
||||
return dict(scelta=best, margine=sh[best] - secondo, sh=sh)
|
||||
|
||||
|
||||
def sanity() -> bool:
|
||||
"""La replica a (fase 0, taglio 0) DEVE essere quella di produzione. Senza questo, ogni
|
||||
numero sotto misura la mia copia invece dello sleeve che gira."""
|
||||
mia = cella(PROPOSTA_BANDA, 0, 0)
|
||||
vera = BG.run_cell(BG.PROPOSTA[0], PROPOSTA_BANDA, BG.CAP_REF)
|
||||
j = pd.concat({"m": mia, "v": vera}, axis=1, join="outer")
|
||||
delta = float((j["m"] - j["v"]).abs().max())
|
||||
ok = bool(np.isfinite(delta) and delta < 1e-12 and len(j) == len(vera))
|
||||
print(f"[SANITY] replica (fase 0, taglio 0) vs produzione: max|Δ| = {delta:.2e} "
|
||||
f"barre {len(j)} vs {len(vera)} -> {'OK' if ok else 'DIVERGE'}")
|
||||
return ok
|
||||
|
||||
|
||||
def storia() -> pd.DataFrame:
|
||||
"""(0) Quali gambe sono al muro dei 30 anni — cioe' quali hanno una finestra ROTOLANTE."""
|
||||
rows = []
|
||||
for a in G.EQ_UNIVERSE:
|
||||
s = G._close(a)
|
||||
anni = (s.index[-1] - s.index[0]).days / 365.25
|
||||
rows.append(dict(gamba=a, dal=str(s.index[0].date()), barre=len(s), anni=round(anni, 1),
|
||||
al_muro="SI — rotola" if anni >= MURO_IB_ANNI - 0.15 else "no",
|
||||
anni_al_muro=round(MURO_IB_ANNI - anni, 1)))
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
print(__doc__.split("\n\n")[0])
|
||||
print()
|
||||
if not sanity():
|
||||
print("!! la replica non riproduce la produzione: i numeri sotto non sono confrontabili")
|
||||
return 2
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("(0) LA FINESTRA E' ROTOLANTE — quali gambe sono al muro dei 30 anni di IB")
|
||||
print("=" * 96)
|
||||
H = storia()
|
||||
print(H.to_string(index=False))
|
||||
rotolanti = list(H[H.al_muro.str.startswith("SI")].gamba)
|
||||
print(f"\n gambe con finestra rotolante: {rotolanti or 'nessuna'} — per queste, il pre-2015 "
|
||||
"perde il giorno piu' vecchio ogni notte.")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(" quanto si e' mossa una finestra che NON puo' acquisire dati nuovi (07/08 -> oggi)")
|
||||
print("=" * 96)
|
||||
oggi = riga(0, 0)["sh"]
|
||||
D = pd.DataFrame([dict(banda=f"{f:.0%}", sh_in_0807=SH_IN_0807[f], sh_in_oggi=round(oggi[f], 4),
|
||||
delta=round(oggi[f] - SH_IN_0807[f], 4)) for f in BG.FRAC_GRID])
|
||||
print(D.to_string(index=False))
|
||||
print(f"\n spostamento massimo in valore assoluto: {D.delta.abs().max():.4f} di Sharpe, "
|
||||
f"su una finestra CHIUSA (pre-2015) e a codice fermo.")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print(f"(A) LE NOTTI — banda scelta al buio in {NOTTI} notti consecutive di finestra rotolante")
|
||||
print("=" * 96)
|
||||
spy = G._close("SPY")
|
||||
A = []
|
||||
for k in range(NOTTI):
|
||||
r = riga(0, k)
|
||||
A.append(dict(notte=k, inizio_SPY=str(spy.index[k].date()), scelta=f"{r['scelta']:.0%}",
|
||||
margine=round(r["margine"], 4),
|
||||
**{f"{f:.0%}": round(r["sh"][f], 4) for f in BG.FRAC_GRID}))
|
||||
DA = pd.DataFrame(A)
|
||||
print(DA.to_string(index=False))
|
||||
scelte_A = set(DA.scelta)
|
||||
quota_proposta = float((DA.scelta == f"{PROPOSTA_BANDA:.0%}").mean())
|
||||
print(f"\n celle distinte scelte: {sorted(scelte_A)} "
|
||||
f"| la proposta ({PROPOSTA_BANDA:.0%}) vince {quota_proposta:.0%} delle notti")
|
||||
print(f" margine: min {DA.margine.min():.4f} mediana {DA.margine.median():.4f} "
|
||||
f"max {DA.margine.max():.4f} (pavimento {MARGINE_MINIMO})")
|
||||
print(f" notti sotto il pavimento: {int((DA.margine < MARGINE_MINIMO).sum())}/{NOTTI}")
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("(B) LA FASE PURA — dato FISSO, si muove solo la fase di ribilanciamento")
|
||||
print("=" * 96)
|
||||
for tutte, eti in ((False, f"fase mossa solo su {rotolanti or ['SPY']} (cio' che succede DAVVERO oggi)"),
|
||||
(True, "fase mossa su TUTTE le gambe (controllo: quando anche QQQ e IWM saranno al muro)")):
|
||||
B = []
|
||||
for ph in range(BG.PROPOSTA[0]):
|
||||
r = riga(ph, 0, tutte)
|
||||
B.append(dict(fase=ph, scelta=f"{r['scelta']:.0%}", margine=round(r["margine"], 4),
|
||||
**{f"{f:.0%}": round(r["sh"][f], 4) for f in BG.FRAC_GRID}))
|
||||
DB = pd.DataFrame(B)
|
||||
print(f"\n {eti}")
|
||||
print(DB.to_string(index=False))
|
||||
print(f" celle distinte: {sorted(set(DB.scelta))} | margine min {DB.margine.min():.4f}")
|
||||
if not tutte:
|
||||
scelte_B, marg_B = set(DB.scelta), DB.margine
|
||||
|
||||
print("\n" + "=" * 96)
|
||||
print("VERDETTO — coi criteri dichiarati in testa allo script, calcolati adesso")
|
||||
print("=" * 96)
|
||||
stabile = len(scelte_A) == 1 and len(scelte_B) == 1
|
||||
sopra = bool(DA.margine.min() >= MARGINE_MINIMO and marg_B.min() >= MARGINE_MINIMO)
|
||||
print(f" (i) scelta stabile su notti e fasi : {'SI' if stabile else 'NO'} "
|
||||
f"— notti {sorted(scelte_A)}, fasi {sorted(scelte_B)}")
|
||||
print(f" (ii) margine sempre >= {MARGINE_MINIMO} : {'SI' if sopra else 'NO'} "
|
||||
f"— minimo osservato {min(DA.margine.min(), marg_B.min()):.4f}")
|
||||
if stabile and sopra:
|
||||
print("\n -> il gate (A) HA risoluzione: il fallimento del test e' un fatto sulla PROPOSTA.")
|
||||
return 0
|
||||
print("\n -> il gate (A) NON ha risoluzione. Il verdetto non e' una proprieta' della banda:")
|
||||
print(" e' una proprieta' della notte in cui lo si legge. Come il criterio a ranghi")
|
||||
print(" ritirato il 07/08, va ritirato — congelando il MOTIVO, non l'esito.")
|
||||
print(f" La banda della proposta ({PROPOSTA_BANDA:.0%}) resta la scelta al buio in "
|
||||
f"{quota_proposta:.0%} delle notti: ne' confermata ne' smentita, NON DECIDIBILE cosi'.")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user