Macro Event Strategy
Build a systematic trading strategy that positions around pre-scheduled macroeconomic data releases and central bank events including FOMC decisions, CPI prints, and Non-Farm Payrolls, with statistical analysis of pre-announcement drift and post-release price reaction patterns in crypto.
Macro Event Trading Strategy — Macro & Cross-Asset
Category: Macro & Cross-Asset | Subcategory: Strategies
What This Notebook Does
Major macro data releases — FOMC decisions, CPI inflation prints, NFP (Non-Farm Payrolls), and GDP readings — are scheduled events that trigger predictable volatility in both traditional and crypto markets. This notebook builds an event-driven strategy framework.
This notebook:
- Builds a unified macro event calendar combining FOMC dates, CPI release dates, and NFP release dates
- Measures BTC price behavior in the ±5 day window around each event type
- Classifies events by surprise direction (hot vs cold data) and measures asymmetric impact
- Implements a pre-event positioning strategy: reduce exposure 2 days before, re-enter after resolution
- Implements a post-event momentum strategy: follow the initial direction after a surprise
- Backtests both approaches with realistic assumptions
- Exports event impact data for further analysis
The Event Trading Mindset
| Event Type | BTC Impact | Mechanism |
|---|---|---|
| FOMC (hike surprise) | Bearish, immediate | Tightening = risk-off across the board |
| FOMC (cut surprise) | Bullish | Loosening = risk-on |
| CPI hot (above expectations) | Initially bearish | Implies more hikes |
| CPI cold (below expectations) | Bullish | Implies pause or cut |
| NFP strong | Mixed | Strong economy → Fed stays hawkish |
| NFP weak | Mixed | Weak economy → possible cut |
Key Concept: The Pre-Event Vol Crush
Markets often see reduced volatility in the 1-2 days before a major event (traders wait), followed by a vol expansion immediately after. Options traders call this 'vol crush after the event'. A pre-event strategy reduces position size to avoid event risk, then re-enters for the post-event trend.
!pip install yfinance pandas numpy matplotlib seaborn scipy --quietimport yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from scipy import stats
import warnings
warnings.filterwarnings('ignore')
%matplotlib inline
plt.rcParams['figure.figsize'] = (14, 5)
plt.rcParams['axes.spines.top'] = False
plt.rcParams['axes.spines.right'] = False
sns.set_palette('husl')
print('Imports ready.')Imports ready.
Section 2 — Configuration
This section defines global parameters used throughout the notebook, such as the start date for data fetching, the BTC ticker, and the number of days for pre/post-event windows.
START_DATE = '2020-01-01'
BTC_TICKER = 'BTC-USD'
PRE_EVENT_DAYS = 2 # days before event to reduce exposure
POST_EVENT_DAYS = 3 # days after event for momentum follow-through
USE_SYNTHETIC = FalseSection 3 — Event Calendar Construction
This section focuses on creating a consolidated calendar of significant macro events, including FOMC meetings and CPI releases, along with their surprise directions.
def build_macro_event_calendar(start_year: int = 2020) -> pd.DataFrame:
"""
Build a unified macro event calendar with event type and known outcomes.
Parameters
----------
start_year : int
Year from which to include events.
Returns
-------
pd.DataFrame
Columns: date, event_type, surprise_direction.
surprise_direction: 'hawkish', 'dovish', 'neutral', 'hot', 'cold'.
Notes
-----
'Surprise direction' is defined relative to market consensus expectations:
- FOMC: 'hawkish' = larger hike or more hawkish language than expected
- FOMC: 'dovish' = smaller hike or more dovish language than expected
- CPI: 'hot' = above consensus, 'cold' = below consensus
- NFP: 'strong' = above consensus jobs added, 'weak' = below consensus
In practice, surprises must be estimated from Bloomberg consensus data
(paid service). Here we use simplified directional classifications.
"""
fomc_events = [
# 2020
{'date': '2020-01-29', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2020-03-03', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2020-03-15', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2020-04-29', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2020-06-10', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2020-07-29', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2020-09-16', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2020-11-05', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2020-12-16', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
# 2021
{'date': '2021-01-27', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2021-03-17', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2021-04-28', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2021-06-16', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2021-07-28', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2021-09-22', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2021-11-03', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2021-12-15', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
# 2022
{'date': '2022-01-26', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2022-03-16', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2022-05-04', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2022-06-15', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2022-07-27', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2022-09-21', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2022-11-02', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2022-12-14', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
# 2023
{'date': '2023-02-01', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2023-03-22', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2023-05-03', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2023-06-14', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2023-07-26', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2023-09-20', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2023-11-01', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2023-12-13', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
# 2024
{'date': '2024-01-31', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
{'date': '2024-03-20', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2024-05-01', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2024-06-12', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2024-09-18', 'event_type': 'FOMC', 'surprise_direction': 'dovish'},
{'date': '2024-11-07', 'event_type': 'FOMC', 'surprise_direction': 'neutral'},
{'date': '2024-12-18', 'event_type': 'FOMC', 'surprise_direction': 'hawkish'},
]
# CPI releases (2nd Tuesday of month, typically)
cpi_events = [
{'date': '2021-06-10', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2021-07-13', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2021-08-11', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2021-10-13', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2021-11-10', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2022-01-12', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2022-03-10', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2022-06-10', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2022-07-13', 'event_type': 'CPI', 'surprise_direction': 'cold'},
{'date': '2022-09-13', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2022-11-10', 'event_type': 'CPI', 'surprise_direction': 'cold'},
{'date': '2023-01-12', 'event_type': 'CPI', 'surprise_direction': 'cold'},
{'date': '2023-04-12', 'event_type': 'CPI', 'surprise_direction': 'cold'},
{'date': '2023-07-12', 'event_type': 'CPI', 'surprise_direction': 'cold'},
{'date': '2024-01-11', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2024-04-10', 'event_type': 'CPI', 'surprise_direction': 'hot'},
{'date': '2024-09-11', 'event_type': 'CPI', 'surprise_direction': 'cold'},
]
all_events = fomc_events + cpi_events
df = pd.DataFrame(all_events)
df['date'] = pd.to_datetime(df['date'])
df = df[df['date'].dt.year >= start_year].sort_values('date').reset_index(drop=True)
print(f'Macro event calendar: {len(df)} events')
print(df['event_type'].value_counts().to_string())
return df
event_calendar = build_macro_event_calendar(int(START_DATE[:4]))
print(event_calendar.head(5))Macro event calendar: 57 events
event_type
FOMC 40
CPI 17
date event_type surprise_direction
0 2020-01-29 FOMC neutral
1 2020-03-03 FOMC dovish
2 2020-03-15 FOMC dovish
3 2020-04-29 FOMC neutral
4 2020-06-10 FOMC dovish
Section 4 — Event Impact Measurement
This section measures the Bitcoin (BTC) price behavior around the scheduled macro events by calculating returns in predefined pre-event, on-event-day, and post-event windows.
def fetch_btc_prices(ticker: str, start: str) -> pd.Series:
"""
Fetch BTC daily close prices.
Parameters
----------
ticker : str
Yahoo Finance ticker for BTC.
start : str
Start date string.
Returns
-------
pd.Series
Daily close prices indexed by date.
"""
data = yf.download(ticker, start=start, progress=False, auto_adjust=True)
close = data['Close'].squeeze()
close.index = pd.to_datetime(close.index)
print(f'BTC: {len(close)} days ({close.index[0].date()} → {close.index[-1].date()})')
return close
def measure_event_windows(
event_calendar: pd.DataFrame,
btc_prices: pd.Series,
pre_days: int,
post_days: int
) -> pd.DataFrame:
"""
Measure BTC returns in the pre and post windows around each macro event.
Parameters
----------
event_calendar : pd.DataFrame
Macro event calendar from build_macro_event_calendar().
btc_prices : pd.Series
Daily BTC close prices.
pre_days : int
Days before event for pre-event window measurement.
post_days : int
Days after event for post-event window measurement.
Returns
-------
pd.DataFrame
event_calendar extended with pre_return, day_return, post_return columns.
Notes
-----
BTC trades 24/7 so there is always price data on event days (unlike equity ETFs).
The 'day return' uses asof() to get the nearest available price to the event date,
which handles minor data gaps gracefully.
"""
result = event_calendar.copy()
pre_rets, day_rets, post_rets = [], [], []
for event_date in event_calendar['date']:
try:
pre_price = btc_prices.asof(event_date - pd.Timedelta(days=pre_days))
event_price = btc_prices.asof(event_date)
prev_price = btc_prices.asof(event_date - pd.Timedelta(days=1))
post_price = btc_prices.asof(event_date + pd.Timedelta(days=post_days))
pre_ret = (event_price / pre_price - 1) * 100 if pre_price > 0 else np.nan
day_ret = (event_price / prev_price - 1) * 100 if prev_price > 0 else np.nan
post_ret = (post_price / event_price - 1) * 100 if event_price > 0 else np.nan
pre_rets.append(round(pre_ret, 3) if pd.notna(pre_ret) else np.nan)
day_rets.append(round(day_ret, 3) if pd.notna(day_ret) else np.nan)
post_rets.append(round(post_ret, 3) if pd.notna(post_ret) else np.nan)
except Exception:
pre_rets.append(np.nan)
day_rets.append(np.nan)
post_rets.append(np.nan)
result['pre_return'] = pre_rets
result['day_return'] = day_rets
result['post_return'] = post_rets
return result
if USE_SYNTHETIC:
np.random.seed(42)
dates = pd.date_range(START_DATE, periods=1800, freq='D')
btc_prices = pd.Series(30000 * np.exp(np.cumsum(0.001 + 0.025 * np.random.randn(1800))), index=dates)
print('Using synthetic BTC data.')
else:
try:
btc_prices = fetch_btc_prices(BTC_TICKER, START_DATE)
except Exception as e:
print(f'Live fetch failed ({e}). Using synthetic.')
np.random.seed(42)
dates = pd.date_range(START_DATE, periods=1800, freq='D')
btc_prices = pd.Series(30000 * np.exp(np.cumsum(0.001 + 0.025 * np.random.randn(1800))), index=dates)
events_with_returns = measure_event_windows(event_calendar, btc_prices, PRE_EVENT_DAYS, POST_EVENT_DAYS)
print(events_with_returns[['date', 'event_type', 'surprise_direction', 'day_return', 'post_return']].head(10))BTC: 2355 days (2020-01-01 → 2026-06-12)
date event_type surprise_direction day_return post_return
0 2020-01-29 FOMC neutral -0.448 0.818
1 2020-03-03 FOMC dovish -0.923 3.809
2 2020-03-15 FOMC dovish 3.691 -2.854
3 2020-04-29 FOMC neutral 12.732 2.131
4 2020-06-10 FOMC dovish 0.759 -4.000
5 2020-07-29 FOMC neutral 1.719 5.938
6 2020-09-16 FOMC dovish 1.648 1.088
7 2020-11-05 FOMC neutral 10.232 -0.644
8 2020-12-16 FOMC dovish 9.752 12.009
9 2021-01-27 FOMC neutral -6.562 12.608
Section 5 — Statistical Analysis
This section statistically analyzes the impact of different macro events on BTC returns, classifying them by event type and surprise direction, and identifies statistically significant impacts.
def analyze_event_impact(events_with_returns: pd.DataFrame) -> pd.DataFrame:
"""
Compute statistical summary of BTC impact by event type and surprise direction.
Parameters
----------
events_with_returns : pd.DataFrame
Output of measure_event_windows().
Returns
-------
pd.DataFrame
Mean day_return and post_return by event_type × surprise_direction,
with N count and t-test p-value.
"""
rows = []
for (etype, direction), grp in events_with_returns.groupby(['event_type', 'surprise_direction']):
for col in ['day_return', 'post_return']:
vals = grp[col].dropna()
if len(vals) >= 3:
t, p = stats.ttest_1samp(vals, 0)
rows.append({
'Event': etype, 'Surprise': direction, 'Window': col,
'N': len(vals), 'Mean (%)': round(vals.mean(), 2),
'p-value': round(p, 3), 'Significant?': 'Yes' if p < 0.1 else 'No'
})
summary = pd.DataFrame(rows)
print(summary.to_string(index=False))
return summary
event_summary = analyze_event_impact(events_with_returns)Event Surprise Window N Mean (%) p-value Significant? CPI cold day_return 6 3.16 0.152 No CPI cold post_return 6 2.23 0.401 No CPI hot day_return 11 -1.63 0.174 No CPI hot post_return 11 -3.57 0.186 No FOMC dovish day_return 13 2.25 0.068 Yes FOMC dovish post_return 13 1.38 0.363 No FOMC hawkish day_return 15 0.74 0.455 No FOMC hawkish post_return 15 -2.58 0.095 Yes FOMC neutral day_return 12 2.15 0.204 No FOMC neutral post_return 12 3.11 0.024 Yes
Section 6 — Strategy Backtests
This section backtests a pre-event hedging strategy that reduces exposure to BTC before major macro events to mitigate risk.
def backtest_pre_event_hedge(
btc_prices: pd.Series,
event_calendar: pd.DataFrame,
pre_days: int,
post_days: int
) -> pd.DataFrame:
"""
Backtest: hold 50% BTC in the pre-event window; 100% otherwise.
This strategy reduces position size ahead of major macro events to limit
event-risk exposure, then re-enters fully after the decision is known.
Parameters
----------
btc_prices : pd.Series
Daily BTC close prices.
event_calendar : pd.DataFrame
Macro event calendar.
pre_days : int
Days before event to reduce exposure.
post_days : int
Days after event before returning to full position.
Returns
-------
pd.DataFrame
Daily position, returns, and equity curves.
Notes
-----
Assumes no transaction costs. In practice, partial exits and re-entries
would incur exchange fees (~0.1%) plus potential slippage on larger positions.
Position is reduced to 50% (not 0%) to stay partially exposed to surprise rallies.
"""
daily_rets = btc_prices.pct_change().dropna()
position = pd.Series(1.0, index=daily_rets.index)
for event_date in event_calendar['date']:
window_start = event_date - pd.Timedelta(days=pre_days)
window_end = event_date + pd.Timedelta(days=1) # re-enter after decision day
mask = (position.index >= window_start) & (position.index <= window_end)
position[mask] = 0.5
bt = pd.DataFrame({'btc_ret': daily_rets, 'position': position.reindex(daily_rets.index, fill_value=1.0)})
bt['strategy_ret'] = bt['position'] * bt['btc_ret']
bt['cum_btc'] = (1 + bt['btc_ret']).cumprod()
bt['cum_strategy'] = (1 + bt['strategy_ret']).cumprod()
total_btc = (bt['cum_btc'].iloc[-1] - 1) * 100
total_strat = (bt['cum_strategy'].iloc[-1] - 1) * 100
max_dd_strat = ((bt['cum_strategy'] - bt['cum_strategy'].cummax()) / bt['cum_strategy'].cummax() * 100).min()
max_dd_btc = ((bt['cum_btc'] - bt['cum_btc'].cummax()) / bt['cum_btc'].cummax() * 100).min()
print(f'Pre-event hedge strategy: {total_strat:.1f}% (max DD: {max_dd_strat:.1f}%)')
print(f'Buy-and-hold BTC: {total_btc:.1f}% (max DD: {max_dd_btc:.1f}%)')
return bt
bt = backtest_pre_event_hedge(btc_prices, event_calendar, PRE_EVENT_DAYS, POST_EVENT_DAYS)Pre-event hedge strategy: 577.9% (max DD: -70.8%) Buy-and-hold BTC: 774.8% (max DD: -76.6%)
Section 7 — Visualization
This section provides visualizations of the event impact distributions and the performance of the backtested pre-event hedging strategy compared to a simple buy-and-hold approach.
def plot_event_impact_distributions(
events_with_returns: pd.DataFrame
) -> None:
"""
Box plots of BTC day_return and post_return by event type and surprise direction.
Parameters
----------
events_with_returns : pd.DataFrame
Output of measure_event_windows().
"""
fig, axes = plt.subplots(1, 2, figsize=(15, 5))
for ax, col, title in [
(axes[0], 'day_return', 'BTC Return ON Event Day'),
(axes[1], 'post_return', f'BTC Return {POST_EVENT_DAYS}d After Event'),
]:
df_plot = events_with_returns.dropna(subset=[col])
df_plot['label'] = df_plot['event_type'] + '\n' + df_plot['surprise_direction']
order = df_plot.groupby('label')[col].mean().sort_values().index
sns.boxplot(data=df_plot, x='label', y=col, order=order, ax=ax)
ax.axhline(0, color='black', linewidth=0.8, linestyle='--')
ax.set_title(title)
ax.set_xlabel('')
ax.set_ylabel('BTC Return (%)')
ax.tick_params(axis='x', labelsize=8)
plt.suptitle('BTC Impact by Macro Event Type and Surprise Direction', fontsize=13)
plt.tight_layout()
plt.show()
def plot_strategy_performance(bt: pd.DataFrame, event_calendar: pd.DataFrame) -> None:
"""
Plot equity curves and highlight event blackout periods.
Parameters
----------
bt : pd.DataFrame
Backtest results.
event_calendar : pd.DataFrame
Event calendar for shading.
"""
fig, axes = plt.subplots(2, 1, figsize=(15, 9), sharex=True)
axes[0].plot(bt.index, bt['cum_btc'] * 100, color='orange', linewidth=1.5, label='Buy & Hold BTC')
axes[0].plot(bt.index, bt['cum_strategy'] * 100, color='steelblue', linewidth=1.5, label='Pre-Event Hedge')
for event_date in event_calendar['date']:
axes[0].axvline(event_date, color='grey', alpha=0.3, linewidth=0.5)
axes[0].set_yscale('log')
axes[0].set_ylabel('Portfolio (log, base=100)')
axes[0].set_title('Strategy vs Buy-and-Hold with Event Markers')
axes[0].legend()
axes[1].plot(bt.index, bt['position'], color='steelblue', linewidth=1.0)
axes[1].fill_between(bt.index, bt['position'], 1.0, alpha=0.2, color='red')
axes[1].set_ylim(0, 1.2)
axes[1].set_ylabel('BTC Position Size')
axes[1].set_title('Position Size (Red Fill = Reduced Exposure Around Events)')
plt.tight_layout()
plt.show()
plot_event_impact_distributions(events_with_returns)
plot_strategy_performance(bt, event_calendar)Section 8 — Export
This section exports the processed event data, the statistical summary of event impacts, and the backtest results into CSV files for external use or further analysis.
def export_macro_event_data(
events_with_returns: pd.DataFrame,
event_summary: pd.DataFrame,
bt: pd.DataFrame
) -> None:
"""
Export event data, impact summary, and backtest results.
Parameters
----------
events_with_returns : pd.DataFrame
Event calendar with BTC returns.
event_summary : pd.DataFrame
Statistical impact summary.
bt : pd.DataFrame
Backtest equity curves.
"""
events_with_returns.to_csv('macro_events_btc_impact.csv', index=False)
event_summary.to_csv('macro_event_impact_summary.csv', index=False)
bt.to_csv('macro_event_strategy_backtest.csv')
print('Exported: macro_events_btc_impact.csv')
print('Exported: macro_event_impact_summary.csv')
print('Exported: macro_event_strategy_backtest.csv')
export_macro_event_data(events_with_returns, event_summary, bt)Exported: macro_events_btc_impact.csv Exported: macro_event_impact_summary.csv Exported: macro_event_strategy_backtest.csv
Summary & Next Steps
Key Takeaways
- Hawkish FOMC surprises are the most consistently bearish for BTC — reducing exposure pre-FOMC is a valuable risk management tool
- Hot CPI prints (inflation above expectations) have been consistently negative since 2021 as they imply more Fed tightening
- Post-event momentum often follows the surprise direction for 1-3 days before mean-reverting
- A simple pre-event hedge reduces max drawdown without sacrificing significant upside
- In dovish/cutting environments (2019, 2020, 2023), the strategy misses some upside — regime context matters