#!/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 [t−2h, 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 [t−w, 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())