Mean-reversion strategies hinge on the idea that prices will revert to an average level over time. One popular indicator for such a strategy is the Relative Strength Index (RSI). In this article, we start with a basic RSI mean-reversion strategy, then describe how we can improve performance by filtering out trades during trending conditions using the Average Directional Movement Index (ADX) and the Average True Range (ATR). Generally, you should always think of the market conditions that your strategy works well in and then try to reduce false signals by filtering out undesirable markets.
The basic RSI mean-reversion strategy is built around these core ideas:
RSI Indicator:
The RSI is a momentum indicator that measures the speed and change of
price movements. It typically oscillates between 0 and 100.
Entry Signal:
When the RSI falls below an oversold threshold (commonly 30), the
strategy interprets the market as oversold and a potential reversal is
expected, triggering a buy signal.
Exit Signal:
When the RSI exceeds an overbought threshold (commonly 70), the market
is considered overbought. The strategy then initiates a sell, capturing
profits from the expected reversal.
Below is the essential code for the basic RSI mean-reversion strategy written using Backtrader and yfinance libraries:
import backtrader as bt
import yfinance as yf
import datetime
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib qt5
class RsiStrategy(bt.Strategy):
= (
params 'rsi_period', 14),
('rsi_overbought', 70),
('rsi_oversold', 30),
('printlog', True),
(
)
def __init__(self):
# Reference to the close price series
self.dataclose = self.datas[0].close
# For tracking orders
self.order = None
# Add RSI indicator
self.rsi = bt.indicators.RSI(self.datas[0], period=self.params.rsi_period)
def log(self, txt, dt=None, doprint=False):
if self.params.printlog or doprint:
= dt or self.datas[0].datetime.date(0)
dt print(f'{dt.isoformat()} - {txt}')
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return # Do nothing for pending orders
if order.status in [order.Completed]:
if order.isbuy():
self.log(f'BUY EXECUTED, Price: {order.executed.price:.2f}, '
f'Cost: {order.executed.value:.2f}, Comm {order.executed.comm:.2f}')
elif order.issell():
self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, '
f'Cost: {order.executed.value:.2f}, Comm {order.executed.comm:.2f}')
self.bar_executed = len(self)
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log('Order Canceled/Margin/Rejected')
self.order = None
def next(self):
# Skip new orders if an order is pending.
if self.order:
return
# If we are not in the market, check for oversold condition for buy entry.
if not self.position:
if self.rsi < self.params.rsi_oversold:
self.log(f'BUY CREATE, {self.dataclose[0]:.2f}')
self.order = self.buy()
else:
# If already in the market, check for overbought condition for exit.
if self.rsi > self.params.rsi_overbought:
self.log(f'SELL CREATE, {self.dataclose[0]:.2f}')
self.order = self.sell()
if __name__ == '__main__':
= bt.Cerebro()
cerebro =False)
cerebro.addstrategy(RsiStrategy, printlog
= 'BTC-USD'
ticker = datetime.datetime(2020, 1, 1)
start_date = datetime.datetime(2025, 12, 31)
end_date
= yf.download(ticker, start=start_date, end=end_date, progress=False)
data_df if data_df.empty:
raise ValueError("No data fetched. Check ticker symbol or date range.")
# Adjust DataFrame for Backtrader if needed
= data_df.columns.droplevel(1).str.lower()
data_df.columns = pd.to_datetime(data_df.index)
data_df.index = bt.feeds.PandasData(dataname=data_df)
data
cerebro.adddata(data)
100000.0)
cerebro.broker.set_cash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
print(f'Starting Portfolio Value: {cerebro.broker.getvalue():.2f}')
cerebro.run()print(f'Final Portfolio Value: {cerebro.broker.getvalue():.2f}')
"figure.figsize"] = (10, 6)
plt.rcParams[= cerebro.plot(style='candlestick', barup='green', bardown='red', iplot=False, show=False)[0][0]
figure
plt.tight_layout() figure.show()
### Performance of the Base
Strategy
Let’s take a look at backtest performance for bitcoin from 2020 onward:
Starting Portfolio Value: 100000.00
Final Portfolio Value: 147939.08
Sharpe Ratio: 0.020
Total Return: 39.16%
Annualized Return: 5.25%
Max Drawdown: 62.21%
Total Trades: 8
Winning Trades: 5
Losing Trades: 2
Win Rate: 62.50%
Avg Winning Trade: 26152.38
Avg Losing Trade: -42397.02
Profit Factor: 1.5421118842358195
These numbers indicate moderate growth, though the high drawdown signals the strategy’s vulnerability during trending markets where mean reversion assumptions fail.
Mean reversion strategies work best in range-bound markets. However, during trending phases, prices often continue in one direction rather than reverting, which leads to false RSI signals. To address this, we can use:
ADX (Average Directional Movement Index):
Measures the strength of a trend. A high ADX (above a set threshold)
indicates a trending market.
ATR (Average True Range):
Gauges market volatility. A high ATR-to-price ratio indicates
significant volatility.
For a mean-reversion strategy, the logic now becomes:
Skip trades if both ADX and ATR confirm that the market is trending and volatile.
Take trades only when the market is range-bound—i.e., when the ADX is below the threshold or the ATR/close ratio is low.
The following code integrates ADX and ATR filters to prevent entry or exit signals during trending conditions:
import backtrader as bt
import yfinance as yf
import datetime
import pandas as pd
import matplotlib.pyplot as plt
%matplotlib qt5
class RsiStrategyWithTrendFilters(bt.Strategy):
= (
params 'rsi_period', 14),
('rsi_overbought', 70),
('rsi_oversold', 30),
('adx_period', 14),
('adx_threshold', 25), # A high ADX indicates trending conditions.
('atr_period', 14),
('atr_ratio_threshold', 0.02), # ATR-to-Price ratio threshold.
('printlog', True),
(
)
def __init__(self):
self.dataclose = self.datas[0].close
self.order = None
self.rsi = bt.indicators.RSI(self.datas[0], period=self.params.rsi_period)
self.adx = bt.indicators.AverageDirectionalMovementIndex(self.datas[0], period=self.params.adx_period)
self.atr = bt.indicators.ATR(self.datas[0], period=self.params.atr_period)
def log(self, txt, dt=None, doprint=False):
if self.params.printlog or doprint:
= dt or self.datas[0].datetime.date(0)
dt print(f'{dt.isoformat()} - {txt}')
def notify_order(self, order):
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:.2f}, '
f'Cost: {order.executed.value:.2f}, Comm {order.executed.comm:.2f}')
elif order.issell():
self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, '
f'Cost: {order.executed.value:.2f}, Comm {order.executed.comm:.2f}')
self.bar_executed = len(self)
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log('Order Canceled/Margin/Rejected')
self.order = None
def next(self):
if self.order:
return
# Calculate the ATR-to-Price ratio for the current bar
= self.atr[0] / self.dataclose[0]
atr_ratio
# Determine trending conditions:
= self.adx[0] >= self.params.adx_threshold
trend_confirmed = atr_ratio >= self.params.atr_ratio_threshold
volatile_enough
# For a mean-reversion strategy, skip trading in trending markets.
if trend_confirmed and volatile_enough:
return
# If we're not in the market, consider buying when RSI is oversold.
if not self.position:
if self.rsi < self.params.rsi_oversold:
self.log(f'BUY CREATE at {self.dataclose[0]:.2f} | RSI: {self.rsi[0]:.2f}')
self.order = self.buy()
else:
# If in the market, consider selling when RSI is overbought.
if self.rsi > self.params.rsi_overbought:
self.log(f'SELL CREATE at {self.dataclose[0]:.2f} | RSI: {self.rsi[0]:.2f}')
self.order = self.sell()
if __name__ == '__main__':
= bt.Cerebro()
cerebro =False)
cerebro.addstrategy(RsiStrategyWithTrendFilters, printlog
= 'BTC-USD'
ticker = datetime.datetime(2020, 1, 1)
start_date = datetime.datetime(2025, 12, 31)
end_date = yf.download(ticker, start=start_date, end=end_date, progress=False)
data_df if data_df.empty:
raise ValueError("No data fetched. Check ticker symbol or date range.")
= data_df.columns.droplevel(1).str.lower()
data_df.columns = pd.to_datetime(data_df.index)
data_df.index = bt.feeds.PandasData(dataname=data_df)
data
cerebro.adddata(data)
100000.0)
cerebro.broker.set_cash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
print(f'Starting Portfolio Value: {cerebro.broker.getvalue():.2f}')
cerebro.run()print(f'Final Portfolio Value: {cerebro.broker.getvalue():.2f}')
"figure.figsize"] = (10, 6)
plt.rcParams[= cerebro.plot(style='candlestick', barup='green', bardown='red', iplot=False, show=False)[0][0]
figure
plt.tight_layout() figure.show()
### Improvements Observed
Now let’s see the performance of the enhanced strategy:
Starting Portfolio Value: 100000.00
Final Portfolio Value: 231071.06
Sharpe Ratio: 0.038
Total Return: 83.76%
Annualized Return: 11.56%
Max Drawdown: 33.76%
Total Trades: 4
Winning Trades: 3
Losing Trades: 1
Win Rate: 75.00%
Avg Winning Trade: 49901.91
Avg Losing Trade: -18634.66
Profit Factor: 8.03372435292996
The improved strategy increases returns and lowers risk by filtering out trades during trending and volatile conditions. It takes fewer trades, but they are more selective and in environments where mean reversion is more likely to be successful.
By starting with a basic RSI mean-reversion strategy and then improving it with ADX and ATR filters, we’ve demonstrated how to better adapt the strategy to market conditions. This approach helps avoid false signals in trending markets, leading to higher returns and lower drawdown. Such enhancements underscore the importance of tailoring your trading logic to the prevailing market environment for more robust strategy performance.
Whether you’re refining an existing system or building a new one, understanding the interplay between indicators, market regimes, and risk management is key to optimizing trading strategies. Happy trading!