Skip to content

Statistical Reversion Strategy

Mean reversion using z-scores and statistical measures.

Strategy Overview

Buy stocks that are statistically oversold, short stocks that are overbought, based on deviation from historical norms.

The Signal

Text Only
signal statistical_reversion:
  // Z-score of price vs moving average
  ma = rolling_mean(prices, 20)
  std = rolling_std(prices, 20)
  zscore = (prices - ma) / std

  // Negative zscore = oversold = buy
  emit -zscore

Complete Strategy

Text Only
data:
  source = "prices_with_sectors.parquet"
  format = parquet

// Statistical reversion signal
signal stat_reversion:
  // Multiple timeframe z-scores
  zscore_5 = (prices - rolling_mean(prices, 5)) / rolling_std(prices, 5)
  zscore_20 = (prices - rolling_mean(prices, 20)) / rolling_std(prices, 20)

  // Combine short and medium term
  combined = -0.4 * zscore_5 - 0.6 * zscore_20

  // Sector neutralize
  emit neutralize(combined, by=sectors)

portfolio stat_reversion:
  weights = rank(stat_reversion).long_short(
    top = 0.2,
    bottom = 0.2,
    cap = 0.03
  )

  constraints:
    gross_exposure = 2.0
    net_exposure = 0.0
    max_sector = 0.20

  costs = tc.bps(10)

  backtest rebal=5 from 2015-01-01 to 2024-12-31

Variations

Percentile-Based

Text Only
signal percentile_reversion:
  // Where is price in its historical range?
  percentile = ts_rank(prices, 252) / 252

  // Below 20% = oversold, above 80% = overbought
  signal = 0.5 - percentile

  emit zscore(signal)

Return-Based

Text Only
signal return_reversion:
  // Revert recent returns
  ret_5d = ret(prices, 5)
  ret_20d = ret(prices, 20)

  // Negative returns = buy signal
  emit -zscore(0.5 * ret_5d + 0.5 * ret_20d)

Residual Reversion

Text Only
signal residual_reversion:
  // Market-adjusted deviation
  ret_5d = ret(prices, 5)
  market_ret = ret(market, 5)

  // How much did stock over/underperform market?
  residual = ret_5d - market_ret

  // Revert the residual
  emit -zscore(residual)

Multi-Horizon Reversion

Text Only
signal multi_horizon:
  // Short-term (5-day)
  z5 = (prices - rolling_mean(prices, 5)) / rolling_std(prices, 5)

  // Medium-term (20-day)
  z20 = (prices - rolling_mean(prices, 20)) / rolling_std(prices, 20)

  // Long-term (60-day)
  z60 = (prices - rolling_mean(prices, 60)) / rolling_std(prices, 60)

  // Weight by decay speed
  combined = -0.5 * z5 - 0.35 * z20 - 0.15 * z60

  emit neutralize(combined, by=sectors)

With Volume Confirmation

Text Only
signal volume_confirmed:
  // Price deviation
  price_z = (prices - rolling_mean(prices, 20)) / rolling_std(prices, 20)

  // Volume spike = stronger signal
  vol_ratio = volume / rolling_mean(volume, 20)
  vol_spike = vol_ratio > 1.5

  // Boost signal when volume confirms
  signal = -price_z * where(vol_spike, 1.3, 1.0)

  emit zscore(signal)

Regime Filter

Text Only
signal filtered_reversion:
  // Base signal
  reversion = -zscore((prices - rolling_mean(prices, 20)) / rolling_std(prices, 20))

  // Trend filter - don't revert in strong trends
  ma_50 = rolling_mean(prices, 50)
  ma_200 = rolling_mean(prices, 200)
  trend_strength = abs(ma_50 - ma_200) / ma_200

  // Low trend = good for reversion
  low_trend = trend_strength < 0.05

  emit where(low_trend, reversion, reversion * 0.5)

Expected Results

Text Only
Backtest Results: stat_reversion
================================
Period: 2015-01-01 to 2024-12-31

Returns:
  Total Return: 62%
  Annual Return: 5.1%
  Annual Volatility: 8.3%
  Sharpe Ratio: 0.61

Turnover:
  Annual Turnover: 520%
  Avg Holding Period: 14 days

Performance by Regime:
  Low Trend: Sharpe 0.85
  High Trend: Sharpe 0.25

Risk Considerations

Trend Risk

Mean reversion fails in trends:

Text Only
// Stop loss mechanism
signal with_stop:
  reversion = stat_reversion

  // If position moving against us significantly, reduce
  position_pnl = position * ret(prices, 5)
  stop = position_pnl < -0.10  // 10% loss on position

  emit where(stop, 0, reversion)

Correlation Risk

All reversion trades can fail together:

Text Only
// Add diversification
constraints:
  max_position = 0.02  // Smaller positions
  min_positions = 50   // More positions

See Also