Featured
How to Get a 131% Return with Mean Reversion Trading Strategy: From Stock Selection to Backtesting
In this comprehensive guide, we’ll dive into mean-reversion trading strategy using Python. We’ll start by selecting stocks based on specific criteria and then explore the core principles of the strategy. Ultimately, we’ll conduct a backtest to evaluate the performance of our selected stocks when the mean-reversion strategy is applied.
Mean Reversion Trading
In general, trading strategies can be broadly categorized into two types: trend-following and mean-reversion. While trend-following strategies aim to exploit the persistence of a price movement, mean-reversion strategies, on the other hand, bet on the price returning to an average or mean level after a deviation.
Mean reversion is a trading strategy grounded in the belief that asset prices and historical returns eventually revert to their long-term mean or average level. Essentially, if a stock or asset temporarily deviates significantly from its historical average — whether by underperforming or overperforming — the mean reversion trader anticipates that it will soon return to its “normal” state.
In essence, mean reversion capitalizes on price fluctuations and assumes that a stock’s high and low prices are temporary and that its price will, over time, revert to its average.
The Code: Step-by-Step Guide
This guide will be divided into two primary sections: The first section focuses on stock selection, while the second section will be strategy validation through backtesting the chosen stocks using a mean-reversion strategy.
Stock Selection
Choosing the right stocks is crucial. One of the most effective technique to identify mean-reverting stocks is the Augmented Dickey-Fuller (ADF) test, which checks for the stationarity of a time series.
By assessing the stationarity of a time series data, the ADF test aids in determining whether a stock’s price movements tend to revert to its historical mean over time. A statistically significant result from the ADF test suggests that the stock’s price is likely to return to its average value after deviating, indicating a potential mean reversion pattern.
Let’s start with the code. First, let’s import all the necessary libraries.
# Import necessary libraries
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
from backtesting import Backtest, Strategy
from backtesting.lib import crossover
from statsmodels.tsa.stattools import adfuller
We will be using yfinance
to retrieve the data from Yahoo! Finance and from statsmodels.tsa.stattools import adfuller
to calculate the Augmented Dickey-Fuller (ADF) test for our universe of stocks.
# Create a list of US stocks
stock_symbols = [
'AAPL', 'MSFT', 'GOOGL', 'AMZN', 'TSLA', 'BRK-A', 'NVDA',
'JPM', 'JNJ', 'V', 'PG', 'UNH', 'MA', 'DIS', 'HD', 'BAC', 'VZ',
'INTC', 'KO', 'PFE', 'WMT', 'MRK', 'PEP', 'T', 'BA', 'XOM', 'ABBV',
'NKE', 'MCD', 'CSCO', 'DOW', 'ADBE', 'IBM', 'CVX', 'CRM', 'ABT', 'MDT',
'PYPL', 'NEE', 'COST', 'AMGN', 'CMCSA', 'NFLX', 'ORCL', 'PM', 'HON', 'ACN',
'TMO', 'AVGO'
]
# Fetch the data from Yahoo Finance
df = {}
for symbol in stock_symbols:
data = yf.download(symbol, start='2015-01-01', end='2023-01-01')
df[symbol] = data['Close']
After importing all of the necessary libraries, we need to create a list of stocks that we would like to scan and store it to list stock_symbols
. You are free to include as many stocks as you wish, but for this case study, I will only include around 47 stocks to ensure faster data retrieval and processing.
Next, we are going to use the yfinance
library to retrieve the historcial price of our selected stocks. For this case, I will retrieve the historical data from 2015–01–01
to 2023–01–01
.
If you would like to inspect the historical data, you can use print(df)
. Meanwhile, to print the closing stock prices individually for a specific stock (e.g., ‘AAPL’): print(df[‘AAPL’].Close)
.
Moving on, let’s code the ADF calculation:
stationary_stocks = []
p_values = []
for symbol, data in df.items():
result = adfuller(data['Close'])
# A p-value less than 0.05 indicates that the data is stationary
p_value = result[1]
if p_value <= 0.05:
stationary_stocks.append(symbol)
p_values.append(p_value)
print("Stocks suitable for mean reversion strategy:")
for stock, p_value in zip(stationary_stocks, p_values): # Use zip to iterate over both lists simultaneously
print(f"Stock: {stock}, p-value: {p_value:.4f}")
The provided code evaluates stocks for potential mean reversion strategies using the Augmented Dickey-Fuller (ADF) test. Like previously mentioned, the ADF test assesses whether a stock’s historical closing prices exhibit stationarity, a key characteristic for mean reversion strategies.
For this case, the ADF test’s p-value (probability) is checked against the significance level of 0.05. A p-value below this threshold suggests that the data is stationary, indicating that the stock prices tend to revert to their mean over time.
The code compiles a list of stocks with p-values less than or equal to 0.05 in list stationary_stocks
, identifying those that are potentially suitable candidates for mean reversion strategies due to their demonstrated tendency to revert to their historical averages.
Running above code will result in the following output:
As it turns out, from our universe of stocks. IBM is the only stock which its daily closing price exhibit mean-reverting properties with a p-value of 0.0120.
We can also visualize the stock to assess its mean-reverting tendencies using matplotlib.pylot
with the following code:
def plot_stationary_stocks(df, stationary_stocks):
for stock in stationary_stocks:
data = df[stock].Close
# Calculate rolling statistics
rolling_mean = data.rolling(window=30).mean() # 30-day rolling mean
rolling_std = data.rolling(window=30).std() # 30-day rolling standard deviation
# Plot the statistics
plt.figure(figsize=(12, 6))
plt.plot(data, label=f'{stock} Prices', color='blue')
plt.plot(rolling_mean, label='Rolling Mean', color='red')
plt.plot(rolling_std, label='Rolling Std. Dev.', color='black')
plt.title(f'Stationarity Check for {stock}')
plt.xlabel('Date')
plt.ylabel('Prices')
plt.legend()
plt.grid(True)
plt.show()
# Calling the function
plot_stationary_stocks(df, stationary_stocks)
Strategy Formulation and Backtesting
Let’s move on to trying out a mean-reversion strategy on our selected stock (IBM).
To accomplish this, we’ll be harnessing the capabilities of the backtesting.py library — a user-friendly and powerful toolkit for assessing trading strategies within the Python ecosystem. This library shines particularly brightly when it comes to validating straightforward strategies, making it an ideal choice for such scenarios. For more information about backtesting.py, you can visit its official page: Backtesting.py — Backtest trading strategies in Python (kernc.github.io).
Strategy Formulation
class MeanReversion(Strategy):
n1 = 30 # Period for the moving average
def init(self):
# Compute moving average
self.offset = 0.01 # Buy/sell when price is 1% below/above the moving average
prices = self.data['Close']
self.ma = self.I(self.compute_rolling_mean, prices, self.n1)
def compute_rolling_mean(self, prices, window):
return [(sum(prices[max(0, i - window):i]) / min(i, window)) if i > 0 else np.nan for i in range(len(prices))]
def next(self):
size = 0.1
# If price drops to more than offset% below n1-day moving average, buy
if self.data['Close'] < self.ma[-1] * (1 - self.offset):
if self.position.size < 0: # Check for existing short position
self.buy() # Close short position
self.buy(size=size)
# If price rises to more than offset% above n1-day moving average, sell
elif self.data['Close'] > self.ma[-1] * (1 + self.offset):
if self.position.size > 0: # Check for existing long position
self.sell() # Close long position
self.sell(size=size)
Now this might be a little too technical, I’ll try my best to explain it thoroughly.
The strategy’s objective is to capitalize on price movements that deviate significantly from a specified moving average.
Here’s an explanation of the key components:
- The
MeanReversion
class is derived from theStrategy
class, inheriting its functionality. This class serves as the foundation for implementing the mean-reversion strategy. - Within the
init
method, the moving average periodn1
is set to 30 days. The strategy uses a simple moving average to assess price trends. - The
offset
parameter is defined as 1%, indicating that buying or selling actions will be executed when the price deviates by 1% below or above the moving average. - The
compute_rolling_mean
method calculates the rolling mean of the closing prices over the specified window. The calculated values are then used to determine the moving average. - The
next
method is the core of the strategy, executed for each new trading data point. The variablesize
is set to 0.1, means that for every trade executed by this strategy, a position will be opened or closed with an amount equal to 10% of the total available capital in the portfolio.
The strategy employs conditional statements to trigger buy and sell actions based on price deviations from the moving average. If the price falls below the moving average by more than the offset percentage, a buy action is triggered. Conversely, if the price rises above the moving average by more than the offset percentage, a sell action is triggered.
The strategy includes additional checks to close existing positions before initiating new ones, ensuring proper handling of long and short positions.
In essence, this mean-reversion strategy leverages the relationship between price and moving average to identify buying and selling opportunities when significant deviations occur.
Running the backtest
stock_to_backtest = stationary_stocks[0]
df = df[stock_to_backtest]
bt = Backtest(df, MeanReversion, cash=100000, commission=.002)
stats = bt.run()
bt.plot()
In the code above, the Backtest
class is employed to set up the backtesting environment. This is done by creating an instance named bt
, where historical price data from the DataFrame df
is combined with the MeanReversion
strategy we formulated earlier. Additional parameters such as initial cash of $100,000 and a commision fee of 0.2% per trade are also specified for the backtesting simulation.
Backtest Result and Intepretation
Let’s observe how the strategy perform through the generated plot using bt.plot()
.
We can also print the stats with the following code
print(stats)
Here’s what the output of what our trading statistic will look like
Start 2014-12-31 00:00:00
End 2022-12-30 00:00:00
Duration 2921 days 00:00:00
Exposure Time [%] 99.851117
Equity Final [$] 231170.852727
Equity Peak [$] 235442.515106
Return [%] 131.170853
Buy & Hold Return [%] -8.145763
Return (Ann.) [%] 11.048887
Volatility (Ann.) [%] 23.417591
Sharpe Ratio 0.47182
Sortino Ratio 0.798583
Calmar Ratio 0.339264
Max. Drawdown [%] -32.567221
Avg. Drawdown [%] -3.396693
Max. Drawdown Duration 645 days 00:00:00
Avg. Drawdown Duration 42 days 00:00:00
# Trades 1613
Win Rate [%] 73.961562
Best Trade [%] 25.858214
Worst Trade [%] -15.540084
Avg. Trade [%] 1.976996
Max. Trade Duration 189 days 00:00:00
Avg. Trade Duration 42 days 00:00:00
Profit Factor 3.146867
Expectancy [%] 2.089253
SQN 7.474048
_strategy MeanReversion
_equity_curve ...
_trades Size Entr...
dtype: object
The strategy produced a positive return of 131.17%, which translates to an annualized return of 11.05%. For comparison, a Buy & Hold strategy over the same period would have resulted in a loss of 8.15%.
Key risk metrics reveal a maximum drawdown of 32.57%, meaning the strategy at its worst point had a drop of 32.57% from its peak value.
The Sharpe Ratio, a measure of risk-adjusted returns, stands at 0.47182. A Sharpe Ratio above 1 is generally considered excellent, so this strategy’s return per unit of risk is moderate. The Calmar Ratio, which compares the annualized return to the maximum drawdown, is 0.339, indicating that the strategy’s return is about a third of its maximum drawdown, suggesting that there might be potential improvements in risk management.
Regarding individual trades, the strategy made a total of 1,613 trades with a high win rate of 73.96%. The Profit Factor, which is the ratio of gross profit to gross loss, is at a favorable level of 3.147, implying that the strategy’s profitable trades were over three times larger than its losing trades.
Conclusion
Throughout this case study, we’ve successfully identified a mean-reverting stock using the ADF test and validated it by applying the strategy through backtesting with backtesting.py
library, resulting in an impressive 131.17% return.
While this approach delivered substantial returns, it’s important to acknowledge that this framework is a basic implementation of mean-reversion trading strategy and would serves as an excellent foundation for developing a more sophisticated and resilient algorithmic trading strategy.
Feel free to share your ideas on enhancing the strategy in the comments section below!