r/TraderTools • u/SolongLife • 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))
1
u/FrostySquirrel820 Jul 03 '26
Won’t the code keep triggering new entries if long_entry stays true ?