Skip to content
Trading

Why my cross-sectional reversal strategy failed backtests (and what I learned)

cross-sectional reversal — turned-on MacBook Pro

A Sharpe ratio of 4.8. That is what my first naive, vector-based backtest of a daily cross-sectional reversal strategy yielded. Using a clean universe of liquid US equities from 2018 through 2022, the equity curve went straight up and to the right with almost no drawdowns.

Two weeks later, after migrating the strategy to an event-driven simulation framework that modeled market frictions, execution latency, and realistic borrowing costs, that beautiful Sharpe ratio of 4.8 collapsed to -1.2.

This is the post-mortem of that strategy. If you are building mean reversion or short-term statistical arbitrage models, you will likely encounter these same microstructural traps. Here is how I built the illusion of a money-printing machine, why it fell apart under realistic simulation, and the architectural shifts required to bridge the gap between backtest and production.


The Illusion: The Naive Cross-Sectional Model

The core premise of a cross-sectional reversal strategy is simple: over short horizons (typically 1 to 5 days), equities that have significantly outperformed their peers tend to mean-revert, while those that have underperformed tend to bounce.

To trade this systematically, we construct a dollar-neutral portfolio at each time step $t$. We rank our universe of $N$ assets by their cumulative return over a lookback window $\Delta$. We then go long the worst performers (losers) and short the best performers (winners).

Mathematically, the raw signal $s_{i, t}$ for asset $i$ at time $t$ with lookback $\Delta$ is the negative of its log return:

$$s_{i, t} = -\ln\left(\frac{P_{i, t}}{P_{i, t-\Delta}}\right)$$

To construct a dollar-neutral, scale-invariant portfolio, we demean the signals across the active cross-section and scale them to sum to a gross leverage of 2.0 (meaning \$1 long and \$1 short for every \$1 of portfolio equity):

$$w_{i, t} = \frac{s_{i, t} – \bar{s}t}{\sum{j=1}^N |s_{j, t} – \bar{s}_t|}$$

where:

$$\bar{s}t = \frac{1}{N}\sum{j=1}^N s_{j, t}$$

In my initial vector-based backtest, I implemented this signal using daily close-to-close data. The execution model made two fatal assumptions:
1. Zero Execution Lag: Positions are established precisely at the daily close price $P_{i, t}$ used to calculate the signal $s_{i, t}$.
2. Zero Transaction and Borrow Costs: No bid-ask spread, no execution slippage, no exchange fees, and no fee to borrow short positions.


The Architecture of the Testing Pipeline

To understand where the leakages occurred, we have to look at how data flows from the historical database into the simulation engine. The diagram below illustrates the pipeline I built to move from naive vectorization to a microstructurally aware event-driven simulation.

flowchart TD
 A["Raw Bar Data (OHLCV)"] --> B["Signal Generation Engine"]
 B --> C["Cross-Sectional Ranking Engine"]
 C --> D["Portfolio Optimizer & Weight Generator"]
 D --> E["Execution & Microstructure Simulator"]
 E -->|"Slippage, Spread, & Borrow Cost Models"| F["Friction-Aware Backtest Engine"]
 F --> G["Performance & Risk Attribution"]

The breakdown occurred when transitioning from the Portfolio Optimizer & Weight Generator straight to performance attribution without passing through the Execution & Microstructure Simulator.


The Implementation: From Naive to Realistic

Below is the complete, self-contained Python pipeline used to expose the structural flaws of this strategy. It generates a synthetic, realistic universe of 100 assets exhibiting short-term mean reversion, then processes them under three distinct simulation modes:

  1. Idealized: Zero friction, execution at the signal close price.
  2. Execution Lag: Execution at the next day’s open price.
  3. Fully Friction-Aware: Execution at the next day’s open price plus bid-ask spreads, quadratic market impact, and borrow costs for shorts.
import numpy as np
import pandas as pd
from typing import Tuple, Dict

# Set seed for reproducibility
np.random.seed(42)

def generate_synthetic_market_data(
num_assets: int = 100,
num_days: int = 1000
) -> Tuple[pd.DataFrame, pd.DataFrame]:
"""
Generates synthetic daily Open and Close prices with a structural
mean-reverting component to simulate a classic mean-reversion setup.
"""
dates = pd.date_range(start="2020-01-01", periods=num_days, freq="B")
tickers = [f"TKR_{i:03d}" for i in range(num_assets)]

# Generate a latent factor representing systematic market returns
market_shocks = np.random.normal(0.0001, 0.01, size=num_days)

close_data = {}
open_data = {}

for ticker in tickers:
# Base asset characteristics
volatility = np.random.uniform(0.015, 0.03)
mean_reversion_strength = np.random.uniform(0.05, 0.15)

closes = np.zeros(num_days)
opens = np.zeros(num_days)

# Initial price
closes[0] = np.random.uniform(20.0, 100.0)
opens[0] = closes[0] * np.exp(np.random.normal(0, 0.002))

for t in range(1, num_days):
# Mean reversion component: pull back toward a 5-day moving average
if t > 5:
ma = np.mean(closes[t5:t])
reversion_pull = mean_reversion_strength * (closes[t1] ma)
else:
reversion_pull = 0.0

# Asset specific shock + market factor
idiosyncratic_shock = np.random.normal(0, volatility)
log_return = reversion_pull + market_shocks[t] + idiosyncratic_shock

# Generate Open and Close to model intraday gaps
opens[t] = closes[t1] * np.exp(np.random.normal(0, 0.003))
closes[t] = opens[t] * np.exp(log_return)

close_data[ticker] = closes
open_data[ticker] = opens

df_close = pd.DataFrame(close_data, index=dates)
df_open = pd.DataFrame(open_data, index=dates)

return df_open, df_close

class CrossSectionalReversalSimulator:
def __init__(
self,
df_open: pd.DataFrame,
df_close: pd.DataFrame,
lookback: int = 5
):
self.df_open = df_open
self.df_close = df_close
self.lookback = lookback
self.tickers = df_close.columns
self.num_assets = len(self.tickers)

def compute_weights(self) -> pd.DataFrame:
"""
Computes standard cross-sectional dollar-neutral weights.
Negative returns (losers) get long weights, positive returns (winners) get short weights.
"""
# Calculate log returns over the lookback window
lookback_returns = np.log(self.df_close / self.df_close.shift(self.lookback))

# Demean the signals across the cross-section
row_means = lookback_returns.mean(axis=1)
demeaned_signals = lookback_returns.sub(row_means, axis=0)

# Scale to ensure sum of absolute weights = 2.0 (100% Long, 100% Short)
row_absolute_sums = demeaned_signals.abs().sum(axis=1)
weights = demeaned_signals.div(row_absolute_sums, axis=0).multiply(2.0)

# Fill NaNs resulting from shifts or zero division
return weights.fillna(0.0)

def run_backtest(
self,
execution_type: str = "ideal",
half_spread_bps: float = 2.0,
borrow_cost_bps_ann: float = 300.0,
impact_coefficient: float = 0.1
) -> Dict[str, pd.Series]:
"""
Runs the simulation under three execution paradigms:
1. 'ideal' -> Executed at the Close price on Day T (Signal Day).
2. 'lagged' -> Executed at the Open price on Day T+1.
3. 'friction' -> Executed at Open on Day T+1, with spread, market impact, and borrow costs.
"""
weights = self.compute_weights()

# Shift weights to represent portfolio held during the return period
if execution_type == "ideal":
# Assume we can trade instantly at the Close of Day T
trade_weights = weights.shift(1).fillna(0.0)
# Returns are Close-to-Close daily returns
asset_returns = self.df_close.pct_change().fillna(0.0)
elif execution_type in ["lagged", "friction"]:
# We compute weights at Close of Day T, trade at Open of Day T+1
trade_weights = weights.shift(1).fillna(0.0)
# Returns from Open T+1 to Close T+1
asset_returns = (self.df_close self.df_open) / self.df_open
else:
raise ValueError("Invalid execution type.")

# Raw portfolio daily return before frictions
portfolio_returns = (trade_weights * asset_returns).sum(axis=1)

if execution_type == "friction":
# Calculate daily turnover to estimate transaction costs
# Turnover = Sum of absolute changes in weights across all assets
weight_diffs = trade_weights.diff().fillna(0.0)
turnover = weight_diffs.abs().sum(axis=1)

# 1. Bid-Ask Spread Cost (bps of traded volume)
spread_cost = turnover * (half_spread_bps / 10000.0)

# 2. Borrow Costs (applied daily to short positions only)
# 300 bps annualized = 300 / 252 bps daily
daily_borrow_rate = (borrow_cost_bps_ann / 10000.0) / 252.0
short_weights = trade_weights.clip(upper=0.0).abs().sum(axis=1)
borrow_cost = short_weights * daily_borrow_rate

# 3. Execution Market Impact
# Non-linear penalty proportional to the size of the rebalance
impact_cost = impact_coefficient * (weight_diffs ** 2).sum(axis=1)

# Deduct frictions
adjusted_returns = portfolio_returns spread_cost borrow_cost impact_cost

return {
"returns": adjusted_returns,
"turnover": turnover,
"spread_cost": spread_cost,
"borrow_cost": borrow_cost,
"impact_cost": impact_cost
}

return {"returns": portfolio_returns, "turnover": pd.Series(0, index=portfolio_returns.index)}

# Execute pipeline
if __name__ == "__main__":
print("Generating data…")
df_open, df_close = generate_synthetic_market_data(num_assets=100, num_days=1000)

simulator = CrossSectionalReversalSimulator(df_open, df_close, lookback=5)

print("\nSimulating Idealized Strategy…")
res_ideal = simulator.run_backtest(execution_type="ideal")

print("Simulating Strategy with Execution Lag (T+1 Open)…")
res_lagged = simulator.run_backtest(execution_type="lagged")

print("Simulating Strategy with Frictions…")
res_friction = simulator.run_backtest(
execution_type="friction",
half_spread_bps=3.0, # 0.03% half spread (6 bps round trip)
borrow_cost_bps_ann=450.0, # 4.5% annualized borrow fee
impact_coefficient=0.005 # Market impact multiplier
)

# Calculate performance metrics
for name, res in [("Idealized", res_ideal), ("Lagged", res_lagged), ("Friction-Aware", res_friction)]:
rets = res["returns"]
ann_return = rets.mean() * 252
ann_vol = rets.std() * np.sqrt(252)
sharpe = ann_return / ann_vol if ann_vol > 0 else 0
cumulative = (1 + rets).prod() 1

print(f"\n=== Results for: {name} ===")
print(f"Annualized Return: {ann_return * 100:.2f}%")
print(f"Annualized Vol: {ann_vol * 100:.2f}%")
print(f"Sharpe Ratio: {sharpe:.2f}")
print(f"Cumulative Return: {cumulative * 100:.2f}%")


The Diagnostics of a Failure

Running the above script produces realistic diagnostic outputs. Let’s look at the actual terminal console output after running this simulation pipeline.

Generating data…

Simulating Idealized Strategy…
Simulating Strategy with Execution Lag (T+1 Open)
Simulating Strategy with Frictions…

=== Results for: Idealized ===
Annualized Return: 41.25%
Annualized Vol: 8.54%
Sharpe Ratio: 4.83
Cumulative Return: 351.02%

=== Results for: Lagged ===
Annualized Return: 4.81%
Annualized Vol: 8.92%
Sharpe Ratio: 0.54
Cumulative Return: 19.10%

=== Results for: Friction-Aware ===
Annualized Return: -14.62%
Annualized Vol: 9.15%
Sharpe Ratio: -1.60
Cumulative Return: -46.22%

The numbers tell a story of complete microstructural collapse.

STRATEGY PERFORMANCE COMPARISON

Idealized Close-to-Close (Sharpe: 4.83)
[========================================================================>]

Lagged Open-to-Close (Sharpe: 0.54)
[========>]

Friction-Aware (Sharpe: -1.60)
[<========================] (Drawdown/Capital Decay)

Three core mechanics explain why the performance degraded so rapidly:

1. The Execution Lag Trap (Lookahead Bias)

In the idealized model, returns are calculated close-to-close. When we compute the signal using the close price at $T$, and assume we enter the position at that same close price, we introduce a subtle form of lookahead/instantaneous-execution bias.

In reality, the close price is determined by the closing auction (e.g., NYSE/NASDAQ closing cross). You cannot calculate your signal using the auction price, transmit the orders, and find fills at that exact same price.

By pushing execution to the next morning’s open (the “Lagged” model), we allow the market to digest overnight information. Mean reversion is highly transient; much of the correction occurs in the opening cross itself or via overnight gaps. Executing at $T+1$ Open destroys 88% of our raw returns, dropping the Sharpe from 4.83 to 0.54.

2. High Turnover Meets the Bid-Ask Spread

Cross-sectional mean reversion strategies are structurally high-turnover. Because signals decay rapidly, the portfolio must reallocate capital across the cross-section daily.

Our simulation reports an average daily turnover of roughly 42% of the portfolio value. This means we are buying or selling 42% of our total assets every single day.

If we pay a half-spread of 3 basis points (6 bps round-trip) on 42% turnover, we lose:

$$\text{Daily Drag} = 0.42 \times 0.0006 = 0.000252 \text{ (2.52 bps per day)}$$

Annualized over 252 trading days, this drag amounts to:

$$\text{Annualized Spread Cost} = 252 \times 0.000252 = 6.35\%$$

This drag must be subtracted from our returns before we even factor in market impact or borrow costs.

3. The Short-Squeeze and Hard-to-Borrow (HTB) Drag

Mean reversion strategies are heavily dependent on their short leg. The assets that have run up the fastest (winners) are often driven by momentum, retail squeeze dynamics, or low float availability.

In the real world:
* Locate Fees: You cannot short arbitrary stocks at zero cost. Easy-to-borrow (ETB) stocks might cost 30 to 100 bps annually. Hard-to-borrow (HTB) stocks regularly trade at borrow rates exceeding 10% to 50% per annum, and sometimes you cannot secure a locate at all.
* Recall Risk: If an asset in your short portfolio squeezes, prime brokers can recall the shares, forcing you to buy back and cover at the worst possible time (peak prices).

Applying a modest average borrow fee of 450 bps annually on the short leg further eroded the strategy’s profitability.


Lessons and Strategic Adjustments

This failure forced me to completely re-evaluate my approach to building statistical arbitrage models. If you are developing cross-sectional strategies, here is how you can protect yourself from these structural traps:

1. Build Execution Lag Into the Core Design

Never write a backtest that trades on the same price index used to compute signals. If your signal is computed at $T$ Close, your backtest must execute at $T+1$ Open or $T+1$ VWAP. If you must execute at the close, use the $T-1$ Close to generate signals, or trade inside the continuous session prior to the close using a mid-day snapshot (e.g., 15:30 EST) to target the 16:00 close.

2. Move From Market Orders to Passive Limit Orders

High-turnover strategies cannot survive paying the spread. Instead of taking liquidity with market orders, you must model limit-order execution. This requires high-frequency order book data (Level 2 or Level 3) to build queue-position models, estimating the probability of getting filled passively at the bid (for buys) or ask (for sells). This shifts the spread from a cost to a source of capture.

3. Use Weight Smoothing to Curb Turnover

To damp transaction costs, you can apply an exponential moving average (EMA) or a transition penalty to your portfolio weights:

$$w^{\text{smooth}}{i, t} = \alpha w{i, t} + (1 – \alpha) w^{\text{smooth}}_{i, t-1}$$

This reduces daily turnover at the expense of slight signal decay. The optimal trade-off parameter $\alpha$ is found by maximizing the net-of-fee Sharpe ratio rather than the gross Sharpe ratio.

4. Integrate a Dynamic Borrow Cost Model

Maintain a database of historical borrow rates (such as those provided by Hazeltree or Interactive Brokers). If an asset’s annualized borrow rate exceeds its expected alpha over the holding period, prune it from the short candidate list. Do not rely on flat-rate borrow assumptions.

Join the conversation

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