Momentum Portfolio Rotation
Build a cross-sectional momentum-based portfolio rotation strategy that periodically ranks assets by recent risk-adjusted return performance and rebalances into the top momentum quintile while rotating out of the bottom performers, systematically capturing the momentum risk premium in crypto assets.
Momentum Portfolio Rotation — Portfolio Construction
Category: Portfolio | Subcategory: Construction
What This Notebook Does
Momentum rotation is one of the most robust factors in finance: assets that performed well over the past 3–12 months tend to continue outperforming over the next 1–3 months. Applied as a portfolio strategy:
- Each month, rank all assets by their past N-month return
- Go long the top-K assets, equal-weight (or weight by momentum score)
- Rotate out of laggards into leaders each period
This is sometimes called cross-sectional momentum (vs time-series momentum which is long/flat/short a single asset).
Key parameters:
- Lookback window: 3–12 months (1-month skipped to avoid short-term reversal)
- Hold period: 1 month (classic Jegadeesh-Titman)
- Top-K: number of assets to hold (typically 20–33% of universe)
This notebook:
- Fetches data (Yahoo Finance or synthetic) for a crypto universe
- Ranks assets monthly by momentum score
- Constructs the rotation portfolio and tracks its performance
- Compares momentum rotation vs equal-weight buy-and-hold
- Analyses turnover and transaction costs
- Exports signals and portfolio weights
!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
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
LOOKBACK_MONTHS is the momentum signal window — how far back we look to rank assets. We skip the most recent month (SKIP_MONTHS = 1) to avoid the well-documented short-term reversal effect. TOP_K is the number of assets held at any time. TRANSACTION_COST is applied as a one-way cost on each trade.
# ── Data Source Toggle ─────────────────────────────
USE_LIVE_DATA = False
TICKERS = ['BTC-USD','ETH-USD','SOL-USD','BNB-USD','AVAX-USD',
'MATIC-USD','DOT-USD','LINK-USD','ADA-USD','XRP-USD']
START_DATE = '2021-01-01'
END_DATE = '2024-12-31'
# ──────────────────────────────────────────────────
ASSETS = ['BTC','ETH','SOL','BNB','AVAX','MATIC','DOT','LINK','ADA','XRP']
LOOKBACK_MONTHS = 6
SKIP_MONTHS = 1
TOP_K = 3
TRANSACTION_COST = 0.001 # 10 bps per trade (one-way)
print('Config ready.')Config ready.
Section 2 — Data Acquisition
We fetch daily adjusted closing prices. The synthetic path simulates 10 correlated crypto assets with different volatility and drift profiles, ensuring some assets genuinely trend (high drift) while others revert. This makes the momentum signal more realistic to test.
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)
print(f'Live data: {len(prices)} days')
else:
rng = np.random.default_rng(42)
n = 1000
vols = np.array([0.65,0.75,1.20,0.70,1.10,1.40,1.00,1.20,0.90,0.80])/np.sqrt(252)
# Varying momentum: some assets have strong positive drift, others weak
mu = np.array([0.60,0.55,1.00,0.40,0.85,0.50,0.45,0.70,0.30,0.35])/252
corr = np.full((10,10), 0.60); np.fill_diagonal(corr, 1.0)
cov = np.outer(vols,vols)*corr
L = np.linalg.cholesky(cov)
data = rng.standard_normal((n,10)) @ L.T + mu
idx = pd.date_range('2021-01-01', periods=n, freq='B')
returns = pd.DataFrame(data, columns=ASSETS, index=idx)
prices = (1+returns).cumprod()*100
print(f'Synthetic data: {len(prices)} days, {len(ASSETS)} assets')
returns = prices.pct_change().dropna()
monthly = prices.resample('ME').last()
monthly_rets = monthly.pct_change().dropna()Synthetic data: 1000 days, 10 assets
Section 3 — Momentum Signal Construction
The momentum score for each asset at month t is its cumulative return from t - LOOKBACK_MONTHS - SKIP_MONTHS to t - SKIP_MONTHS. Skipping the most recent month avoids the reversal effect. Assets are then ranked 1 (best) to N (worst). We hold the top-K ranked assets with equal weight.
lookback = LOOKBACK_MONTHS + SKIP_MONTHS
momentum_scores = []
for i in range(lookback, len(monthly_rets)):
window_end = i - SKIP_MONTHS
window_start = i - lookback
if window_start < 0:
continue
# Cumulative return over lookback window
cum_ret = (1 + monthly_rets.iloc[window_start:window_end]).prod() - 1
ranked = cum_ret.rank(ascending=False)
held = ranked[ranked <= TOP_K].index.tolist()
momentum_scores.append({
'date': monthly_rets.index[i],
'held': held,
'scores': cum_ret.to_dict()
})
print(f'Momentum signals generated for {len(momentum_scores)} months')
print(f'Example month ({momentum_scores[-1]["date"].date()}): held = {momentum_scores[-1]["held"]}')Momentum signals generated for 38 months Example month (2024-10-31): held = ['ETH', 'AVAX', 'MATIC']
Section 4 — Portfolio Backtest
We iterate month by month: apply the momentum signal, equal-weight the top-K assets, and hold until next rebalance. Transaction costs are applied as a fraction of the portfolio value each time assets change. This simulates realistic performance including turnover costs.
portfolio_rets = []
prev_held = []
for signal in momentum_scores:
date_from = signal['date']
held = signal['held']
# Get next month's daily returns
next_month_mask = (returns.index > date_from)
next_month_rets = returns[next_month_mask].iloc[:23] # ~1 month of trading days
if next_month_rets.empty:
continue
# Turnover cost
new_held = set(held)
old_held = set(prev_held)
turnover = len(new_held.symmetric_difference(old_held)) / (2 * len(held) + 1e-9)
daily_tc = TRANSACTION_COST * turnover / max(len(next_month_rets), 1)
# Portfolio daily return
port_daily = next_month_rets[held].mean(axis=1) - daily_tc
portfolio_rets.append(port_daily)
prev_held = held
if portfolio_rets:
port_series = pd.concat(portfolio_rets).sort_index()
port_series = port_series[~port_series.index.duplicated(keep='first')]
# Benchmark: equal-weight buy-and-hold over same period
bench = returns.loc[port_series.index].mean(axis=1)
equity_mom = (1 + port_series).cumprod()
equity_bench = (1 + bench).cumprod()
print(f'Momentum rotation total return: {equity_mom.iloc[-1]-1:.1%}')
print(f'Equal-weight benchmark return: {equity_bench.iloc[-1]-1:.1%}')
else:
print('No portfolio data generated. Try reducing LOOKBACK_MONTHS.')Momentum rotation total return: 153.6% Equal-weight benchmark return: 341.0%
Section 5 — Visualisation
The left panel compares the equity curves of the momentum rotation strategy vs the equal-weight benchmark. The right panel shows the monthly momentum scores as a heatmap — red cells are low-ranked assets (laggards), blue cells are high-ranked (leaders). This makes it easy to see which assets dominated during each period.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
fig.suptitle('Momentum Portfolio Rotation', fontsize=13, fontweight='bold')
ax1 = axes[0]
ax1.plot(equity_mom.index, equity_mom, color='#1976d2', lw=1.8, label='Momentum Rotation')
ax1.plot(equity_bench.index, equity_bench, color='#9e9e9e', lw=1.5, ls='--', label='Equal Weight')
ax1.set_ylabel('Growth of $1')
ax1.legend(fontsize=9)
ax1.set_title('Equity Curves')
ax2 = axes[1]
score_df = pd.DataFrame(
[{**{'date': s['date']}, **s['scores']} for s in momentum_scores[-24:]])
score_df.set_index('date', inplace=True)
sns.heatmap(score_df.T, ax=ax2, cmap='RdYlGn', center=0,
xticklabels=[str(d.date()) for d in score_df.index[::4]],
linewidths=0.3, cbar_kws={'label': 'Momentum Score'})
ax2.set_xticklabels(ax2.get_xticklabels(), rotation=45, fontsize=7)
ax2.set_title('Momentum Scores (last 24 months)')
plt.tight_layout(); plt.show()Section 6 — Export
Save daily portfolio returns and the monthly signal history. The signal history records which assets were held each month, useful for attribution analysis.
port_series.to_csv('momentum_portfolio_rotation.csv', header=['return'])
signal_log = pd.DataFrame([{'date': s['date'], 'held': ','.join(s['held'])} for s in momentum_scores])
signal_log.to_csv('momentum_signals.csv', index=False)
print('Saved: momentum_portfolio_rotation.csv, momentum_signals.csv')Saved: momentum_portfolio_rotation.csv, momentum_signals.csv
Section 7 — Conclusion
This notebook demonstrates a momentum portfolio rotation strategy applied to a universe of crypto assets. We observed how to fetch data, construct momentum signals, backtest a portfolio with transaction costs, visualize performance, and export the results. The strategy aims to capitalize on the tendency of assets that have performed well recently to continue performing well in the near future.