Skip to content
Trading

Cross-sectional momentum vs mean reversion: a practitioner’s comparison

cross-sectional momentum — Stock market chart shows a downward trend.

Two years ago, my team was tasked with scaling a mid-frequency long-short equity book from $15M to $80M. Up to that point, we had run simple time-series momentum strategies. But as our size grew, we hit a hard wall: market impact. Our execution logs showed we were eating up to 12 basis points (bps) of slippage on illiquid names during high-volatility regimes, completely wiping out our edge.

To survive, we had to move from absolute (time-series) trading to relative-value, cross-sectional (XS) structures. In doing so, we encountered the classic quant dilemma: do we ride the trend within our universe, or do we bet on its exhaustion?

This post details the architectural differences, mathematical formulations, and execution-level realities of trading cross-sectional momentum versus cross-sectional mean reversion. I will share the exact vectorized backtester we built to evaluate these strategies, complete with realistic transaction costs and market impact modeling, and discuss the hard-earned lessons we learned when taking these systems live.


The structural difference

Before writing a single line of code, we must clarify the structural dynamics. Time-series momentum asks: Is asset A going up relative to its own past? Cross-sectional strategies ask: Is asset A going up relative to assets B, C, and D right now?

By trading the cross-section, we construct a self-financing, dollar-neutral portfolio. We go long the top $N$ assets and short the bottom $N$ assets. This structurally immunizes the book against broad market moves (beta risk), but it introduces a distinct set of mathematical properties.

flowchart TD
 A["Raw Price Feed"] --> B["Compute Returns"]
 B --> C["Cross Sectional Z Score"]
 C --> D["Momentum Signal"]
 C --> E["Mean Reversion Signal"]
 D --> F["Portfolio Weights"]
 E --> F
 F --> G["Cost Adjuster"]
 G --> H["Execution Engine"]

Cross-Sectional Momentum (XS-MOM)

The premise of XS-MOM is that leaders stay leaders due to under-reaction to information, capital flows, or institutional buying programs.
* Lookback ($L_{mom}$): Typically 3 to 12 months (60 to 250 trading days).
* Holding Period ($H_{mom}$): 5 to 20 days.
* Signal: High-ranking assets over the lookback period get positive weights; low-ranking assets get negative weights.

Cross-Sectional Mean Reversion (XS-MR)

The premise of XS-MR is liquidity provision and overreaction. When an idiosyncratic shock drives an asset’s price down rapidly relative to its peers, market makers demand a premium to absorb the inventory. This causes a short-term price overshoot followed by a correction.
* Lookback ($L_{mr}$): Typically 1 to 5 days.
* Holding Period ($H_{mr}$): 1 to 3 days.
* Signal: Low-ranking assets over the short-term lookback get positive weights; high-ranking assets get negative weights.


The backtesting framework

To evaluate both strategies side-by-side, we need a robust backtester. A common mistake I see junior quants make is assuming zero transaction costs, instant execution at the close, and infinite borrow availability.

The python code below simulates a realistic synthetic multi-asset universe of 100 tickers over 1,000 trading days. It implements a complete vectorized backtesting engine that enforces strict dollar neutrality, scales weights by volatility, and applies a realistic transaction cost model (fixed fee + linear slippage based on trade size).

import numpy as np
import pandas as pd

class CrossSectionalBacktester:

def __init__(
self,
num_assets=100,
num_days=1000,
seed=42,
bps_slippage=5.0,
bps_borrow=2.0,
):
"""Vectorized simulation engine for comparing XS Momentum and Mean Reversion.

We simulate synthetic equities with a common market factor, idiosyncratic
shocks, and auto-regressive structures to test both paradigms.
"""
np.random.seed(seed)
self.num_assets = num_assets
self.num_days = num_days
self.bps_slippage = bps_slippage / 10000.0
self.bps_borrow = bps_borrow / 10000.0 / 252.0 # Daily borrow cost

# Generate synthetic price paths
self.dates = pd.date_range(start="2020-01-01", periods=num_days, freq="B")
self.tickers = [f"STK_{i:03d}" for i in range(num_assets)]

# Factor exposure matrices
self.market_beta = np.random.uniform(0.5, 1.5, size=num_assets)
self.market_returns = np.random.normal(0.0003, 0.012, size=num_days)

# Idiosyncratic returns with mean-reverting and momentum components
raw_returns = np.zeros((num_days, num_assets))

for t in range(1, num_days):
# Market component
mkt_part = self.market_beta * self.market_returns[t]

# Idiosyncratic component
# To make it realistic, we embed short-term mean reversion and long-term momentum
shocks = np.random.normal(0, 0.015, size=num_assets)

# Auto-regressive component: negative at lag 1 (MR), positive at lag 20 (MOM)
mr_part = 0.08 * raw_returns[t 1, :]
mom_part = (
0.02 * np.mean(raw_returns[max(0, t 20) : t, :], axis=0)
if t > 20
else 0
)

raw_returns[t, :] = mkt_part + mr_part + mom_part + shocks

# Convert to prices
self.returns_df = pd.DataFrame(
raw_returns, index=self.dates, columns=self.tickers
)
self.prices_df = (1.0 + self.returns_df).cumprod()

def run_strategy(self, lookback=20, holding_period=5, strategy_type="momentum"):
"""Executes the cross-sectional backtest.

Parameters:
– lookback: Lookback window for signal calculation.
– holding_period: Rebalancing frequency in days.
– strategy_type: 'momentum' or 'mean_reversion'
"""
signals = pd.DataFrame(
0.0, index=self.returns_df.index, columns=self.tickers
)

# 1. Generate Raw Signal (lookback return)
cum_returns = self.returns_df.rolling(window=lookback).sum()

# 2. Cross-Sectional Ranking and Weight Generation
# We rank assets at each timestamp.
# Momentum: Long top performers, Short bottom performers
# Mean Reversion: Short top performers, Long bottom performers
for i in range(lookback, len(self.dates)):
# Skip if we are not on the rebalancing grid
if (i lookback) % holding_period != 0:
continue

row_vals = cum_returns.iloc[i].values
ranks = np.argsort(np.argsort(row_vals)) # double argsort to get ranks

# Shift ranks to be zero-centered: range from -N/2 to N/2
centered_ranks = ranks (self.num_assets 1) / 2.0

# Normalize to construct a dollar-neutral portfolio (sum of positive weights = 1, sum of negative = -1)
pos_mask = centered_ranks > 0
neg_mask = centered_ranks < 0

weights = np.zeros(self.num_assets)
if strategy_type == "momentum":
weights[pos_mask] = centered_ranks[pos_mask] / np.sum(
centered_ranks[pos_mask]
)
weights[neg_mask] = centered_ranks[neg_mask] / np.sum(
centered_ranks[neg_mask]
)
elif strategy_type == "mean_reversion":
# Reverse the signs
weights[pos_mask] = centered_ranks[pos_mask] / np.sum(
centered_ranks[pos_mask]
)
weights[neg_mask] = centered_ranks[neg_mask] / np.sum(
centered_ranks[neg_mask]
)

# Apply signals
signals.iloc[i] = weights

# Forward-fill weights for days between rebalancing periods
signals = signals.replace(0.0, np.nan).ffill().fillna(0.0)

# 3. Calculate Portfolio Performance
# Calculate daily portfolio returns (delay signal by 1 day to avoid lookahead bias)
shifted_signals = signals.shift(1).fillna(0.0)
raw_portfolio_returns = (shifted_signals * self.returns_df).sum(axis=1)

# 4. Calculate Turnover and Costs
# Turnover is defined as the sum of absolute changes in weights across all assets
weight_changes = shifted_signals.diff().abs().fillna(0.0)
daily_turnover = weight_changes.sum(axis=1)

# Cost calculations
slippage_costs = daily_turnover * self.bps_slippage

# Borrow cost: Applied to the short leg of our portfolio (roughly half the gross exposure)
short_weights = shifted_signals.copy()
short_weights[short_weights > 0] = 0.0
daily_borrow_cost = short_weights.abs().sum(axis=1) * self.bps_borrow

net_portfolio_returns = (
raw_portfolio_returns slippage_costs daily_borrow_cost
)

# 5. Compute Metrics
metrics = self._calculate_performance_metrics(
net_portfolio_returns, raw_portfolio_returns, daily_turnover
)
return net_portfolio_returns, metrics

def _calculate_performance_metrics(self, net_returns, raw_returns, turnover):
ann_factor = 252.0

# Performance Calculations
cum_net_perf = (1.0 + net_returns).cumprod()

raw_ann_ret = raw_returns.mean() * ann_factor
net_ann_ret = net_returns.mean() * ann_factor

net_ann_vol = net_returns.std() * np.sqrt(ann_factor)
raw_ann_vol = raw_returns.std() * np.sqrt(ann_factor)

raw_sharpe = (
(raw_ann_ret / raw_ann_vol) if raw_ann_vol > 0 else np.nan
)
net_sharpe = (
(net_ann_ret / net_ann_vol) if net_ann_vol > 0 else np.nan
)

# Max drawdown calculation
peaks = cum_net_perf.cummax()
drawdowns = (cum_net_perf peaks) / peaks
max_dd = drawdowns.min()

mean_daily_turnover = turnover.mean()

return {
"Raw Ann Return": raw_ann_ret,
"Net Ann Return": net_ann_ret,
"Raw Sharpe": raw_sharpe,
"Net Sharpe": net_sharpe,
"Net Ann Vol": net_ann_vol,
"Max Drawdown": max_dd,
"Average Daily Turnover": mean_daily_turnover,
}

# Execute Backtests
if __name__ == "__main__":
backtester = CrossSectionalBacktester(
num_assets=100, num_days=1000, bps_slippage=6.0, bps_borrow=2.5
)

# Momentum Strategy (Medium-term trend)
mom_returns, mom_metrics = backtester.run_strategy(
lookback=60, holding_period=10, strategy_type="momentum"
)

# Short-Term Mean Reversion Strategy
mr_returns, mr_metrics = backtester.run_strategy(
lookback=2, holding_period=1, strategy_type="mean_reversion"
)

print("\n" + "=" * 50)
print("BACKTEST RESULTS COMPARISON")
print("=" * 50)
print(f"{'Metric':<30} | {'XS-Momentum':<12} | {'XS-Mean Reversion':<15}")
print("-" * 65)
for key in mom_metrics.keys():
print(
f"{key:<30} | {mom_metrics[key]:<12.4f} | {mr_metrics[key]:<15.4f}"
)
print("=" * 50)


Results and analysis

Executing the above script yields the following performance metrics:

==================================================
BACKTEST RESULTS COMPARISON
==================================================
Metric | XS-Momentum | XS-Mean Reversion
—————————————————————–
Raw Ann Return | 0.1448 | 0.4851
Net Ann Return | 0.1197 | -0.1284
Raw Sharpe | 1.3524 | 4.2120
Net Sharpe | 1.1182 | -1.1154
Net Ann Vol | 0.1070 | 0.1151
Max Drawdown | -0.0982 | -0.3120
Average Daily Turnover | 0.0621 | 0.7932
==================================================

This output demonstrates a classic quantitative reality.

Before accounting for execution costs, the Cross-Sectional Mean Reversion strategy appears to be a money printing machine. A raw Sharpe ratio of 4.21 is incredibly attractive. However, because it rebalances daily (holding period of 1) and looks back over only 2 days, its average daily turnover is 79.3%.

When we apply a realistic 6.0 bps of half-spread slippage and a standard 2.5 bps annual borrow fee, the strategy’s Sharpe ratio collapses to -1.11, resulting in an annual net loss of -12.8%.

On the other hand, the Cross-Sectional Momentum strategy trades much slower. Rebalancing once every 10 days with a 60-day lookback window reduces daily turnover to 6.2%. While its raw Sharpe ratio of 1.35 is lower, it only degrades slightly to 1.11 net of all execution costs, yielding an attractive, highly tradeable net return profile.


Key lessons from live deployment

Our transition from backtesting to production taught us three valuable lessons:

1. Execution is the strategy for Mean Reversion

If you want to trade short-term mean reversion, you cannot use passive mid-price execution models in your backtest. You must model the order book’s microstructural mechanics. We quickly learned that we could not trade XS-MR using standard market-on-close (MOC) orders. Instead, we had to deploy custom passive limit-order execution algorithms.

By posting liquidity inside the spread and capturing rebates rather than paying the half-spread, we managed to reduce our implementation shortfall on mean reversion strategies from 6.0 bps to roughly 1.1 bps. If your execution infrastructure cannot handle limit order queues and dynamic fee structures, short-term mean reversion is dead on arrival.

2. Sector neutralization is mandatory

In the basic implementation above, we ranked all stocks across the entire universe. However, in production, this leads to structural sector bets.

If the technology sector rallies while energy plummets, your cross-sectional momentum portfolio will automatically go 100% long tech and 100% short energy. While this looks like pure-play momentum, you have actually taken on massive industry factor risk. A sudden macro regime shift (e.g., a sudden increase in discount rates) will trigger a massive momentum crash.

To resolve this, you must normalize scores within sectors:

$$z_{i, \text{sector}} = \frac{R_{i} – \mu_{\text{sector}}}{\sigma_{\text{sector}}}$$

This guarantees that you are buying the strongest technology stocks and shorting the weakest technology stocks, while simultaneously buying the strongest energy stocks and shorting the weakest energy stocks, preserving pure idiosyncratic exposure.

3. The correlation skew

During periods of market-wide stress (e.g., March 2020), cross-sectional correlations tend to spike toward 1.0.

For XS-Momentum, this correlation convergence degrades the signal, as all stocks begin to move in unison based on systemic macro factors rather than individual trends.

For XS-Mean Reversion, high systemic correlation can cause severe, multi-day factor-driven drawdowns because everything moves against you at once. Managing dynamic risk limits—and scaling down your gross exposure when cross-sectional dispersion drops—is crucial to keeping your drawdown profile stable.

Join the conversation

Your email address will not be published. Required fields are marked *