Portfolio & Risk·Portfolio Construction·Intermediate

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.

portfolio-theoryrisk-managementspot-trading

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:

  1. Fetches data (Yahoo Finance or synthetic) for a crypto universe
  2. Ranks assets monthly by momentum score
  3. Constructs the rotation portfolio and tracks its performance
  4. Compares momentum rotation vs equal-weight buy-and-hold
  5. Analyses turnover and transaction costs
  6. Exports signals and portfolio weights
[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
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.

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

[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)
    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.

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

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

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

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.

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