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.
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:
- Fetches S&P 500 (^GSPC), NASDAQ (^IXIC), and BTC-USD daily prices
- Computes rolling 30/60/90-day correlations between equity indices and BTC
- Measures beta of BTC relative to SPX and NASDAQ
- Identifies correlation regime shifts and their macro drivers
- Runs a lead-lag analysis: does equity move before BTC or vice versa?
- Compares crypto-equity correlation across VIX regimes (low/medium/high fear)
- Exports a correlation regime signal for use in risk-on/off strategy notebooks
The Crypto-Equity Correlation Story
| Period | SPX-BTC Correlation | Driver |
|---|---|---|
| 2017–2018 | Low/negative | Crypto in own bubble, unrelated to equities |
| 2020 (COVID crash) | Spiked positive | All risk assets sold together (liquidity crunch) |
| 2020-2021 recovery | High positive | Both benefited from zero-rate liquidity tsunami |
| 2022 bear market | Very high positive | Both crushed by rate hikes and risk-off flows |
| 2023-2024 | Declining | Crypto 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 --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('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)
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
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)