Research·Factor Research & Analysis·Advanced

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.

factor-investingquant-research

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:

  1. Loads price data for multiple assets
  2. Constructs momentum factor portfolios at multiple lookbacks
  3. Measures the IC and portfolio return at each lookback
  4. Identifies momentum crashes and their regime context
  5. Analyses the long and short legs separately
  6. Exports factor returns
[1]
!pip install numpy pandas matplotlib seaborn scipy yfinance --quiet
[2]
import 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.

[3]
# ── 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.

[4]
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.

[5]
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.

[6]
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()
cell output

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.

[7]
pd.DataFrame(factor_rets).to_csv('momentum_factor_analysis.csv')
print('Saved: momentum_factor_analysis.csv')
Saved: momentum_factor_analysis.csv