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.
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:
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:
lookback
period
using Ordinary Least Squares (OLS) regression on the log prices. This
allows the strategy to adapt to changing market conditions.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.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.exit_threshold
(i.e., the
price has moved back closer to or above its mean).exit_threshold
(i.e., the
price has moved back closer to or below its mean).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
"ignore")
warnings.filterwarnings(
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 or self.datas[0].datetime.date(0)
dt 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
= log_prices[:-1] # X_{t-1}
x_lag = np.diff(log_prices) # X_t - X_{t-1}
dx
try:
# OLS regression: dx = alpha + beta * x_lag
= stats.linregress(x_lag, dx)
slope, intercept, r_value, p_value, std_err
# Convert to OU parameters (assuming dt = 1)
= -slope
theta = intercept / theta if theta > 1e-6 else np.mean(log_prices)
mu
# Estimate sigma from residuals
= dx - (intercept + slope * x_lag)
residuals = np.std(residuals)
sigma
# Equilibrium standard deviation
= sigma / np.sqrt(2 * theta) if theta > 1e-6 else sigma
equilibrium_std
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
= np.array([np.log(self.dataclose[-i]) for i in range(self.params.lookback-1, -1, -1)])
recent_log_prices
# Estimate OU parameters
= self.estimate_ou_parameters(recent_log_prices)
mu, theta, sigma, eq_std
if mu is None or eq_std is None or eq_std <= 0:
return
# Calculate current deviation and z-score
= np.log(self.dataclose[0])
current_log_price = current_log_price - mu
deviation = deviation / eq_std
z_score
# 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',
=10000, lookback=60, sma_period=30, entry_threshold=1.5, exit_threshold=0.5):
cash"""
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...")
= yf.download(ticker, start=start_date, end=end_date, auto_adjust=False).droplevel(1, 1)
data
if data.empty:
print(f"No data found for {ticker}")
return None
# Convert to Backtrader format
= bt.feeds.PandasData(dataname=data)
bt_data
# Create Cerebro engine
= bt.Cerebro()
cerebro
# Add strategy
cerebro.addstrategy(OUMeanReversionStrategy,=lookback,
lookback=sma_period,
sma_period=entry_threshold,
entry_threshold=exit_threshold,
exit_threshold=False) # Set to True for detailed logs
printlog
# Add data
cerebro.adddata(bt_data)
# Set cash
cerebro.broker.setcash(cash)
# Add commission (0.1% per trade)
=0.001)
cerebro.broker.setcommission(commission
=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
# Add analyzers
='returns')
cerebro.addanalyzer(bt.analyzers.Returns, _name='sharpe')
cerebro.addanalyzer(bt.analyzers.SharpeRatio, _name='drawdown')
cerebro.addanalyzer(bt.analyzers.DrawDown, _name='trades')
cerebro.addanalyzer(bt.analyzers.TradeAnalyzer, _name
# Run strategy
print("Running strategy...")
= cerebro.run()
results = results[0]
strat
# Print results
print("\n=== PERFORMANCE SUMMARY ===")
= cerebro.broker.getvalue()
final_value = (final_value - cash) / cash * 100
total_return 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
= strat.analyzers.returns.get_analysis()
returns_analysis = strat.analyzers.sharpe.get_analysis()
sharpe_analysis = strat.analyzers.drawdown.get_analysis()
drawdown_analysis = strat.analyzers.trades.get_analysis()
trades_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:
= trades_analysis['total']['total']
total_trades = trades_analysis['won']['total'] if 'won' in trades_analysis else 0
won_trades = (won_trades / total_trades * 100) if total_trades > 0 else 0
win_rate print(f"Total Trades: {total_trades}")
print(f"Win Rate: {win_rate:.1f}%")
# Plot results
print("\nGenerating plots...")
'figure.figsize'] = [10, 6]
plt.rcParams[='candlestick', barup='green', bardown='red', iplot=False)
cerebro.plot(style
return cerebro, results
# Example usage
if __name__ == '__main__':
# Run the strategy
= run_ou_strategy(
cerebro, results ='ETH-USD',
ticker='2020-01-01',
start_date='2024-12-31',
end_date=10000,
cash=30,
lookback=30,
sma_period=1.2,
entry_threshold=0.8
exit_threshold
)
# 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:params
: Defines the configurable
parameters of the strategy:
lookback
: The number of past data points (days) to use
for estimating OU parameters.sma_period
: The period for the Simple Moving Average
(SMA) trend filter.entry_threshold
: The Z-score deviation required to
initiate a trade.exit_threshold
: The Z-score deviation at which to close
an open position.printlog
: A boolean to control detailed logging.__init__(self)
:
self.dataclose
to easily access the closing
price.SimpleMovingAverage
indicator.self.order
to track pending orders and
self.position_type
to track the current position
(long/short/none).self.ou_params
and self.z_scores
lists are
used to store calculated values for later analysis/plotting.log(self, txt, dt=None)
: A utility
function for logging messages, controlled by printlog
.notify_order(self, order)
: A
backtrader
callback method that is invoked when an order
changes its status (e.g., submitted, completed, canceled). It logs the
details of executed trades and handles pending order status.estimate_ou_parameters(self, log_prices)
:
This is the core mathematical function:
log_prices
(logarithm of asset
prices).x_lag
(lagged log prices) and dx
(daily changes in log prices).stats.linregress
) to
find the slope
and intercept
of
dx
against x_lag
.slope
and intercept
values are then
used to calculate the OU parameters:
theta = -slope
: The speed of reversion.mu = intercept / theta
: The long-term mean.sigma
: Estimated from the standard deviation of the
regression residuals.equilibrium_std = sigma / np.sqrt(2 * theta)
: The
standard deviation of the process at equilibrium.None
if parameters
cannot be estimated.next(self)
: This method is called by
backtrader
for each new bar of data:
lookback
period.recent_log_prices
for the
lookback
window.estimate_ou_parameters
to get the OU
parameters.z_score
of the current log price
relative to the estimated mean and equilibrium standard deviation.self.buy()
and self.sell()
are
backtrader
functions to place market orders.run_ou_strategy
Function:ticker
,
start_date
, end_date
, cash
, and
strategy parameters as input.yfinance.download
to fetch historical data for the specified ticker and date range.
auto_adjust=False
and droplevel(1, 1)
are
used as per the user’s saved preference for yfinance
data
handling.cerebro = bt.Cerebro()
creates the main backtesting
engine.cerebro.addstrategy(...)
: Adds an instance of our
OUMeanReversionStrategy
with the specified parameters.cerebro.adddata(...)
: Feeds the downloaded data to the
engine.cerebro.broker.setcash(...)
: Sets the initial
capital.cerebro.broker.setcommission(...)
: Sets a commission
for trades (0.1%).cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
:
This sizer ensures that 95% of the available cash is used for each
trade.cerebro.addanalyzer(...)
: Adds various
backtrader
analyzers to calculate performance metrics
(Returns, Sharpe Ratio, Drawdown, Trade statistics).cerebro.run()
executes the
backtest.cerebro.plot()
generates a
visual representation of the trades and equity curve.The if __name__ == '__main__':
block demonstrates how to
run the strategy:
if __name__ == '__main__':
# Run the strategy
= run_ou_strategy(
cerebro, results ='ETH-USD',
ticker='2020-01-01',
start_date='2024-12-31',
end_date=10000,
cash=30,
lookback=30,
sma_period=1.2,
entry_threshold=0.8
exit_threshold )
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.
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.