Macro·Macro Data Fetching·Beginner

Equity Crypto Correlation

Analyze the evolving relationship between major equity indices like S&P 500 and Nasdaq-100 with cryptocurrency markets, measuring correlation regime persistence, volatility spillover effects, and tail dependence during risk-on rallies and risk-off liquidation events.

macromarket-analysis

Equity vs Crypto Correlation Analysis — Macro & Cross-Asset

Category: Macro & Cross-Asset | Subcategory: Data


What This Notebook Does

One of the most debated topics in crypto investing is whether Bitcoin and crypto assets are an independent asset class or simply a high-beta version of equities. The data shows it's both — depending on the macro environment.

This notebook:

  1. Fetches S&P 500 (^GSPC), NASDAQ (^IXIC), and BTC-USD daily prices
  2. Computes rolling 30/60/90-day correlations between equity indices and BTC
  3. Measures beta of BTC relative to SPX and NASDAQ
  4. Identifies correlation regime shifts and their macro drivers
  5. Runs a lead-lag analysis: does equity move before BTC or vice versa?
  6. Compares crypto-equity correlation across VIX regimes (low/medium/high fear)
  7. Exports a correlation regime signal for use in risk-on/off strategy notebooks

The Crypto-Equity Correlation Story

PeriodSPX-BTC CorrelationDriver
2017–2018Low/negativeCrypto in own bubble, unrelated to equities
2020 (COVID crash)Spiked positiveAll risk assets sold together (liquidity crunch)
2020-2021 recoveryHigh positiveBoth benefited from zero-rate liquidity tsunami
2022 bear marketVery high positiveBoth crushed by rate hikes and risk-off flows
2023-2024DecliningCrypto ETF narrative decoupled BTC partially

Key insight: In normal times, crypto and equities may diverge. In crises, everything correlates to 1.0. Institutional risk management will always force correlated selling during margin calls and deleveraging.

[ ]
!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('deep')
print('Imports ready.')
Imports ready.

Section 2 — Configuration

This section defines key parameters for the analysis, including the start date for data fetching, the rolling window sizes for correlation calculations, and a dictionary of financial tickers. It also includes a list of significant macro events to be marked on plots, helping to contextualize market movements.

[ ]
START_DATE      = '2017-01-01'
ROLLING_WINDOWS = [30, 60, 90]
TICKERS = {
    'SPX':    '^GSPC',
    'NASDAQ': '^IXIC',
    'VIX':    '^VIX',
    'BTC':    'BTC-USD',
    'ETH':    'ETH-USD',
}
USE_SYNTHETIC = False

MACRO_EVENTS = [
    {'date': '2020-03-16', 'label': 'COVID Crash Low'},
    {'date': '2021-11-22', 'label': 'Market Peak'},
    {'date': '2022-01-26', 'label': 'Fed Pivot Signal'},
    {'date': '2022-06-15', 'label': '75bps Hike'},
    {'date': '2023-01-13', 'label': 'Bear Mkt Bottom'},
    {'date': '2024-01-10', 'label': 'BTC ETF Approved'},
]

Section 3 — Data Acquisition

This section handles data retrieval and preparation. It includes a function (fetch_multi_asset) to download historical daily closing prices for specified tickers from Yahoo Finance. It also has a function (generate_synthetic_multi_asset) to create synthetic data for testing purposes, especially useful if live data fetching encounters issues or for simulating different market conditions. The data is processed to ensure proper alignment and handling of non-trading days.

[ ]
def fetch_multi_asset(
    tickers: dict,
    start: str
) -> pd.DataFrame:
    """
    Fetch daily close prices for multiple assets and align them.

    Parameters
    ----------
    tickers : dict
        Mapping of friendly names to Yahoo Finance tickers.
        Example: {'SPX': '^GSPC', 'BTC': 'BTC-USD'}.
    start : str
        Start date in 'YYYY-MM-DD' format.

    Returns
    -------
    pd.DataFrame
        Daily close prices with friendly column names.
        VIX is kept as its own column (levels, not returns used for conditioning).

    Notes
    -----
    Equity indices only trade on business days while BTC trades 24/7.
    Equity columns are forward-filled on weekends for alignment.
    This creates a slight look-ahead bias on weekend crypto moves — be aware
    when computing same-day correlations.
    """
    symbols = list(tickers.values())
    raw = yf.download(symbols, start=start, progress=False, auto_adjust=True)
    prices = raw['Close'].copy()
    reverse = {v: k for k, v in tickers.items()}
    prices.rename(columns=reverse, inplace=True)
    prices.index = pd.to_datetime(prices.index)
    prices = prices.ffill().dropna()
    print(f'Fetched {len(prices)} rows for {list(prices.columns)}')
    return prices


def generate_synthetic_multi_asset(start: str, n_days: int = 2000) -> pd.DataFrame:
    """
    Generate synthetic multi-asset prices with regime-varying correlation.

    Parameters
    ----------
    start : str
        Start date in 'YYYY-MM-DD' format.
    n_days : int
        Number of business days to simulate.

    Returns
    -------
    pd.DataFrame
        Synthetic prices for SPX, NASDAQ, VIX, BTC, ETH.
    """
    np.random.seed(7)
    dates = pd.date_range(start, periods=n_days, freq='B')

    # SPX and NASDAQ highly correlated (baseline ~0.92)
    spx_rets  = 0.0003 + 0.01 * np.random.randn(n_days)
    ixic_rets = 0.0004 + 0.012 * (0.92 * (spx_rets / 0.01) + 0.39 * np.random.randn(n_days)) * 0.01

    # BTC: regime-varying correlation with SPX
    corr_regime = np.concatenate([
        np.full(400, 0.15),
        np.full(400, 0.70),
        np.full(400, 0.80),
        np.full(400, 0.45),
        np.full(400, 0.30),
    ])[:n_days]

    spx_std = 0.01
    btc_rets = np.array([
        0.0005 + 0.035 * (
            corr_regime[i] * (spx_rets[i] / spx_std) + np.sqrt(1 - corr_regime[i]**2) * np.random.randn()
        )
        for i in range(n_days)
    ])
    eth_rets = btc_rets * 1.2 + 0.005 * np.random.randn(n_days)

    # VIX (inverse relationship with SPX returns roughly)
    vix_levels = 15 + 10 * np.clip(-np.cumsum(spx_rets) * 5 + np.random.randn(n_days) * 2, -10, 60)

    df = pd.DataFrame({
        'SPX':    2500 * np.exp(np.cumsum(spx_rets)),
        'NASDAQ': 6000 * np.exp(np.cumsum(ixic_rets)),
        'VIX':    np.abs(vix_levels),
        'BTC':    10000 * np.exp(np.cumsum(btc_rets)),
        'ETH':    300 * np.exp(np.cumsum(eth_rets)),
    }, index=dates)
    return df


if USE_SYNTHETIC:
    prices = generate_synthetic_multi_asset(START_DATE)
    print('Using synthetic data.')
else:
    try:
        prices = fetch_multi_asset(TICKERS, START_DATE)
    except Exception as e:
        print(f'Live fetch failed ({e}). Falling back to synthetic.')
        prices = generate_synthetic_multi_asset(START_DATE)

print(prices.tail(3))
Fetched 3138 rows for ['BTC', 'ETH', 'SPX', 'NASDAQ', 'VIX']
Ticker               BTC          ETH          SPX        NASDAQ        VIX
Date                                                                       
2026-06-10  61449.289062  1620.137695  7266.990234  25169.500000  22.219999
2026-06-11  63561.054688  1672.280640  7394.299805  25809.660156  19.440001
2026-06-12  62904.011719  1657.800049  7394.299805  25809.660156  19.490000

Section 4 — Rolling Correlation & Beta

This section focuses on calculating rolling correlations and beta coefficients. The compute_cross_asset_metrics function takes the multi-asset price data and computes rolling Pearson correlations between a target asset (e.g., BTC) and various benchmarks (e.g., SPX, NASDAQ) over specified window sizes (30, 60, 90 days). It also calculates the 60-day rolling beta, which measures the sensitivity of the target asset's returns to the benchmark's returns. These metrics are crucial for understanding how closely crypto assets move with traditional equities over time.

[ ]
def compute_cross_asset_metrics(
    prices: pd.DataFrame,
    windows: list,
    target: str = 'BTC',
    benchmarks: list = None
) -> pd.DataFrame:
    """
    Compute rolling correlation and beta between target asset and benchmarks.

    Parameters
    ----------
    prices : pd.DataFrame
        Multi-asset daily close prices.
    windows : list of int
        Rolling window sizes in days.
    target : str
        Asset whose correlation/beta we are measuring (e.g., 'BTC').
    benchmarks : list of str, optional
        Benchmark assets (e.g., ['SPX', 'NASDAQ']). Defaults to all except target.

    Returns
    -------
    pd.DataFrame
        Rolling correlations and betas indexed by date.
        Beta = rolling_cov(target, benchmark) / rolling_var(benchmark).

    Notes
    -----
    Beta > 1 means BTC amplifies equity moves.
    Beta < 0 means BTC moves opposite to equities (rare but occurs during crypto bull markets).
    Rolling beta is noisy on short windows; 60d is a reasonable trade-off.
    """
    if benchmarks is None:
        benchmarks = [c for c in prices.columns if c not in [target, 'VIX']]

    log_rets = np.log(prices / prices.shift(1)).dropna()
    metrics = pd.DataFrame(index=log_rets.index)

    for bm in benchmarks:
        for w in windows:
            metrics[f'corr_{bm}_{w}d'] = log_rets[target].rolling(w).corr(log_rets[bm])

        # Rolling beta at 60d
        w = 60
        cov = log_rets[target].rolling(w).cov(log_rets[bm])
        var = log_rets[bm].rolling(w).var()
        metrics[f'beta_{bm}_60d'] = cov / var

    return metrics


metrics = compute_cross_asset_metrics(prices, ROLLING_WINDOWS)
print('Cross-asset metrics computed.')
print(metrics[['corr_SPX_60d', 'corr_NASDAQ_60d', 'beta_SPX_60d']].tail(5))
Cross-asset metrics computed.
            corr_SPX_60d  corr_NASDAQ_60d  beta_SPX_60d
Date                                                   
2026-06-08      0.405403         0.404609      1.280685
2026-06-09      0.421084         0.417612      1.329329
2026-06-10      0.398064         0.402466      1.183483
2026-06-11      0.441170         0.444286      1.264221
2026-06-12      0.416502         0.430876      1.137439

Section 5 — Lead-Lag Analysis

This section performs a lead-lag analysis to determine if one asset's price movements consistently precede another's. The compute_lead_lag function calculates cross-correlations between two assets (e.g., SPX and BTC) at various daily lags. A positive correlation at a positive lag k suggests that the first asset leads the second by k days. This analysis helps to understand potential directional relationships and market efficiency between different asset classes.

[ ]
def compute_lead_lag(
    prices: pd.DataFrame,
    asset_a: str = 'SPX',
    asset_b: str = 'BTC',
    max_lag: int = 5
) -> pd.DataFrame:
    """
    Compute cross-correlation at various lags to identify lead-lag relationship.

    Parameters
    ----------
    prices : pd.DataFrame
        Multi-asset close prices.
    asset_a : str
        First asset name (candidate leader).
    asset_b : str
        Second asset name (candidate follower).
    max_lag : int
        Maximum lag in days (both positive and negative).

    Returns
    -------
    pd.DataFrame
        Cross-correlation at each lag. Positive lag k means A leads B by k days.

    Notes
    -----
    A positive correlation at lag +k means asset_a's return today predicts
    asset_b's return k days from now — asset_a leads.
    Interpret with caution: most liquid markets adjust quickly and lead-lag
    relationships often disappear after transaction costs.
    """
    log_rets = np.log(prices / prices.shift(1)).dropna()
    a_rets = log_rets[asset_a]
    b_rets = log_rets[asset_b]

    results = []
    for lag in range(-max_lag, max_lag + 1):
        if lag >= 0:
            corr = a_rets.corr(b_rets.shift(-lag))
            label = f'{asset_a} leads {asset_b} by {lag}d'
        else:
            corr = b_rets.corr(a_rets.shift(lag))
            label = f'{asset_b} leads {asset_a} by {abs(lag)}d'
        results.append({'lag': lag, 'correlation': round(corr, 4), 'interpretation': label})

    return pd.DataFrame(results)


lead_lag_df = compute_lead_lag(prices, 'SPX', 'BTC')
print('Lead-lag analysis (SPX vs BTC):')
print(lead_lag_df.to_string(index=False))
Lead-lag analysis (SPX vs BTC):
 lag  correlation      interpretation
  -5      -0.0348 BTC leads SPX by 5d
  -4       0.0437 BTC leads SPX by 4d
  -3       0.0156 BTC leads SPX by 3d
  -2       0.0097 BTC leads SPX by 2d
  -1      -0.0261 BTC leads SPX by 1d
   0       0.2806 SPX leads BTC by 0d
   1      -0.0464 SPX leads BTC by 1d
   2       0.0197 SPX leads BTC by 2d
   3       0.0145 SPX leads BTC by 3d
   4      -0.0064 SPX leads BTC by 4d
   5       0.0031 SPX leads BTC by 5d

Section 6 — Visualization

This section is dedicated to visualizing the computed metrics and relationships. It includes functions to:

  • plot_correlation_timeline: Displays the rebased price performance of SPX and BTC, along with their rolling correlations over time. It also marks significant macro events to provide context.
  • plot_correlation_by_vix_regime: Uses a box plot to illustrate how the BTC-SPX correlation changes across different VIX (volatility index) regimes (low, medium, high fear), highlighting the impact of market sentiment.
  • plot_lead_lag: Presents a bar chart showing the cross-correlation between two assets (e.g., SPX and BTC) at various positive and negative lags, helping to identify potential lead-lag relationships.
[ ]
def plot_correlation_timeline(
    prices: pd.DataFrame,
    metrics: pd.DataFrame,
    macro_events: list
) -> None:
    """
    Plot normalized price history and rolling correlation timelines.

    Parameters
    ----------
    prices : pd.DataFrame
        Multi-asset close prices.
    metrics : pd.DataFrame
        Rolling correlation and beta output.
    macro_events : list of dict
        Each dict: {'date': 'YYYY-MM-DD', 'label': 'Event Name'}.
    """
    fig, axes = plt.subplots(2, 1, figsize=(15, 10), sharex=True)

    # Panel 1: Prices
    norm = prices[['SPX', 'BTC']] / prices[['SPX', 'BTC']].iloc[0] * 100
    axes[0].plot(norm.index, norm['SPX'], label='S&P 500 (rebased)', color='steelblue', linewidth=1.5)
    axes[0].plot(norm.index, norm['BTC'], label='BTC (rebased)',      color='orange',   linewidth=1.5)
    axes[0].set_yscale('log')
    axes[0].set_ylabel('Rebased Price (log scale)')
    axes[0].set_title('S&P 500 vs BTC — Performance Comparison (Base = 100)')
    axes[0].legend()

    # Panel 2: Rolling correlations
    for col, color in [('corr_SPX_30d', 'lightblue'), ('corr_SPX_60d', 'steelblue'), ('corr_SPX_90d', 'navy')]:
        axes[1].plot(metrics.index, metrics[col], label=col, color=color, linewidth=1.2, alpha=0.85)
    axes[1].axhline(0, color='black', linewidth=0.8, linestyle='--')
    axes[1].axhline(0.5, color='green', linewidth=0.5, linestyle=':', alpha=0.7)
    axes[1].set_ylim(-1, 1)
    axes[1].set_ylabel('Pearson Correlation')
    axes[1].set_title('Rolling BTC-SPX Log-Return Correlation')
    axes[1].legend()

    for event in macro_events:
        edate = pd.to_datetime(event['date'])
        for ax in axes:
            ax.axvline(edate, color='crimson', alpha=0.4, linewidth=1.0, linestyle='--')

    plt.tight_layout()
    plt.show()


def plot_correlation_by_vix_regime(
    prices: pd.DataFrame,
    metrics: pd.DataFrame
) -> None:
    """
    Compare BTC-SPX correlation in low, medium, and high VIX regimes.

    Parameters
    ----------
    prices : pd.DataFrame
        Must include 'VIX' column.
    metrics : pd.DataFrame
        Must include 'corr_SPX_60d' column.
    """
    combined = metrics[['corr_SPX_60d']].join(prices['VIX'], how='inner').dropna()
    vix_q = combined['VIX'].quantile([0.33, 0.67]).values
    combined['vix_regime'] = pd.cut(
        combined['VIX'],
        bins=[-np.inf, vix_q[0], vix_q[1], np.inf],
        labels=['Low Fear', 'Medium Fear', 'High Fear']
    )

    fig, ax = plt.subplots(figsize=(10, 5))
    colors = ['green', 'goldenrod', 'red']
    sns.boxplot(data=combined, x='vix_regime', y='corr_SPX_60d',
                order=['Low Fear', 'Medium Fear', 'High Fear'],
                palette=colors, ax=ax)
    ax.axhline(0, color='black', linewidth=0.8, linestyle='--')
    ax.set_title('BTC-SPX Correlation by VIX Fear Regime')
    ax.set_xlabel('VIX Regime')
    ax.set_ylabel('60d Rolling Correlation')
    plt.tight_layout()
    plt.show()

    print('\nMean correlation by VIX regime:')
    print(combined.groupby('vix_regime')['corr_SPX_60d'].agg(['mean', 'std', 'count']))


def plot_lead_lag(
    lead_lag_df: pd.DataFrame,
    asset_a: str = 'SPX',
    asset_b: str = 'BTC'
) -> None:
    """
    Bar chart of lead-lag cross-correlations.

    Parameters
    ----------
    lead_lag_df : pd.DataFrame
        Output of compute_lead_lag().
    asset_a, asset_b : str
        Asset names for labeling.
    """
    fig, ax = plt.subplots(figsize=(10, 4))
    colors = ['red' if x < 0 else 'steelblue' for x in lead_lag_df['correlation']]
    ax.bar(lead_lag_df['lag'], lead_lag_df['correlation'], color=colors, edgecolor='white')
    ax.axhline(0, color='black', linewidth=0.8)
    ax.axvline(0, color='black', linewidth=0.8, linestyle='--')
    ax.set_xlabel(f'Lag (days) — Positive = {asset_a} leads {asset_b}')
    ax.set_ylabel('Cross-Correlation')
    ax.set_title(f'{asset_a} vs {asset_b} Lead-Lag Analysis')
    plt.tight_layout()
    plt.show()


plot_correlation_timeline(prices, metrics, MACRO_EVENTS)
plot_correlation_by_vix_regime(prices, metrics)
plot_lead_lag(lead_lag_df)
cell output
cell output

Mean correlation by VIX regime:
                 mean       std  count
vix_regime                            
Low Fear     0.116607  0.211401   1016
Medium Fear  0.282366  0.206837   1046
High Fear    0.396456  0.163021   1016
cell output

Section 7 — Correlation-Based Trading Signal

This section constructs a composite market regime signal by combining information about the BTC-SPX correlation and the VIX (volatility index). The build_equity_crypto_regime_signal function categorizes the market into three regimes:

  • correlated_high_fear: High correlation between BTC and equities during periods of high market fear (high VIX). This is often a dangerous zone for risk assets.
  • correlated_low_fear: High correlation during calm periods (low VIX). This typically occurs during bull runs where both asset classes benefit from favorable conditions.
  • decoupled: Low correlation, suggesting BTC is moving independently of equities, often driven by crypto-specific narratives or events. This signal can be valuable for developing risk-on/risk-off trading strategies.
[ ]
def build_equity_crypto_regime_signal(
    metrics: pd.DataFrame,
    prices: pd.DataFrame,
    high_corr_threshold: float = 0.55,
    vix_risk_off_threshold: float = 30.0
) -> pd.DataFrame:
    """
    Build a composite regime signal combining SPX correlation and VIX.

    Parameters
    ----------
    metrics : pd.DataFrame
        Must include 'corr_SPX_60d' and 'beta_SPX_60d'.
    prices : pd.DataFrame
        Must include 'VIX' column.
    high_corr_threshold : float
        60d correlation above this = 'risk_on_correlated' regime.
    vix_risk_off_threshold : float
        VIX above this level = elevated systemic risk.

    Returns
    -------
    pd.DataFrame
        Columns: corr_60d, beta_60d, vix, regime.
        regime: 'correlated_high_fear', 'correlated_low_fear', 'decoupled'.

    Notes
    -----
    'correlated_high_fear' = BTC moving with equities during fear — dangerous zone.
    'correlated_low_fear'  = BTC moving with equities during calm — typical bull run.
    'decoupled'            = BTC on its own cycle — crypto-specific narrative driving price.
    """
    df = metrics[['corr_SPX_60d', 'beta_SPX_60d']].join(prices['VIX'], how='inner').dropna()
    df.columns = ['corr_60d', 'beta_60d', 'vix']

    conditions = [
        (df['corr_60d'] >= high_corr_threshold) & (df['vix'] >= vix_risk_off_threshold),
        (df['corr_60d'] >= high_corr_threshold) & (df['vix'] <  vix_risk_off_threshold),
    ]
    choices = ['correlated_high_fear', 'correlated_low_fear']
    df['regime'] = np.select(conditions, choices, default='decoupled')

    print('Regime distribution:')
    print(df['regime'].value_counts())
    return df


regime_signal = build_equity_crypto_regime_signal(metrics, prices)
print(regime_signal.tail(5))
Regime distribution:
regime
decoupled               2799
correlated_low_fear      229
correlated_high_fear      50
Name: count, dtype: int64
            corr_60d  beta_60d        vix     regime
Date                                                
2026-06-08  0.405403  1.280685  18.920000  decoupled
2026-06-09  0.421084  1.329329  19.870001  decoupled
2026-06-10  0.398064  1.183483  22.219999  decoupled
2026-06-11  0.441170  1.264221  19.440001  decoupled
2026-06-12  0.416502  1.137439  19.490000  decoupled

Section 8 — Export

This final section is responsible for exporting all the computed data and signals into CSV files. The export_equity_crypto_data function saves the original price data, the calculated metrics (correlations and betas), the market regime signal, and the lead-lag analysis results. This allows the outputs of this notebook to be easily integrated into other analyses, dashboards, or trading strategy backtesting systems.

[ ]
def export_equity_crypto_data(
    prices: pd.DataFrame,
    metrics: pd.DataFrame,
    regime_signal: pd.DataFrame,
    lead_lag_df: pd.DataFrame
) -> None:
    """
    Export all computed data to CSV files.

    Parameters
    ----------
    prices, metrics, regime_signal, lead_lag_df : pd.DataFrame
        Data to export.
    """
    prices.to_csv('equity_crypto_prices.csv')
    metrics.to_csv('equity_crypto_correlations.csv')
    regime_signal.to_csv('equity_crypto_regime_signal.csv')
    lead_lag_df.to_csv('spx_btc_lead_lag.csv', index=False)
    print('Exported: equity_crypto_prices.csv')
    print('Exported: equity_crypto_correlations.csv')
    print('Exported: equity_crypto_regime_signal.csv')
    print('Exported: spx_btc_lead_lag.csv')


export_equity_crypto_data(prices, metrics, regime_signal, lead_lag_df)
Exported: equity_crypto_prices.csv
Exported: equity_crypto_correlations.csv
Exported: equity_crypto_regime_signal.csv
Exported: spx_btc_lead_lag.csv

Summary & Next Steps

Key Takeaways

  • Correlation is not stable — the BTC-SPX relationship has ranged from near-zero to 0.8+ over different market cycles
  • During crises, correlation spikes toward 1.0 — institutional forced selling drives all risk assets down together
  • VIX regime matters — in high-fear environments, BTC acts as a levered equity instrument, not a hedge
  • Beta to SPX typically ranges 2–5x, meaning BTC amplifies equity moves significantly
  • Lead-lag: on short horizons, SPX tends to slightly lead BTC (institutional capital flows through equities first)