Building a Mean Reversion
Trading System with Python.
The Mean Reversion System is a popular trading strategy
that relies on the statistical concept of mean reversion.
This principle suggests that prices and returns eventually
return to their historical average or mean.
Traders employing this strategy look for opportunities to
profit from price deviations from the mean, expecting the
price to revert to its average over time. To summarize:
Mean reversion is a financial theory that assumes
asset prices fluctuate around a stable long-term
average.
This type of trading system is generally well-suited for
sideways markets, where prices do not follow a specific
direction but instead fluctuate above and below the
average over a certain period.
Therefore, I will design and develop a mean reversion
system to maximize profits in sideways markets while
minimizing the drawdown during bearish markets. The
indicators best suited for this purpose are Bollinger
Bands and RSI.
From the candlestick chart, we will use observation to
define the entry and exit conditions as follows:
Entry Conditions:
The closing price is below the lower Bollinger Band
The RSI is below 30 (indicating oversold conditions)
Exit Conditions:
The closing price rises above the Bollinger Band’s
mean
From now on, it will be a step-by-step to building a trading
system and performing optimization with Python.
1. Imports
import numpy as np
import pandas as pd
import yfinance as yf
import vectorbt as vbt
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
numpy and pandas: For numerical and data
manipulation tasks.
yfinance: To download historical stock data.
vectorbt: To perform backtesting and analyze
portfolios.
matplotlib.pyplot and mpl_toolkits.mplot3d: For
plotting 3D graphs.
2. Functions for Technical Indicators
# Function to calculate Bollinger Bands
def calculate_bollinger_bands(df, window=20, std_dev=2):
"""Calculate Bollinger Bands."""
df['Mean'] = df['Close'].rolling(window=window).mean()
df['Std'] = df['Close'].rolling(window=window).std()
df['Upper_Band'] = df['Mean'] + (std_dev * df['Std'])
df['Lower_Band'] = df['Mean'] - (std_dev * df['Std'])
return df
calculate_bollinger_bands: This function calculates the
Bollinger Bands, which consist of 3 components:
Mean (the moving average of the closing prices),
Upper Band (Mean + standard deviation * a
multiplier),
Lower Band (Mean — standard deviation * a
multiplier).
It uses a rolling window (default of 20 days) and a
standard deviation multiplier (default of 2) to
calculate these bands.
# Function to calculate RSI
def calculate_rsi(df, period=14):
"""Calculate Relative Strength Index (RSI)."""
delta = df['Close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
df['RSI'] = 100 - (100 / (1 + rs))
return df
calculate_rsi: This function calculates the Relative
Strength Index (RSI), which is a momentum
oscillator that measures the speed and change of
price movements. It is computed using the average
gains and losses over a specified period (default of 14
days).
3. Data Download
# Define the stock symbol and time period
symbol = 'INTC'
start_date = '2014-01-01'
end_date = '2024-12-27'
# Download the data
df = yf.download(symbol, start=start_date, end=end_date)
df.columns = ['Adj Close', 'Close', 'High', 'Low', 'Open', 'Volume']
df.ffill(inplace=True)
symbol = 'SI=F': Specifies the stock symbol for Silver
Futures (SI=F).
start_date = '2014-01-01' and end_date = '2024-12-27':
Defines the time range for the stock data.
df = yf.download(symbol, start=start_date, end=end_date):
Downloads the historical data for Silver Futures from
Yahoo Finance.
df.columns = ['Adj Close', 'Close', 'High', 'Low', 'Open',
'Volume']: Renames the columns of the downloaded
DataFrame to standard names.
df.ffill(inplace=True): Forward-fills any missing data (in
case there are gaps in the data).
4. Parameter Combinations for Optimization
# Initialize arrays to store results
results = []
window_range = range(1, 50) # Example range for window
std_dev_range = np.arange(1, 4, 0.5) # Example range for std_dev
period_range = range(1, 30) # Example range for period
The script then sets up parameter ranges for the
Bollinger Bands and RSI:
window_range = range(1, 50): Range of window sizes for
the moving average.
std_dev_range = np.arange(1, 4, 0.5): Range of standard
deviation multipliers.
period_range = range(1, 30): Range of periods for
calculating the RSI.
5. Loop for Backtesting and Optimization
# Loop through all combinations of window, std_dev, and period
for window in window_range:
for std_dev in std_dev_range:
for period in period_range:
# Calculate Bollinger Bands and RSI for current parameter set
temp_df = df.copy()
temp_df = calculate_bollinger_bands(temp_df, window=window,
std_dev=std_dev)
temp_df = calculate_rsi(temp_df, period=period)
# Define entry and exit signals
temp_df['Buy_Signal'] = (temp_df['Close'] <
temp_df['Lower_Band']) & (temp_df['RSI'] < 30)
temp_df['Sell_Signal'] = (temp_df['Close'] >
temp_df['Upper_Band']) & (temp_df['RSI'] > 70)
temp_df['Exit_Buy'] = (temp_df['Close'] > temp_df['Mean'])
temp_df['Exit_Sell'] = (temp_df['Close'] < temp_df['Mean'])
# Convert signals to boolean arrays
entries = temp_df['Buy_Signal'].to_numpy()
exits = temp_df['Exit_Buy'].to_numpy()
# Backtest using vectorbt
portfolio = vbt.Portfolio.from_signals(
close=temp_df['Close'],
entries=entries,
exits=exits,
init_cash=100_000,
fees=0.001
)
# Store results (final portfolio value, can also include
Sharpe ratio or other metrics)
results.append((window, std_dev, period,
portfolio.stats().loc['Total Return [%]']))
# Convert results to DataFrame for easy plotting
results_df = pd.DataFrame(results, columns=['Window', 'Std_Dev', 'Period',
'Total Return [%]'])
For each combination of parameters (window, std_dev,
and period), the following steps are performed:
1. Bollinger Bands and RSI Calculation: For each
combination, the Bollinger Bands and RSI are calculated
using the respective functions.
2. Define Entry and Exit Signals:
Buy Signal: The code generates a signal to buy when
the closing price is below the lower Bollinger
Band and the RSI is below 30 (indicating that the
asset is oversold).
Exit Buy: A sell signal is triggered when the closing
price crosses above the moving average (the
“Mean”).
3. Backtesting with vectorbt:
A portfolio is created using the from_signals method
from vectorbt, which simulates the strategy based on
the entry and exit signals, an initial cash amount of
100,000, and a fee of 0.1%.
4. Store Results: The results (final total return) are
stored in an array results for later analysis.
6. Data Preparation for 3D Plotting
# Extract data for 3D plotting
x = results_df['Window']
y = results_df['Std_Dev']
z = results_df['Period']
value = results_df['Total Return [%]']
# Create a 3D plot of the optimization results
fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection='3d')
ax.scatter(x, y, z, c=value, cmap='viridis', marker='o')
ax.set_xlabel('Window')
ax.set_ylabel('Std Dev')
ax.set_zlabel('Period')
ax.set_title('Optimization of Bollinger Bands and RSI Parameters')
plt.show()
Results DataFrame: After the loop, the results are
stored in a DataFrame (results_df) that includes
the window, std_dev, period, and the Total Return [%] for
each combination.
3D Plot: The code is then used matplotlib to create a
3D scatter plot of the results:
x-axis: Window size for the Bollinger Bands.
y-axis: Standard deviation multiplier for the Bollinger
Bands.
z-axis: Period for the RSI.
Color: The color of the points represents the total
return percentage.
7. Identify the Best Parameters
# Find the best parameter combination (highest final portfolio value)
best_params = results_df.loc[results_df['Total Return [%]'].idxmax()]
# Print the best parameters and corresponding final portfolio value
print(f"Best Parameters:")
print(f"Window: {best_params['Window']}")
print(f"Std Dev: {best_params['Std_Dev']}")
print(f"Period: {best_params['Period']}")
print(f"Final Portfolio Value: {best_params['Total Return [%]']}")
Best Parameter Combination: The code identifies
the combination of window, std_dev, and period that
results in the highest total return by finding the row
with the maximum value in the Total Return
[%] column.
Print the Best Parameters: The best parameters
and the corresponding final portfolio value are
printed to the console.
Full Code Here:
import numpy as np
import pandas as pd
import yfinance as yf
import vectorbt as vbt
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
# Function to calculate Bollinger Bands
def calculate_bollinger_bands(df, window=20, std_dev=2):
"""Calculate Bollinger Bands."""
df['Mean'] = df['Close'].rolling(window=window).mean()
df['Std'] = df['Close'].rolling(window=window).std()
df['Upper_Band'] = df['Mean'] + (std_dev * df['Std'])
df['Lower_Band'] = df['Mean'] - (std_dev * df['Std'])
return df
# Function to calculate RSI
def calculate_rsi(df, period=14):
"""Calculate Relative Strength Index (RSI)."""
delta = df['Close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
df['RSI'] = 100 - (100 / (1 + rs))
return df
# Define the symbol and time period
symbol = 'SI=F'
start_date = '2014-01-01'
end_date = '2024-12-27'
# Download the data
df = yf.download(symbol, start=start_date, end=end_date)
df.columns = ['Adj Close', 'Close', 'High', 'Low', 'Open', 'Volume']
df.ffill(inplace=True)
# Initialize arrays to store results
results = []
window_range = range(1, 50) # Example range for window
std_dev_range = np.arange(1, 4, 0.5) # Example range for std_dev
period_range = range(1, 30) # Example range for period
# Loop through all combinations of window, std_dev, and period
for window in window_range:
for std_dev in std_dev_range:
for period in period_range:
# Calculate Bollinger Bands and RSI for current parameter set
temp_df = df.copy()
temp_df = calculate_bollinger_bands(temp_df, window=window,
std_dev=std_dev)
temp_df = calculate_rsi(temp_df, period=period)
# Define entry and exit signals
temp_df['Buy_Signal'] = (temp_df['Close'] <
temp_df['Lower_Band']) & (temp_df['RSI'] < 30)
temp_df['Exit_Buy'] = (temp_df['Close'] > temp_df['Mean'])
# Convert signals to boolean arrays
entries = temp_df['Buy_Signal'].to_numpy()
exits = temp_df['Exit_Buy'].to_numpy()
# Backtest using vectorbt
portfolio = vbt.Portfolio.from_signals(
close=temp_df['Close'],
entries=entries,
exits=exits,
init_cash=100_000,
fees=0.001
)
# Store results (final portfolio value, can also include
Sharpe ratio or other metrics)
results.append((window, std_dev, period,
portfolio.stats().loc['Total Return [%]']))
# Convert results to DataFrame for easy plotting
results_df = pd.DataFrame(results, columns=['Window', 'Std_Dev', 'Period',
'Total Return [%]'])
# Extract data for 3D plotting
x = results_df['Window']
y = results_df['Std_Dev']
z = results_df['Period']
value = results_df['Total Return [%]']
# Create a 3D plot of the optimization results
fig = plt.figure(figsize=(12, 8))
ax = fig.add_subplot(111, projection='3d')
ax.scatter(x, y, z, c=value, cmap='viridis', marker='o')
ax.set_xlabel('Window')
ax.set_ylabel('Std Dev')
ax.set_zlabel('Period')
ax.set_title('Optimization of Bollinger Bands and RSI Parameters')
plt.show()
# Find the best parameter combination (highest final portfolio value)
best_params = results_df.loc[results_df['Total Return [%]'].idxmax()]
# Print the best parameters and corresponding final portfolio value
print(f"Best Parameters:")
print(f"Window: {best_params['Window']}")
print(f"Std Dev: {best_params['Std_Dev']}")
print(f"Period: {best_params['Period']}")
print(f"Final Portfolio Value: {best_params['Total Return [%]']}")
After running the Python code, the result will be as
follows:
Best Parameters:
Window: 41.0
Std Dev: 1.0
Period: 21.0
Final Portfolio Value: 154.8820997183831
Now, we will take this best parameter and backtest it
again to see the various statistics.
import numpy as np
import pandas as pd
import yfinance as yf
import vectorbt as vbt
import matplotlib.pyplot as plt
# Function to calculate Bollinger Bands
def calculate_bollinger_bands(df, window=20, std_dev=2):
"""Calculate Bollinger Bands."""
df['Mean'] = df['Close'].rolling(window=window).mean()
df['Std'] = df['Close'].rolling(window=window).std()
df['Upper_Band'] = df['Mean'] + (std_dev * df['Std'])
df['Lower_Band'] = df['Mean'] - (std_dev * df['Std'])
return df
# Function to calculate RSI
def calculate_rsi(df, period=14):
"""Calculate Relative Strength Index (RSI)."""
delta = df['Close'].diff()
gain = (delta.where(delta > 0, 0)).rolling(window=period).mean()
loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean()
rs = gain / loss
df['RSI'] = 100 - (100 / (1 + rs))
return df
# Define the symbol and time period
symbol = 'SI=F'
start_date = '2014-01-01'
end_date = '2024-12-27'
# Download the data
df = yf.download(symbol, start=start_date, end=end_date)
df.columns = ['Adj Close', 'Close', 'High', 'Low', 'Open', 'Volume']
df.ffill(inplace=True)
# Calculate Bollinger Bands and RSI
df = calculate_bollinger_bands(df, window=41, std_dev=1)
df = calculate_rsi(df, period=21)
# Define entry and exit signals
df['Buy_Signal'] = (df['Close'] < df['Lower_Band']) & (df['RSI'] < 30)
df['Exit_Buy'] = (df['Close'] > df['Mean'])
# Convert signals to boolean arrays
entries = df['Buy_Signal'].to_numpy()
exits = df['Exit_Buy'].to_numpy()
# Backtest using vectorbt
portfolio = vbt.Portfolio.from_signals(
close=df['Close'],
entries=entries,
exits=exits,
init_cash=100_000,
fees=0.001
)
# Display performance metrics
print(portfolio.stats())
# Plot equity curve
portfolio.plot().show()
This Python code has the same indicator functions, data
download, and trading conditions as before. The only
addition is the backtest using the best parameters. The
results are as follows:
Performance Metrics
Performance Visualization
From testing the trading system with silver for almost 11
years, it was observed that although the system had
periods of negative returns, these were due to the drop in
silver prices.
Even though the prices fell, the trading system was still
able to perform better than buy and hold from that point
onward. After 2016, the system started making profits and
has continued to do so up to the present, clearly
outperforming the market (the green line beats the gray
line).
After this, I will test the same trading conditions with other
assets to identify the strengths and weaknesses of this
system to understand it as much as possible.
Backtest and Optimize with Intel Stock (INTC)
# Define the stock symbol and time period
symbol = 'INTC'
start_date = '2014-01-01'
end_date = '2024-12-27'
Best Parameters:
Window: 48.0
Std Dev: 1.0
Period: 29.0
Final Portfolio Value: 140.6236377023705
Performance Metrics
Performance Visualization
From the backtest with Intel stock, we can see the
weaknesses of the system. In the early stages, when the
stock was in an uptrend, the system hardly generated any
entry conditions because it didn’t meet the criteria.
However, once the system started going sideways in 2020
and eventually turned bearish, it was able to generate
profits and outperform the market over nearly 11 years.
Backtest and Optimize with Gold (GC=F)
# Define the Gold symbol and time period
symbol = 'GC=F'
start_date = '2014-01-01'
end_date = '2024-12-27'
Best Parameters:
Window: 37.0
Std Dev: 1.5
Period: 15.0
Final Portfolio Value: 96.16868596766638
Performance Metrics
Performance Visualization
From the backtest with gold, it can be seen that the system
generates more consistent profits compared to buy and
hold but buy and hold yields a higher profit over the 11
years.
The trading system and buy-and-hold alternate in
profitability, with the system performing better during
periods when gold enters consolidation or retracement
phases, as observed from 2014 to 2019.
However, when gold entered a bullish phase from 2020 to
2021, the system could not generate profits comparable to
the market’s uptrend. But once it entered a consolidation
phase again, the system outperformed buy and hold.
From the experiment with the mean reversion system
across all 3 markets, the conclusion is that this
system performs well in sideways and bearish
markets, as it can filter out losses.
Therefore, if we want to apply this system, it is essential to
understand the overall market fundamentals and predict
when the market is in an uptrend to switch to a buy-and-
hold strategy.
Conversely, when the market enters a consolidation phase,
we should switch back to using this trading system. This
approach will make trading more effective. Understanding
the overall market fundamentals is not an impossible task,
and you can further study it through the article below:
M2 as a Predictor for Market Trends
Building an Investment Strategy Around Money Supply (M2)
wire.insiderfinance.io
If you read this article to the end, you will notice that
almost every image I included has a watermark with
my own logo. I want to explain that this is necessary
because someone copied my article and posted it on
their own website under their name, without giving
credit. To prevent anyone from copying my work, I
had to add these watermarks. I truly apologize for
this, as I originally wrote the entire article as a
personal study record. However, I felt the need to
add these watermarks in order to maintain my
dignity. I apologize again and thank you for your
understanding.
The purpose of this article is solely educational. It is not
intended as investment advice or recommendations.
Readers are encouraged to use their own judgment and
test the system independently before making any
decisions.