@@ -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 % 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 230 m , 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 1 h del
giorno ( la stessa ` held ` di sleeves . _tp01_returns , spalmata sulle ore ) ; P & L orario = held × rendimento
1 h ; fee sul turnover giornaliero . RISK - OFF : held = 0 nelle barre 1 h che intersecano [ t − 2 h , t + 2 h ] ,
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 ( ) )