← Back to Home
An Ornstein-Uhlenbeck Mean-Reversion Strategy with Python and Backtrader

An Ornstein-Uhlenbeck Mean-Reversion Strategy with Python and Backtrader

In the dynamic world of financial markets, prices often exhibit seemingly random walks. However, certain theories suggest that prices, particularly for some assets, tend to revert to their long-term average. This concept, known as mean reversion, forms the basis for numerous trading strategies. But can a sophisticated mathematical model, like the Ornstein-Uhlenbeck (OU) process, truly capture and profit from this elusive market behavior?

This article delves into the implementation of an Ornstein-Uhlenbeck Mean Reversion strategy using backtrader, a powerful Python backtesting framework, and yfinance for data acquisition. We’ll explore how to model asset prices as an OU process, estimate its parameters on a rolling basis, and generate trading signals based on deviations from the estimated mean.

Understanding the Ornstein-Uhlenbeck Process

The Ornstein-Uhlenbeck process is a continuous-time stochastic process that describes the velocity of a particle undergoing Brownian motion under the influence of a spring-like force that pulls it towards a central equilibrium position. In finance, it’s often used to model variables that are mean-reverting, such as interest rates, commodity prices, or, as in our case, the logarithm of asset prices.

The key parameters of an OU process are:

The Strategy: OU Mean Reversion with Trend Filter

Our strategy, implemented in backtrader, aims to identify when an asset’s price has deviated significantly from its estimated OU mean and then trade on the expectation that it will revert. To enhance the robustness of the strategy and avoid false signals in strong trends, we incorporate a simple moving average (SMA) as a trend filter.

Here’s how it works:

  1. Rolling OU Parameter Estimation: The strategy continuously estimates the \mu, \theta, and \sigma parameters of the Ornstein-Uhlenbeck process over a defined lookback period using Ordinary Least Squares (OLS) regression on the log prices. This allows the strategy to adapt to changing market conditions.
  2. Z-Score Calculation: For each bar, a Z-score is calculated, representing how many standard deviations the current log price is away from the estimated long-term mean (\mu), normalized by the equilibrium standard deviation (\sigma / \sqrt{2\theta}).
  3. Trend Filtering: A Simple Moving Average (SMA) of the closing prices is calculated. This acts as a filter to ensure trades are placed in alignment with the broader trend.
  4. Entry Signals:
    • Long Entry: If the Z-score falls below a negative entry_threshold (indicating the price is significantly below its mean) AND the current price is above the SMA (indicating an uptrend), a long position is initiated. The rationale is to buy a “cheap” asset in an upward-trending market, expecting it to revert to its mean.
    • Short Entry: If the Z-score rises above a positive entry_threshold (indicating the price is significantly above its mean) AND the current price is below the SMA (indicating a downtrend), a short position is initiated. Here, we sell a “expensive” asset in a downward-trending market, expecting it to revert downwards.
  5. Exit Signals:
    • Long Exit: A long position is exited when the Z-score rises above a negative exit_threshold (i.e., the price has moved back closer to or above its mean).
    • Short Exit: A short position is exited when the Z-score falls below a positive exit_threshold (i.e., the price has moved back closer to or below its mean).

Python Code

Let’s break down the Python code for this strategy.

import backtrader as bt
import yfinance as yf
import numpy as np
import pandas as pd
from scipy import stats
import warnings

warnings.filterwarnings("ignore")

import matplotlib.pyplot as plt
%matplotlib inline

class OUMeanReversionStrategy(bt.Strategy):
    """
    Ornstein-Uhlenbeck Mean Reversion Strategy
    
    The strategy estimates OU process parameters over a rolling window
    and generates trading signals based on deviations from the estimated mean.
    """
    
    params = (
        ('lookback', 60),           # Rolling window for OU parameter estimation
        ('sma_period', 30),         # sma period for trend
        ('entry_threshold', 1.5),   # Z-score threshold for entry
        ('exit_threshold', 0.5),    # Z-score threshold for exit
        ('printlog', False),        # Print trade logs
    )
    
    def __init__(self):
        # Data feeds
        self.dataclose = self.datas[0].close
        self.sma = bt.indicators.SimpleMovingAverage(self.dataclose, period=self.params.sma_period)  # Add this line
        
        # Track our position
        self.order = None
        self.position_type = None  # 'long', 'short', or None
        
        # Store OU parameters and signals
        self.ou_params = []
        self.z_scores = []
        
    def log(self, txt, dt=None):
        """Logging function"""
        if self.params.printlog:
            dt = dt or self.datas[0].datetime.date(0)
            print(f'{dt.isoformat()}: {txt}')
    
    def notify_order(self, order):
        """Handle order notifications"""
        if order.status in [order.Submitted, order.Accepted]:
            return
            
        if order.status in [order.Completed]:
            if order.isbuy():
                self.log(f'BUY EXECUTED: Price: {order.executed.price:.4f}, '
                         f'Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}')
            else:
                self.log(f'SELL EXECUTED: Price: {order.executed.price:.4f}, '
                         f'Cost: {order.executed.value:.2f}, Comm: {order.executed.comm:.2f}')
                        
        elif order.status in [order.Canceled, order.Margin, order.Rejected]:
            self.log('Order Canceled/Margin/Rejected')
            
        self.order = None
    
    def estimate_ou_parameters(self, log_prices):
        """
        Estimate Ornstein-Uhlenbeck parameters using OLS regression
        
        OU process: dX = θ(μ - X)dt + σdW
        Discretized: X_t - X_{t-1} = θμΔt - θX_{t-1}Δt + ε_t
        
        Returns: (mu, theta, sigma, equilibrium_std)
        """
        if len(log_prices) < 10:  # Need minimum data points
            return None, None, None, None
            
        # Prepare regression data
        x_lag = log_prices[:-1]  # X_{t-1}
        dx = np.diff(log_prices)  # X_t - X_{t-1}
        
        try:
            # OLS regression: dx = alpha + beta * x_lag
            slope, intercept, r_value, p_value, std_err = stats.linregress(x_lag, dx)
            
            # Convert to OU parameters (assuming dt = 1)
            theta = -slope
            mu = intercept / theta if theta > 1e-6 else np.mean(log_prices)
            
            # Estimate sigma from residuals
            residuals = dx - (intercept + slope * x_lag)
            sigma = np.std(residuals)
            
            # Equilibrium standard deviation
            equilibrium_std = sigma / np.sqrt(2 * theta) if theta > 1e-6 else sigma
            
            return mu, theta, sigma, equilibrium_std
            
        except Exception as e:
            return None, None, None, None
    
    def next(self):
        """Main strategy logic called on each bar"""
        
        # Need enough data for parameter estimation
        if len(self.dataclose) < self.params.lookback:
            return
            
        # Get recent log prices for parameter estimation
        recent_log_prices = np.array([np.log(self.dataclose[-i]) for i in range(self.params.lookback-1, -1, -1)])
        
        # Estimate OU parameters
        mu, theta, sigma, eq_std = self.estimate_ou_parameters(recent_log_prices)
        
        if mu is None or eq_std is None or eq_std <= 0:
            return
            
        # Calculate current deviation and z-score
        current_log_price = np.log(self.dataclose[0])
        deviation = current_log_price - mu
        z_score = deviation / eq_std
        
        # Store for analysis
        self.ou_params.append({'mu': mu, 'theta': theta, 'sigma': sigma, 'eq_std': eq_std})
        self.z_scores.append(z_score)
        
        self.log(f'Close: {self.dataclose[0]:.4f}, Log Price: {current_log_price:.4f}, '
                 f'μ: {mu:.4f}, Z-Score: {z_score:.2f}')
        
        # Skip if we have a pending order
        if self.order:
            return
        
        # Trading logic
        if not self.position:  # No position
            if z_score < -self.params.entry_threshold and self.dataclose[0] > self.sma[0]:
                # Price below mean AND uptrending - go long (expect reversion up)
                self.log(f'LONG SIGNAL: Z-Score {z_score:.2f}')
                self.order = self.buy()
                self.position_type = 'long'
                
            elif z_score > self.params.entry_threshold and self.dataclose[0] < self.sma[0]:
                # Price above mean AND downtrending - go short (expect reversion down)
                self.log(f'SHORT SIGNAL: Z-Score {z_score:.2f}')
                self.order = self.sell()
                self.position_type = 'short'
                
        else:  # We have a position
            if self.position_type == 'long' and z_score > -self.params.exit_threshold:
                # Exit long position
                self.log(f'EXIT LONG: Z-Score {z_score:.2f}')
                self.order = self.sell()
                self.position_type = None
                
            elif self.position_type == 'short' and z_score < self.params.exit_threshold:
                # Exit short position
                self.log(f'EXIT SHORT: Z-Score {z_score:.2f}')
                self.order = self.buy()
                self.position_type = None


def run_ou_strategy(ticker='EURUSD=X', start_date='2020-01-01', end_date='2024-12-31', 
                    cash=10000, lookback=60, sma_period=30, entry_threshold=1.5, exit_threshold=0.5):
    """
    Run the OU Mean Reversion strategy
    """
    
    print(f"=== OU Mean Reversion Strategy ===")
    print(f"Ticker: {ticker}")
    print(f"Period: {start_date} to {end_date}")
    print(f"Lookback: {lookback} days")
    print(f"Entry Threshold: ±{entry_threshold}")
    print(f"Exit Threshold: ±{exit_threshold}")
    print(f"Initial Cash: ${cash:,.2f}")
    print("=" * 50)
    
    # Download data
    print("Downloading data...")
    data = yf.download(ticker, start=start_date, end=end_date, auto_adjust=False).droplevel(1, 1)
    
    if data.empty:
        print(f"No data found for {ticker}")
        return None
    
    # Convert to Backtrader format
    bt_data = bt.feeds.PandasData(dataname=data)
    
    # Create Cerebro engine
    cerebro = bt.Cerebro()
    
    # Add strategy
    cerebro.addstrategy(OUMeanReversionStrategy,
                        lookback=lookback,
                        sma_period=sma_period,
                        entry_threshold=entry_threshold,
                        exit_threshold=exit_threshold,
                        printlog=False)  # Set to True for detailed logs
    
    # Add data
    cerebro.adddata(bt_data)
    
    # Set cash
    cerebro.broker.setcash(cash)
    
    # Add commission (0.1% per trade)
    cerebro.broker.setcommission(commission=0.001)
    
    cerebro.addsizer(bt.sizers.PercentSizer, percents=95)

    
    # Add analyzers
    cerebro.addanalyzer(bt.analyzers.Returns, _name='returns')
    cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='sharpe')
    cerebro.addanalyzer(bt.analyzers.DrawDown, _name='drawdown')
    cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name='trades')
    
    # Run strategy
    print("Running strategy...")
    results = cerebro.run()
    strat = results[0]
    
    # Print results
    print("\n=== PERFORMANCE SUMMARY ===")
    
    final_value = cerebro.broker.getvalue()
    total_return = (final_value - cash) / cash * 100
    print(f"Initial Portfolio Value: ${cash:,.2f}")
    print(f"Final Portfolio Value: ${final_value:,.2f}")
    print(f"Total Return: {total_return:.2f}%")
    
    # Get analyzer results
    returns_analysis = strat.analyzers.returns.get_analysis()
    sharpe_analysis = strat.analyzers.sharpe.get_analysis()
    drawdown_analysis = strat.analyzers.drawdown.get_analysis()
    trades_analysis = strat.analyzers.trades.get_analysis()
    
    print(f"\nAnnualized Return: {returns_analysis.get('rnorm100', 0):.2f}%")
    print(f"Sharpe Ratio: {sharpe_analysis.get('sharperatio', 0):.2f}")
    print(f"Max Drawdown: {drawdown_analysis.get('max', {}).get('drawdown', 0):.2f}%")
    
    if 'total' in trades_analysis:
        total_trades = trades_analysis['total']['total']
        won_trades = trades_analysis['won']['total'] if 'won' in trades_analysis else 0
        win_rate = (won_trades / total_trades * 100) if total_trades > 0 else 0
        print(f"Total Trades: {total_trades}")
        print(f"Win Rate: {win_rate:.1f}%")
    
    # Plot results
    print("\nGenerating plots...")
    plt.rcParams['figure.figsize'] = [10, 6]
    cerebro.plot(style='candlestick', barup='green', bardown='red', iplot=False)
    
    return cerebro, results


# Example usage
if __name__ == '__main__':
    # Run the strategy
    cerebro, results = run_ou_strategy(
        ticker='ETH-USD',
        start_date='2020-01-01', 
        end_date='2024-12-31',
        cash=10000,
        lookback=30,
        sma_period=30, 
        entry_threshold=1.2,
        exit_threshold=0.8
    )
    
    # You can also test with other assets like stocks or crypto
    # cerebro, results = run_ou_strategy(ticker='AAPL', start_date='2020-01-01', end_date='2024-12-31')
    # cerebro, results = run_ou_strategy(ticker='BTC-USD', start_date='2020-01-01', end_date='2024-12-31')

OUMeanReversionStrategy Class:

run_ou_strategy Function:

Running the Example

The if __name__ == '__main__': block demonstrates how to run the strategy:

if __name__ == '__main__':
    # Run the strategy
    cerebro, results = run_ou_strategy(
        ticker='ETH-USD',
        start_date='2020-01-01', 
        end_date='2024-12-31',
        cash=10000,
        lookback=30,
        sma_period=30, 
        entry_threshold=1.2,
        exit_threshold=0.8
    )

This example runs the strategy on ETH-USD (Ethereum to USD) data from 2020 to 2024 with specific parameters. You can easily modify the ticker, start_date, end_date, and strategy parameters to test it on different assets (e.g., 'AAPL' for Apple stock or 'BTC-USD' for Bitcoin) and optimize its performance.

Pasted image 20250606194215.png

Conclusion

The Ornstein-Uhlenbeck Mean Reversion strategy offers an intriguing approach to trading, attempting to capitalize on the tendency of prices to revert to their historical averages. By dynamically estimating the OU process parameters and incorporating a trend filter, this strategy aims to generate robust trading signals. While mean reversion strategies can be effective in certain market regimes, it’s crucial to remember that past performance does not guarantee future results. Further research, optimization, and robust out-of-sample testing are always recommended before deploying any trading strategy in a live environment.