@@ -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 % d egli 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 % d elle 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 % d elle 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 ( ) )