Momentum Factor Analysis
Analyze the cross-sectional and time-series momentum risk factor in cryptocurrency markets comprehensively, measuring its long-run risk premium magnitude, Sharpe ratio, factor return autocorrelation structure, and dynamic correlation to traditional asset class momentum factor returns.
Momentum Factor Analysis — Research & Experimentation
Category: Research & Experimentation | Subcategory: Factor Research
What This Notebook Does
Momentum is one of the most robust and widely replicated factors in finance — assets that have outperformed recently tend to continue outperforming in the near future. In crypto markets, momentum is particularly strong because the market is dominated by retail investors who tend to chase winners, amplifying trends.
This notebook provides a complete momentum factor analysis:
- Factor construction: build the momentum factor as the return of past winners minus past losers (long-short portfolio)
- Lookback sensitivity: how does factor performance depend on the lookback window? (1w, 1m, 3m, 6m, 12m)
- Skip period: standard momentum skips the most recent month to avoid short-term reversal (microstructure noise)
- IC analysis: information coefficient at different lookbacks
- Drawdown profile: momentum suffers from momentum crashes — sudden violent reversals during market recoveries
This notebook:
- Loads price data for multiple assets
- Constructs momentum factor portfolios at multiple lookbacks
- Measures the IC and portfolio return at each lookback
- Identifies momentum crashes and their regime context
- Analyses the long and short legs separately
- Exports factor returns
!pip install numpy pandas matplotlib seaborn scipy yfinance --quietimport numpy as np
import pandas as pd
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
print('Imports ready.')Imports ready.
Section 1 — Configuration
LOOKBACKS specifies each momentum lookback window in trading days. SKIP_DAYS implements the standard skip-period — we omit the most recent SKIP_DAYS days from the lookback window to avoid reverting the short-term microstructure effect. TOP_N is the number of assets in each leg of the long-short portfolio.
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD','AVAX-USD',
'MATIC-USD','LINK-USD','DOT-USD']
START_DATE = '2020-01-01'
END_DATE = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS = ['BTC','ETH','SOL','BNB','AVAX','MATIC','LINK','DOT']
LOOKBACKS = {'1w': 5, '2w': 10, '1m': 21, '3m': 63, '6m': 126}
SKIP_DAYS = 5 # skip most recent week to avoid reversal
TOP_N = 3 # long top 3, short bottom 3
REBAL_FREQ = 5 # rebalance every 5 trading days (weekly)
print('Config ready.')Config ready.
Section 2 — Data Acquisition
The synthetic data uses different trend strengths per asset — some assets are strong uptrenders, some weak — to create a realistic cross-section where momentum strategies can distinguish winners from losers. The random seed is fixed so results are reproducible.
if USE_LIVE_DATA:
import yfinance as yf
prices = yf.download(TICKERS, start=START_DATE, end=END_DATE)['Close']
prices.columns = ASSETS
prices.dropna(inplace=True)
returns = prices.pct_change().dropna()
print(f'Live data: {len(returns)} days')
else:
rng = np.random.default_rng(42)
n = 1200
vols = np.array([0.65,0.75,1.20,0.70,1.10,1.40,1.15,0.90]) / np.sqrt(252)
mu = np.array([0.30,0.25,0.55,0.20,0.50,0.65,0.45,0.35]) / 252
corr = np.full((8,8), 0.60); np.fill_diagonal(corr, 1.0)
cov = np.outer(vols, vols) * corr
L = np.linalg.cholesky(cov)
z = rng.standard_t(df=4, size=(n, 8))
data = z @ L.T + mu
idx = pd.date_range('2020-01-01', periods=n, freq='B')
returns = pd.DataFrame(data, columns=ASSETS, index=idx)
prices = (1 + returns).cumprod() * 100
print(f'Synthetic data: {n} days')Synthetic data: 1200 days
Section 3 — Factor Portfolio Construction
For each lookback window, at each rebalancing day we: (1) compute each asset's return over the lookback period (excluding the skip period), (2) rank assets by this return, (3) go long the top TOP_N assets and short the bottom TOP_N, with equal weights in each leg. The factor return is the daily return of this long-short portfolio.
def build_momentum_factor(returns: pd.DataFrame, lookback: int,
skip: int, top_n: int, rebal: int) -> pd.Series:
"""
Build long-short momentum factor returns.
Parameters
----------
returns : pd.DataFrame Daily asset returns.
lookback : int Lookback window in days.
skip : int Days to skip before lookback starts.
top_n : int Number of assets in each leg.
rebal : int Rebalancing frequency in days.
Returns
-------
pd.Series Daily factor returns (long - short).
"""
n = len(returns)
factor_rets = pd.Series(np.nan, index=returns.index)
long_w = np.zeros(len(returns.columns))
short_w = np.zeros(len(returns.columns))
for t in range(lookback + skip, n):
if t % rebal == 0:
# Compute past return excluding skip period
past = returns.iloc[t - lookback - skip:t - skip]
cum_ret = (1 + past).prod() - 1
ranks = cum_ret.rank()
long_mask = ranks >= (len(ranks) - top_n + 1)
short_mask = ranks <= top_n
long_w = long_mask.astype(float) / top_n
short_w = short_mask.astype(float) / top_n
daily = returns.iloc[t]
factor_rets.iloc[t] = (long_w * daily).sum() - (short_w * daily).sum()
return factor_rets.dropna()
factor_rets = {}
for name, lb in LOOKBACKS.items():
factor_rets[name] = build_momentum_factor(returns, lb, SKIP_DAYS, TOP_N, REBAL_FREQ)
for name, fr in factor_rets.items():
sharpe = fr.mean() / (fr.std() + 1e-9) * np.sqrt(252)
ann_ret = fr.mean() * 252
print(f'MOM-{name}: Ann Ret={ann_ret:.1%}, Sharpe={sharpe:.2f}')MOM-1w: Ann Ret=-16.7%, Sharpe=-0.21 MOM-2w: Ann Ret=-28.6%, Sharpe=-0.36 MOM-1m: Ann Ret=39.3%, Sharpe=0.50 MOM-3m: Ann Ret=29.2%, Sharpe=0.37 MOM-6m: Ann Ret=20.9%, Sharpe=0.27
Section 4 — Visualisation
The left panel shows cumulative factor returns for all lookback horizons. Typically longer-horizon momentum (3m, 6m) is more stable than short-term, which is contaminated by reversal effects. The right panel shows the rolling Sharpe ratio for the 1-month momentum factor — time-varying Sharpe reveals momentum crashes and regime dependence.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Momentum Factor Analysis', fontsize=13, fontweight='bold')
ax1 = axes[0]
colors = ['#e53935','#fb8c00','#fdd835','#43a047','#1976d2']
for (name, fr), color in zip(factor_rets.items(), colors):
eq = (1 + fr).cumprod()
ax1.plot(eq.index, eq, lw=1.5, color=color, label=f'MOM-{name}')
ax1.set_ylabel('Factor Cumulative Return'); ax1.legend(fontsize=8)
ax1.set_title('Long-Short Momentum Factor by Lookback')
ax2 = axes[1]
fr_1m = factor_rets['1m']
roll_sharpe = fr_1m.rolling(63).apply(lambda x: x.mean()/(x.std()+1e-9)*np.sqrt(252))
ax2.plot(roll_sharpe.index, roll_sharpe, lw=1.5, color='#1976d2')
ax2.axhline(0, color='black', lw=0.8, ls='--')
ax2.fill_between(roll_sharpe.index, roll_sharpe, 0,
where=roll_sharpe < 0, alpha=0.2, color='red')
ax2.set_ylabel('Rolling 63d Sharpe'); ax2.set_title('1M Momentum: Rolling Sharpe')
plt.tight_layout(); plt.show()Section 5 — Export
Save all factor return series in a single CSV. Each column represents one lookback variant of the momentum factor, enabling downstream attribution and factor combination analysis.
pd.DataFrame(factor_rets).to_csv('momentum_factor_analysis.csv')
print('Saved: momentum_factor_analysis.csv')Saved: momentum_factor_analysis.csv