Macro·Macro Strategy Implementations·Intermediate

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.

macrotrading-strategies

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:

  1. Builds a unified macro event calendar combining FOMC dates, CPI release dates, and NFP release dates
  2. Measures BTC price behavior in the ±5 day window around each event type
  3. Classifies events by surprise direction (hot vs cold data) and measures asymmetric impact
  4. Implements a pre-event positioning strategy: reduce exposure 2 days before, re-enter after resolution
  5. Implements a post-event momentum strategy: follow the initial direction after a surprise
  6. Backtests both approaches with realistic assumptions
  7. Exports event impact data for further analysis

The Event Trading Mindset

Event TypeBTC ImpactMechanism
FOMC (hike surprise)Bearish, immediateTightening = risk-off across the board
FOMC (cut surprise)BullishLoosening = risk-on
CPI hot (above expectations)Initially bearishImplies more hikes
CPI cold (below expectations)BullishImplies pause or cut
NFP strongMixedStrong economy → Fed stays hawkish
NFP weakMixedWeak 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 --quiet
[ ]
import 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    = False

Section 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)
cell output
cell output

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