Files
PythagorasGoal/scripts/research/r0910_macro_riskoff.py
T
Adriano Dal Pastro a4e942c8bb 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
2026-09-10 14:56:29 +00:00

299 lines
16 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
"""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())