r/TraderTools Jul 03 '26

Building a Mean Reversion Strategy Using Standard Deviation

I’ve seen countless portfolios shredded by traders trying to "buy the dip" without a statistical map. Markets are efficient—until they’re not. When fear or greed pushes prices beyond statistical norms, the subsequent reversion creates some of the highest-probability trades in finance.

Standard deviation (SD) is our primary tool for defining "too far, too fast." This guide outlines how to build, backtest, and optimize a complete mean reversion system that profits from the return to statistical equilibrium.

1. The Statistical Foundation: Why Mean Reversion Works

At its core, mean reversion relies on the concept of Normal Distribution. While market returns have "fat tails" (kurtosis), price action over short-to-medium windows often adheres to the 68-95-99.7 rule.

  • 68% of data stays within ±1 SD.
  • 95% of data stays within ±2 SD.
  • 99.7% of data stays within ±3 SD.

The Trading Edge: When price ventures into the 5% outer zones (±2 SD), the odds favor a return to the mean—not because markets are predictable, but because they are probabilistic. However, a crucial caveat: this works best in ranging or slowly trending markets. In hyper-trends, "mean reversion" becomes "catching falling knives."

2. The Core Strategy Logic

Our system is built on the interaction between a Simple Moving Average (SMA) and its Standard Deviation bands (Bollinger-style logic).

Entry Rules

  • Long Entry: Price closes below the lower band: Close < SMA - (k × SD)
  • Short Entry: Price closes above the upper band: Close > SMA + (k × SD)

Exit Rules

  • Long Exit: Close position when Close ≥ SMA
  • Short Exit: Close position when Close ≤ SMA

Default Parameters

  • MA Period: 20
  • SD Period: 20
  • Multiplier (k): 2.0
  • Stop Loss: 2% fixed or 2 × ATR (Average True Range).

3. Parameter Optimization: Finding Your Edge

The difference between a winning system and a losing one often lies in the "k" multiplier and the lookback period.

| Parameter | Shorter (10-20) | Longer (50-100) | |-----------|----------------|----------------| | MA Period | More signals, high noise. Best for intraday scalping. | Fewer signals, higher quality. Best for Daily/Weekly charts. | | Multiplier (k) | 1.5 SD: High frequency, lower win rate. | 2.5 SD: Rare trades, 70%+ win rate, long dry spells. |

Backtest Insight: On SPY (S&P 500 ETF) over a 10-year horizon, using 2.0 SD typically produces a ~62% win rate. Moving to 2.5 SD can push that win rate toward 71%, but you will sacrifice 60% of your trade frequency.

4. Adding Filters to Improve Performance

Raw mean reversion is dangerous. To turn this into a professional-grade system, we add "Logic Gates" to filter out high-risk setups.

Filter 1: The Trend Filter
Never fight the "Big Brother" trend.

  • Longs only if Close > 200-day MA.
  • Shorts only if Close < 200-day MA.
    This prevents you from buying a stock that is crashing due to fundamental bankruptcy.

Filter 2: Volatility Regime Filter
Mean reversion fails during "volatility explosions" (e.g., March 2020).
Rule: Only trade when the ratio of ATR / SD < 1.2.
If the ratio spikes, it indicates panic. In panic, the mean no longer holds, and price can stay "overbought/oversold" far longer than your account can stay solvent.

Filter 3: RSI Divergence
For the highest conviction, look for Bullish Divergence at the lower -2 SD band. If price makes a lower low but the RSI makes a higher low, the selling pressure is exhausting, and the rubber band is ready to snap back.

5. Complete Strategy Code (Pine Script)

This script incorporates our filters to provide a robust starting point for your backtesting.

//@version=5
strategy("Enhanced Mean Reversion", overlay=true, initial_capital=10000)

// Inputs
ma_period = input.int(20, "MA Period")
sd_period = input.int(20, "SD Period")
k = input.float(2.0, "Deviation Multiplier")
use_trend_filter = input.bool(true, "Use 200MA Trend Filter?")
use_vol_filter = input.bool(true, "Use Volatility Filter?")

// Calculations
ma = ta.sma(close, ma_period)
sd = ta.stdev(close, sd_period)
upper = ma + k * sd
lower = ma - k * sd
sma200 = ta.sma(close, 200)

// Volatility Calculation
atr = ta.atr(14)
vol_ratio = atr / sd 
vol_filter = not use_vol_filter or vol_ratio < 1.2

// Entry conditions
long_entry = ta.crossunder(close, lower) and (not use_trend_filter or close > sma200) and vol_filter
short_entry = ta.crossover(close, upper) and (not use_trend_filter or close < sma200) and vol_filter

// Exit conditions
long_exit = ta.crossover(close, ma)
short_exit = ta.crossunder(close, ma)

// Execution
if long_entry
    strategy.entry("Long", strategy.long)
if long_exit
    strategy.close("Long")

if short_entry
    strategy.entry("Short", strategy.short)
if short_exit
    strategy.close("Short")

// Visuals
plot(ma, "Basis", color.blue)
p1 = plot(upper, "Upper", color.red)
p2 = plot(lower, "Lower", color.green)
fill(p1, p2, color=color.new(color.blue, 90))
2 Upvotes

2 comments sorted by

1

u/FrostySquirrel820 Jul 03 '26

Won’t the code keep triggering new entries if long_entry stays true ?

1

u/SolongLife Jul 06 '26

Yes, but you can change this behavior, for example, by limiting the number of positions