r0910: risk-off ±2h intorno a FOMC/CPI su TP01 REFUTED 0/3 (ΔSh −0,097, −1,24%/anno; le ore dei dati sono fra le migliori)

Calendario letto dal web (Fed 61 riunioni + BLS 88 release CPI, 2019-2026), TP01 orario dalla stessa
held di sleeves, null location-matched 1000 estrazioni (Δ vero al 4,2° pctl), per anno 2/8.
Registro §78, memoria 20-ondate (D-bis), diario 2026-09-10b.

Fixes #9

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016kqvff47UBGeYfj1QeN4zE
This commit is contained in:
Adriano Dal Pastro
2026-09-10 14:56:29 +00:00
parent 5b72a4b188
commit a4e942c8bb
4 changed files with 366 additions and 1 deletions
+298
View File
@@ -0,0 +1,298 @@
#!/usr/bin/env python
"""r0910_macro_riskoff.py — «risk-off» di TP01 nella finestra ±2h intorno a FOMC e CPI: vale qualcosa?
DOMANDA (operatore, 2026-09-10, da un articolo su EA forex con «agente fondamentale»): se TP01 si
mettesse FLAT nelle due ore prima e dopo la dichiarazione FOMC (14:00 ET) e la release CPI
(08:30 ET), guadagnerebbe Sharpe o drift? E' un risk-off, quindi per la regola di §3 («non
de-esporre il weekend») PARTE REFUTED e deve superare il null del de-levering (M5) e un null
location-matched (M18) prima di essere creduto.
VERDETTO PRE-REGISTRATO (scritto PRIMA di vedere un numero):
LEAD solo se TUTTE: (a) ΔSharpe(riskoff base) > 0 con le fee di chiusura/riapertura;
(b) il Δ vero sta al ≥95° percentile del null location-matched (stesso numero di eventi per
anno, stesse ore del giorno, giorni estratti a caso); (c) Δ positivo in ≥70% degli anni con ≥5
eventi (M9). Altrimenti REFUTED. E l'effetto si cita anche in €/giorno all'equity di oggi.
COSA NON C'E' (dichiarato): il funding (mai in nessun backtest, 2,16%/anno di drift) — irrilevante
qui perche' identico nelle due varianti salvo ~1% delle ore; il rimbalzo di prezzo nella finestra
(slippage: TP01 chiude e riapre a mid, in un'ora in cui lo spread e' largo — se il risultato fosse
positivo andrebbe rimisurato col costo vero della finestra, C1). SKH01 non e' qui: e' un breakout su
barra 230m, appiattirlo cambierebbe il percorso del segnale.
CALENDARIO (letto dal web il 2026-09-10, non ricordato — regola globale):
FOMC: federalreserve.gov/monetarypolicy/fomccalendars.htm (2021-2026) e fomchistorical2019/2020.
Ore: 14:00 ET per le riunioni programmate (standard Fed); le due d'emergenza del 2020
(03/03 10:00 ET, 15/03 17:00 ET) con la loro ora; i «notation vote» ESCLUSI (nessuna
dichiarazione a orario). ET→UTC con America/New_York (DST).
CPI: bls.gov/bls/news-release/cpi.htm (archivio 2019-2026), 08:30 ET (bls.gov/schedule).
L'archivio raggruppa per anno di RIFERIMENTO: le release di gennaio/febbraio stanno sotto
l'anno prima — corrette qui (+1 anno), e la correzione e' verificata sul 2026 (13/01 e
13/02 stanno nella schedule ufficiale 2026).
TP01 ORARIO: posizione decisa alla chiusura giornaliera (00:00 UTC) e tenuta per le 24 barre 1h del
giorno (la stessa `held` di sleeves._tp01_returns, spalmata sulle ore); P&L orario = held × rendimento
1h; fee sul turnover giornaliero. RISK-OFF: held = 0 nelle barre 1h che intersecano [t2h, t+2h],
con fee_side su chiusura e riapertura (|held| × 2 × fee).
uv run python scripts/research/r0910_macro_riskoff.py
"""
from __future__ import annotations
import sys
from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo
import numpy as np
import pandas as pd
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from src.data.downloader import load_data # noqa: E402
from src.strategies.trend_portfolio import CANONICAL, TrendPortfolio, resample_1d # noqa: E402
ASSETS = ("BTC", "ETH")
ET = ZoneInfo("America/New_York")
FINESTRA_H = 2.0
N_NULL = 1000
SEED = 20260910
EQUITY_OGGI = 4454.83 # trades_db --report 10/09 14:21Z, per il €/giorno
EURUSD = 1.08 # ordine di grandezza, dichiarato: serve solo per la riga in €/g
# ------------------------------------------------------------------ calendario
FOMC_PROGRAMMATE = """
2019-01-30 2019-03-20 2019-05-01 2019-06-19 2019-07-31 2019-09-18 2019-10-30 2019-12-11
2020-01-29 2020-04-29 2020-06-10 2020-07-29 2020-09-16 2020-11-05 2020-12-16
2021-01-27 2021-03-17 2021-04-28 2021-06-16 2021-07-28 2021-09-22 2021-11-03 2021-12-15
2022-01-26 2022-03-16 2022-05-04 2022-06-15 2022-07-27 2022-09-21 2022-11-02 2022-12-14
2023-02-01 2023-03-22 2023-05-03 2023-06-14 2023-07-26 2023-09-20 2023-11-01 2023-12-13
2024-01-31 2024-03-20 2024-05-01 2024-06-12 2024-07-31 2024-09-18 2024-11-07 2024-12-18
2025-01-29 2025-03-19 2025-05-07 2025-06-18 2025-07-30 2025-09-17 2025-10-29 2025-12-10
2026-01-28 2026-03-18 2026-04-29 2026-06-17 2026-07-29
""".split()
FOMC_EMERGENZA = [("2020-03-03", "10:00"), ("2020-03-15", "17:00")]
# archivio BLS, ordine della pagina (per anno di riferimento): le release di gen/feb vanno +1 anno
CPI_ARCHIVIO = """
2026-08-12 2026-07-14 2026-06-10 2026-05-12 2026-04-10 2026-03-11 2026-02-13
2025-01-13 2025-12-18 2025-10-24 2025-09-11 2025-08-12 2025-07-15 2025-06-11 2025-05-13 2025-04-10 2025-03-12 2025-02-12
2024-01-15 2024-12-11 2024-11-13 2024-10-10 2024-09-11 2024-08-14 2024-07-11 2024-06-12 2024-05-15 2024-04-10 2024-03-12 2024-02-13
2023-01-11 2023-12-12 2023-11-14 2023-10-12 2023-09-13 2023-08-10 2023-07-12 2023-06-13 2023-05-10 2023-04-12 2023-03-14
2022-02-14 2022-01-12 2022-12-13 2022-11-10 2022-10-13 2022-09-13 2022-08-10 2022-07-13 2022-06-10 2022-05-11 2022-04-12 2022-03-10
2021-02-10 2021-01-12 2021-12-10 2021-11-10 2021-10-13 2021-09-14 2021-08-11 2021-07-13 2021-06-10 2021-05-12 2021-04-13 2021-03-10
2020-02-10 2020-01-13 2020-12-10 2020-11-12 2020-10-13 2020-09-11 2020-08-12 2020-07-14 2020-06-10 2020-05-12 2020-04-10 2020-03-11
2019-02-13 2019-01-14 2019-12-11 2019-11-13 2019-10-10 2019-09-12 2019-08-13 2019-07-11 2019-06-12 2019-05-10 2019-04-10 2019-03-12
""".split()
def cpi_date_corrette(seq: list[str]) -> list[str]:
"""Una data di gen/feb che PRECEDE (nell'ordine della pagina, discendente) una data di
marzo-dicembre dello STESSO anno e' la release dell'anno dopo."""
out = []
for i, d in enumerate(seq):
y, m = int(d[:4]), int(d[5:7])
if m <= 2:
j = i + 1
while j < len(seq) and int(seq[j][5:7]) <= 2:
j += 1
if j < len(seq) and int(seq[j][:4]) == y:
d = f"{y + 1}{d[4:]}"
out.append(d)
return sorted(set(out))
def eventi() -> pd.DataFrame:
rows = []
for d in FOMC_PROGRAMMATE:
rows.append(("FOMC", datetime.fromisoformat(f"{d} 14:00").replace(tzinfo=ET)))
for d, hm in FOMC_EMERGENZA:
rows.append(("FOMC", datetime.fromisoformat(f"{d} {hm}").replace(tzinfo=ET)))
for d in cpi_date_corrette(CPI_ARCHIVIO):
rows.append(("CPI", datetime.fromisoformat(f"{d} 08:30").replace(tzinfo=ET)))
ev = pd.DataFrame(rows, columns=["tipo", "t_et"])
ev["t"] = ev["t_et"].map(lambda x: pd.Timestamp(x.astimezone(ZoneInfo("UTC"))))
return ev.sort_values("t").reset_index(drop=True)
# ------------------------------------------------------------------ TP01 orario
def tp01_orario() -> pd.DataFrame:
"""-> DataFrame orario con held_BTC, held_ETH, r_BTC, r_ETH, fee_day (fee del turnover
giornaliero, sulla prima barra del giorno)."""
tp = TrendPortfolio(**CANONICAL)
parts = {}
for a in ASSETS:
h = load_data(a, "1h")
hidx = pd.to_datetime(h["datetime"], utc=True)
r1h = pd.Series(h["close"].values.astype(float), index=hidx).pct_change().fillna(0.0)
d = resample_1d(h)
tgt = tp.target_series(d)
held = np.zeros(len(tgt)); held[1:] = tgt[:-1]
didx = pd.to_datetime(d["datetime"], utc=True).dt.floor("D")
held_d = pd.Series(held, index=didx)
turn_d = pd.Series(np.abs(np.diff(held, prepend=0.0)) * tp.fee_side, index=didx)
day_of_h = hidx.dt.floor("D")
held_h = held_d.reindex(day_of_h).values
fee_h = np.where(hidx.dt.hour.values == 0, turn_d.reindex(day_of_h).values, 0.0)
parts[a] = pd.DataFrame({f"held_{a}": held_h, f"r_{a}": r1h.values, f"fee_{a}": fee_h}, index=hidx)
J = pd.concat(parts.values(), axis=1, join="inner").dropna()
return J
def pnl(J: pd.DataFrame, flat_mask: np.ndarray | None, fee_side: float) -> pd.Series:
"""Rendimento orario del portafoglio 50/50. `flat_mask` True = barra a esposizione zero
(con fee di chiusura all'ingresso nella finestra e di riapertura all'uscita)."""
out = np.zeros(len(J))
for a in ASSETS:
held = J[f"held_{a}"].values.copy()
fee = J[f"fee_{a}"].values.copy()
if flat_mask is not None:
h0 = held.copy()
held = np.where(flat_mask, 0.0, held)
# transizioni dentro/fuori finestra: |Δheld| × fee, oltre al turnover giornaliero
extra = np.abs(np.diff(held, prepend=held[0])) - np.abs(np.diff(h0, prepend=h0[0]))
fee = fee + np.clip(extra, 0, None) * fee_side
out += 0.5 * (held * J[f"r_{a}"].values - fee)
return pd.Series(out, index=J.index)
def maschera(J: pd.DataFrame, tempi: pd.Series, w_h: float = FINESTRA_H) -> np.ndarray:
"""Barre 1h (open-labeled) che intersecano [tw, t+w] per almeno un evento."""
idx = J.index.values.astype("datetime64[ns]")
m = np.zeros(len(J), dtype=bool)
w = np.timedelta64(int(w_h * 3600), "s")
one_h = np.timedelta64(3600, "s")
for t in tempi:
t64 = np.datetime64(pd.Timestamp(t).tz_convert("UTC").tz_localize(None))
lo, hi = np.searchsorted(idx, t64 - w - one_h, side="right"), np.searchsorted(idx, t64 + w, side="left")
m[lo:hi] = True
return m
def sharpe_h(s: pd.Series) -> float:
return float(s.mean() / s.std() * np.sqrt(24 * 365.25)) if s.std() > 0 else float("nan")
def sharpe_d(s: pd.Series) -> float:
d = s.groupby(s.index.floor("D")).sum()
return float(d.mean() / d.std() * np.sqrt(365.25)) if d.std() > 0 else float("nan")
def drift_annuo(s: pd.Series) -> float:
return float(s.mean() * 24 * 365.25)
# ------------------------------------------------------------------ misura
def misura() -> dict:
J = tp01_orario()
fee_side = TrendPortfolio(**CANONICAL).fee_side
ev = eventi()
ev = ev[(ev["t"] >= J.index[0]) & (ev["t"] <= J.index[-1])].reset_index(drop=True)
base = pnl(J, None, fee_side)
m_all = maschera(J, ev["t"])
ro = pnl(J, m_all, fee_side)
ro_nofee = pnl(J, m_all, 0.0)
out = dict(n_ore=len(J), da=str(J.index[0]), a=str(J.index[-1]), n_ev=len(ev),
n_fomc=int((ev.tipo == "FOMC").sum()), n_cpi=int((ev.tipo == "CPI").sum()),
ore_finestra=int(m_all.sum()), quota_ore=float(m_all.mean()),
base=dict(sh_d=sharpe_d(base), sh_h=sharpe_h(base), drift=drift_annuo(base)),
riskoff=dict(sh_d=sharpe_d(ro), sh_h=sharpe_h(ro), drift=drift_annuo(ro)),
riskoff_nofee=dict(sh_d=sharpe_d(ro_nofee), drift=drift_annuo(ro_nofee)))
out["d_sh_d"] = out["riskoff"]["sh_d"] - out["base"]["sh_d"]
out["d_drift"] = out["riskoff"]["drift"] - out["base"]["drift"]
# cosa succede DENTRO la finestra (il P&L che il risk-off rinuncia)
dentro = base[m_all]; fuori = base[~m_all]
esposto = (J.loc[m_all, [f"held_{a}" for a in ASSETS]].abs().sum(axis=1) > 0)
out["dentro"] = dict(n=int(m_all.sum()), n_esposte=int(esposto.sum()), media_h=float(dentro.mean()),
t=float(dentro.mean() / dentro.std() * np.sqrt(len(dentro))) if dentro.std() > 0 else float("nan"),
somma=float(dentro.sum()), media_fuori_h=float(fuori.mean()))
# per tipo
out["per_tipo"] = {}
for tipo in ("FOMC", "CPI"):
m = maschera(J, ev.loc[ev.tipo == tipo, "t"])
r = pnl(J, m, fee_side)
out["per_tipo"][tipo] = dict(n=int((ev.tipo == tipo).sum()), d_sh_d=sharpe_d(r) - out["base"]["sh_d"],
d_drift=drift_annuo(r) - out["base"]["drift"], somma_dentro=float(base[m].sum()))
# per anno (M9)
anni = {}
for y, g in base.groupby(base.index.year):
my = m_all[base.index.year == y]
ry = ro[base.index.year == y]
ney = int((ev["t"].dt.year == y).sum())
anni[int(y)] = dict(n_ev=ney, d_sh_d=sharpe_d(ry) - sharpe_d(g), somma_dentro=float(g[my].sum()),
d_cum=float((1 + ry).prod() - (1 + g).prod()))
out["anni"] = anni
# null location-matched (M18): stessi conteggi per anno e stesse ORE del giorno, giorni a caso
rng = np.random.default_rng(SEED)
giorni_per_anno = {y: np.array(sorted(set(J.index[J.index.year == y].normalize()))) for y in J.index.year.unique()}
d_null = np.empty(N_NULL)
for k in range(N_NULL):
t_fake = []
for y, g in ev.groupby(ev["t"].dt.year):
pool = giorni_per_anno.get(int(y))
if pool is None or len(pool) == 0:
continue
days = rng.choice(pool, size=len(g), replace=False)
for d, t in zip(days, g["t"]):
t_fake.append(pd.Timestamp(d) + (t - t.normalize()))
mk = maschera(J, pd.Series(t_fake))
d_null[k] = sharpe_d(pnl(J, mk, fee_side)) - out["base"]["sh_d"]
out["null"] = dict(n=N_NULL, media=float(d_null.mean()), p50=float(np.median(d_null)),
p95=float(np.percentile(d_null, 95)), p05=float(np.percentile(d_null, 5)),
pctl_vero=float((d_null < out["d_sh_d"]).mean() * 100))
# €/giorno all'equity di oggi
out["eur_giorno"] = out["d_drift"] * EQUITY_OGGI / EURUSD / 365.25
return out
def verdetto(m: dict) -> tuple[str, list[str]]:
a = m["d_sh_d"] > 0
b = m["null"]["pctl_vero"] >= 95.0
anni_ok = [y for y, v in m["anni"].items() if v["n_ev"] >= 5]
pos = sum(1 for y in anni_ok if m["anni"][y]["d_sh_d"] > 0)
c = anni_ok and pos / len(anni_ok) >= 0.70
righe = [f"(a) ΔSharpe giornaliero con fee > 0: {m['d_sh_d']:+.3f}{'PASS' if a else 'FAIL'}",
f"(b) Δ vero al ≥95° pctl del null location-matched: {m['null']['pctl_vero']:.1f}° → {'PASS' if b else 'FAIL'}",
f"(c) Δ > 0 in ≥70% degli anni con ≥5 eventi: {pos}/{len(anni_ok)}{'PASS' if c else 'FAIL'}"]
return ("LEAD" if (a and b and c) else "REFUTED"), righe
def main() -> int:
print("=" * 88)
print(" r0910 — RISK-OFF ±2h intorno a FOMC e CPI su TP01 (lente oraria, posizione giornaliera)")
print("=" * 88)
m = misura()
print(f" ore: {m['n_ore']} {m['da'][:10]}{m['a'][:10]} eventi: {m['n_ev']} "
f"(FOMC {m['n_fomc']}, CPI {m['n_cpi']}) ore in finestra: {m['ore_finestra']} = {m['quota_ore']:.2%}")
print(f"\n {'':14s} {'Sh giorn.':>10s} {'Sh orario':>10s} {'drift/anno':>11s}")
for k in ("base", "riskoff"):
v = m[k]; print(f" {k:14s} {v['sh_d']:>10.3f} {v['sh_h']:>10.3f} {v['drift']:>+11.2%}")
v = m["riskoff_nofee"]; print(f" {'riskoff nofee':14s} {v['sh_d']:>10.3f} {'':>10s} {v['drift']:>+11.2%}")
print(f"\n Δ riskoff base: Sharpe giornaliero {m['d_sh_d']:+.3f} · drift {m['d_drift']:+.2%}/anno "
f"· ≈ {m['eur_giorno']:+.3f} €/giorno a ${EQUITY_OGGI:,.0f}")
d = m["dentro"]
print(f" dentro la finestra: {d['n']} ore ({d['n_esposte']} esposte), media {d['media_h']*1e4:+.2f} bp/h "
f"(fuori {d['media_fuori_h']*1e4:+.2f} bp/h), t = {d['t']:+.2f}, somma {d['somma']:+.2%}")
print("\n per tipo:")
for t, v in m["per_tipo"].items():
print(f" {t:5s} n={v['n']:3d} ΔSh {v['d_sh_d']:+.3f} Δdrift {v['d_drift']:+.2%} P&L dentro {v['somma_dentro']:+.2%}")
print("\n per anno (M9):")
print(f" {'anno':>4s} {'ev':>3s} {'ΔSh':>7s} {'P&L dentro':>11s} {'Δcum':>8s}")
for y, v in m["anni"].items():
print(f" {y:>4d} {v['n_ev']:>3d} {v['d_sh_d']:>+7.3f} {v['somma_dentro']:>+11.2%} {v['d_cum']:>+8.2%}")
n = m["null"]
print(f"\n null location-matched (M18, {n['n']} estrazioni): ΔSh mediana {n['p50']:+.3f} "
f"[p05 {n['p05']:+.3f}, p95 {n['p95']:+.3f}] — il Δ vero sta al {n['pctl_vero']:.1f}° percentile")
v, righe = verdetto(m)
print("\n VERDETTO PRE-REGISTRATO:")
for r in righe:
print(" " + r)
print(f"\n{v}")
print("\n non modellato: funding (identico nelle due varianti salvo ~1% delle ore), slippage della "
"finestra (spread largo: se il risultato fosse positivo andrebbe rimisurato col costo vero, C1).")
return 0
if __name__ == "__main__":
raise SystemExit(main())