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:
@@ -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 [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())
|
||||
Reference in New Issue
Block a user