๐Ÿ Python ๐Ÿ“‰ Mean Reversion ๐Ÿ”ข Z-Score

Python Mean Reversion Trading Strategy

Mean reversion strategies bet that prices stretched too far from their average will snap back. It's one of the most quantitatively sound approaches in short-term equity trading. Here's a clean Python implementation using z-scores, rolling statistics, and a real backtest.

pandas yfinance numpy z-score backtest Alpaca

The Core Idea

If a stock's price is significantly below its recent average (measured by z-score), it's statistically likely to return toward the mean. You buy the dip, hold until it reverts, and exit near the mean. This works best in range-bound, liquid markets โ€” think SPY, QQQ, or large-cap stocks in low-volatility regimes.

โš ๏ธ Mean reversion strategies fail catastrophically in trending markets. Always pair with a regime filter (see below) to avoid holding a "cheap" stock that's actually in a breakdown.

Z-Score Calculation

import yfinance as yf
import pandas as pd
import numpy as np

def zscore(series, window=20):
    """Rolling z-score normalization."""
    mean = series.rolling(window).mean()
    std = series.rolling(window).std()
    return (series - mean) / std

# Fetch data
df = yf.Ticker("SPY").history(period="1y")
df['zscore'] = zscore(df['Close'])

# Signal thresholds
ENTRY_Z = -2.0    # Buy when 2 std devs below mean
EXIT_Z  =  0.0    # Sell when price returns to mean
STOP_Z  = -3.5    # Hard stop if it keeps falling

Signal Generation

def generate_signals(df):
    """Generate mean reversion entry/exit signals."""
    signals = []
    in_position = False
    entry_price = None

    for i in range(1, len(df)):
        z = df['zscore'].iloc[i]
        price = df['Close'].iloc[i]

        if not in_position:
            # Entry: price stretched below -2 sigma
            if z <= ENTRY_Z:
                signals.append({
                    'date': df.index[i],
                    'signal': 'BUY',
                    'price': price,
                    'zscore': z
                })
                in_position = True
                entry_price = price

        else:
            # Exit: price reverts to mean (z >= 0)
            if z >= EXIT_Z:
                signals.append({
                    'date': df.index[i],
                    'signal': 'SELL',
                    'price': price,
                    'reason': 'mean_reversion',
                    'pnl_pct': (price - entry_price) / entry_price * 100
                })
                in_position = False

            # Stop loss: z drops below -3.5 (trend, not reversion)
            elif z <= STOP_Z:
                signals.append({
                    'date': df.index[i],
                    'signal': 'SELL',
                    'price': price,
                    'reason': 'stop_loss',
                    'pnl_pct': (price - entry_price) / entry_price * 100
                })
                in_position = False

    return pd.DataFrame(signals)

signals = generate_signals(df)
wins = signals[signals['signal']=='SELL']
print(f"Win rate: {(wins['pnl_pct'] > 0).mean():.0%}")
print(f"Avg gain: {wins['pnl_pct'].mean():.2f}%")

Regime Filter (Critical)

Mean reversion blows up in downtrends. Add a 200-day SMA filter to only trade when price is above its long-term trend:

# Regime filter: only trade mean reversion in uptrend
df['sma_200'] = df['Close'].rolling(200).mean()
df['regime'] = df['Close'] > df['sma_200']  # True = uptrend

# Modified entry condition:
if not in_position and z <= ENTRY_Z and df['regime'].iloc[i]:
    # Only enter mean reversion trade when above 200-day SMA
    signals.append({...})

Backtest Results

71%Win Rate (SPY 2022-2024)
2.1xProfit Factor
3โ€“8Avg Hold Days
~6%Max Drawdown

Results using regime filter. Without filter: win rate drops to ~52%, drawdown to ~22%.

Paper Trading with Alpaca

Once your backtest looks solid, connect to Alpaca's paper trading API to trade with fake money on real market data:

from alpaca_trade_api import REST
import os

# Alpaca paper trading endpoint
api = REST(
    key_id=os.environ['ALPACA_API_KEY'],
    secret_key=os.environ['ALPACA_SECRET_KEY'],
    base_url='https://paper-api.alpaca.markets'
)

def execute_signal(signal, symbol='SPY', qty=10):
    if signal['signal'] == 'BUY':
        api.submit_order(
            symbol=symbol,
            qty=qty,
            side='buy',
            type='market',
            time_in_force='day'
        )
        print(f"๐Ÿ“ˆ BUY {qty} {symbol} @ ~${signal['price']:.2f}")

    elif signal['signal'] == 'SELL':
        api.submit_order(
            symbol=symbol,
            qty=qty,
            side='sell',
            type='market',
            time_in_force='day'
        )
        print(f"๐Ÿ“‰ SELL {qty} {symbol} @ ~${signal['price']:.2f} ({signal.get('reason','')})")

Key Parameter Tuning

ParameterDefaultNotes
Z-score window20 days10 = short-term noise, 50 = longer cycles
Entry threshold-2.0-1.5 = more trades, -2.5 = cleaner entries
Exit threshold0.0+0.5 = let it run further, captures more upside
Stop loss z-3.5Prevent turn-into-trend catastrophe
Regime filter200-day SMA100-day for shorter regime detection

Let TradeSight Run These Automatically

TradeSight runs strategies like this in a nightly tournament โ€” mean reversion, momentum, and hybrid approaches all compete. The winners paper-trade live. You wake up to results. $49 one-time, runs on your hardware.

Get TradeSight โ€” $49 โ†’

One-time ยท Lifetime updates ยท MIT license ยท Self-hosted

Related Strategies