The Relative Strength Index (RSI) is one of the most popular technical indicators, beloved by traders for its apparent simplicity in identifying potentially overbought or oversold conditions. Coupled with the intuitive concept of mean reversion – the idea that prices tend to return to their average over time – it forms the basis of many automated trading strategies. Buy when the RSI dips below 30 (oversold), sell when it climbs above 70 (overbought). Simple, right?
Maybe too simple. As a recent backtesting experiment dramatically illustrates, a basic RSI mean reversion strategy, while appealing on the surface, can lead to disastrous results when faced with real-world market dynamics, particularly strong trends. However, by adding a layer of intelligence – a trend filter – the exact same core idea was transformed from a catastrophic failure into a potentially viable strategy.
The Basic Strategy: Simple Logic, Flawed Premise?
The initial strategy was straightforward:
Go Long: Buy when the 14-period RSI crossed below the 30 level (indicating oversold conditions).
Go Short: Sell short when the 14-period RSI crossed above the 70 level (indicating overbought conditions).
Exit Long: Sell the long position if the RSI crossed back above 50 (neutral) or 70 (overbought).
Exit Short: Cover the short position if the RSI crossed back below 50 (neutral) or 30 (oversold).
The logic seems sound for a market oscillating back and forth. Buy the dips, sell the rips.
Here’s how this basic strategy (including short selling) might be implemented using the backtrader Python library:
Python
import backtrader as bt
class RsiMeanReversion(bt.Strategy):
"""
Implements an RSI Mean Reversion strategy WITH SHORT SELLING.
Buys when RSI goes below oversold level.
Sells (shorts) when RSI goes above overbought level.
Exits long when RSI goes above overbought or neutral level.
Exits short (covers) when RSI goes below neutral or oversold level.
"""
params = (
('rsi_period', 14), # Period for the RSI calculation
('oversold', 30), # RSI level considered oversold (for buying)
('overbought', 70), # RSI level considered overbought (for shorting)
('neutral', 50), # RSI level considered neutral for exit
('printlog', False), # Enable/disable logging (set False for cleaner article)
)
def __init__(self):
self.rsi = bt.indicators.RelativeStrengthIndex(period=self.params.rsi_period)
self.order = None
self.dataclose = self.datas[0].close
def log(self, txt, dt=None, doprint=False):
if self.params.printlog or doprint:
dt = dt or self.datas[0].datetime.date(0)
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}, Size: {order.executed.size:.4f}', doprint=True)
elif order.issell():
self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, Size: {order.executed.size:.4f}', doprint=True)
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f'Order Canceled/Margin/Rejected: Status {order.getstatusname()}', doprint=True)
self.order = None
def notify_trade(self, trade):
if not trade.isclosed:
return
self.log(f'OPERATION PROFIT, GROSS {trade.pnl:.2f}, NET {trade.pnlcomm:.2f}', doprint=True)
def next(self):
if self.order:
return
current_position_size = self.position.size
# --- Logic when FLAT (No position) ---
if current_position_size == 0:
if self.rsi < self.params.oversold:
self.log(f'BUY CREATE (Long Entry), RSI={self.rsi[0]:.2f}', doprint=True)
self.order = self.buy()
elif self.rsi > self.params.overbought:
self.log(f'SELL CREATE (Short Entry), RSI={self.rsi[0]:.2f}', doprint=True)
self.order = self.sell()
# --- Logic when LONG (Position > 0) ---
elif current_position_size > 0:
if self.rsi > self.params.overbought or self.rsi > self.params.neutral:
exit_condition = "Overbought" if self.rsi > self.params.overbought else "Neutral Reversion"
self.log(f'SELL CREATE (Long Exit - {exit_condition}), RSI={self.rsi[0]:.2f}', doprint=True)
self.order = self.sell()
# --- Logic when SHORT (Position < 0) ---
elif current_position_size < 0:
if self.rsi < self.params.neutral or self.rsi < self.params.oversold:
exit_condition = "Oversold" if self.rsi < self.params.oversold else "Neutral Reversion"
self.log(f'BUY CREATE (Short Cover - {exit_condition}), RSI={self.rsi[0]:.2f}', doprint=True)
self.order = self.buy()
The Harsh Reality: Unfiltered Backtest Results
When this RsiMeanReversion strategy is backtested on historical data (3 years of BTC-USD daily data from 2020 to 2022), the results are devastating. Instead of generating profits, it produces:
Total Return: -60% (A significant loss of capital)
Maximum Drawdown: 350%
A 60% loss is bad enough, but a 350% drawdown is catastrophic. Drawdown measures the largest peak-to-trough decline in account equity. A drawdown exceeding 100% implies the use of leverage and indicates that at its worst point, the strategy lost not only all its initial capital but also a significant amount of borrowed funds, leading to margin calls and account wipeout.
Why does it fail so spectacularly? Because markets don’t always revert to the mean promptly. They often trend, sometimes strongly. The basic RSI strategy blindly bought dips in powerful downtrends (catching falling knives) and shorted peaks in roaring uptrends (standing in front of a freight train). Each signal fought the prevailing momentum, leading to accumulating losses and crippling drawdowns.
The Enhancement: Adding Market Context with Trend Filters
Recognizing that fighting the trend was the strategy’s Achilles’ heel, the next step is to introduce filters to gauge the market’s underlying direction and strength. The goal is to adapt the strategy: trade with the trend when it’s strong, and revert to mean reversion only when the market is directionless or ranging.
The following filters can be used:
Trend Direction: A 200-period Exponential Moving Average (EMA). Price trading above the EMA suggests an uptrend; below suggests a downtrend.
Trend Strength: The 14-period Average Directional Index (ADX). An ADX value above 25 is used to indicate a strong trend (either up or down).
The core RSI logic was then modified based on these filters:
If Strong Uptrend (Price > 200 EMA and ADX > 25): ONLY take LONG signals (RSI < 30). Ignore short signals (RSI > 70).
If Strong Downtrend (Price < 200 EMA and ADX > 25): ONLY take SHORT signals (RSI > 70). Ignore long signals (RSI < 30).
If Weak/Ranging Trend (ADX <= 25): Allow BOTH long (RSI < 30) and short (RSI > 70) signals, reverting to the original mean-reversion logic.
The exit rules remain the same mean-reversion style.
This enhanced logic is implemented in a new backtrader class:
Python
import backtrader as bt
class RsiMeanReversionTrendFiltered(bt.Strategy):
"""
Implements an RSI Mean Reversion strategy filtered by trend strength (ADX)
and direction (EMA).
- Strong Trend (ADX > threshold):
- Uptrend (Close > EMA): Only take LONG entries on RSI oversold.
- Downtrend (Close < EMA): Only take SHORT entries on RSI overbought.
- Weak/Ranging Trend (ADX <= threshold):
- Take BOTH LONG (RSI oversold) and SHORT (RSI overbought) entries.
- Exits remain mean-reversion based (RSI crossing neutral/opposite threshold).
"""
params = (
('rsi_period', 14), # Period for the RSI calculation
('oversold', 30), # RSI level considered oversold (for buying)
('overbought', 70), # RSI level considered overbought (for shorting)
('neutral', 50), # RSI level considered neutral for exit
('ema_period', 200), # Period for the EMA trend filter
('adx_period', 14), # Period for ADX calculation
('adx_threshold', 25), # ADX level to distinguish strong trend
('printlog', False), # Enable/disable logging (set False for cleaner article)
)
def __init__(self):
# Indicators
self.rsi = bt.indicators.RelativeStrengthIndex(period=self.params.rsi_period)
self.ema = bt.indicators.ExponentialMovingAverage(period=self.params.ema_period)
self.adx = bt.indicators.AverageDirectionalMovementIndex(period=self.params.adx_period)
# Order tracking and price references
self.order = None
self.dataclose = self.datas[0].close
def log(self, txt, dt=None, doprint=False):
if self.params.printlog or doprint:
dt = dt or self.datas[0].datetime.date(0)
print(f'{dt.isoformat()} {txt}')
def notify_order(self, order):
# (Keep the notify_order method exactly the same as the previous version)
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}, Size: {order.executed.size:.4f}', doprint=True)
elif order.issell():
self.log(f'SELL EXECUTED, Price: {order.executed.price:.2f}, Size: {order.executed.size:.4f}', doprint=True)
elif order.status in [order.Canceled, order.Margin, order.Rejected]:
self.log(f'Order Canceled/Margin/Rejected: Status {order.getstatusname()}', doprint=True)
self.order = None
def notify_trade(self, trade):
# (Keep the notify_trade method exactly the same as the previous version)
if not trade.isclosed:
return
self.log(f'OPERATION PROFIT, GROSS {trade.pnl:.2f}, NET {trade.pnlcomm:.2f}', doprint=True)
def next(self):
if self.order:
return
current_rsi = self.rsi[0]
# Add checks to ensure indicators have calculated enough values
if len(self.adx.adx) < 1 or len(self.ema.ema) < 1:
return # Wait for indicators to have data
current_adx = self.adx.adx[0]
current_close = self.dataclose[0]
current_ema = self.ema[0]
current_position_size = self.position.size
is_strong_trend = current_adx > self.params.adx_threshold
is_uptrend = current_close > current_ema
trend_status_log = ""
# --- ENTRY LOGIC (only if flat) ---
if current_position_size == 0:
if is_strong_trend:
if is_uptrend:
trend_status_log = f"Strong Uptrend (ADX={current_adx:.2f})"
if current_rsi < self.params.oversold:
self.log(f'{trend_status_log} - BUY CREATE (Long Entry), RSI={current_rsi:.2f}', doprint=True)
self.order = self.buy()
else: # Strong Downtrend
trend_status_log = f"Strong Downtrend (ADX={current_adx:.2f})"
if current_rsi > self.params.overbought:
self.log(f'{trend_status_log} - SELL CREATE (Short Entry), RSI={current_rsi:.2f}', doprint=True)
self.order = self.sell()
else: # Weak/Ranging Trend
trend_status_log = f"Weak/Ranging Trend (ADX={current_adx:.2f})"
if current_rsi < self.params.oversold:
self.log(f'{trend_status_log} - BUY CREATE (Long Entry), RSI={current_rsi:.2f}', doprint=True)
self.order = self.buy()
elif current_rsi > self.params.overbought:
self.log(f'{trend_status_log} - SELL CREATE (Short Entry), RSI={current_rsi:.2f}', doprint=True)
self.order = self.sell()
# --- EXIT LOGIC (Based on RSI, independent of trend filter for exit) ---
else: # Already in a position
if current_position_size > 0: # Exit LONG
if current_rsi > self.params.overbought or current_rsi > self.params.neutral:
exit_condition = "Overbought" if current_rsi > self.params.overbought else "Neutral Reversion"
self.log(f'SELL CREATE (Long Exit - {exit_condition}), RSI={current_rsi:.2f}', doprint=True)
self.order = self.sell()
elif current_position_size < 0: # Exit SHORT
if current_rsi < self.params.neutral or current_rsi < self.params.oversold:
exit_condition = "Oversold" if current_rsi < self.params.oversold else "Neutral Reversion"
self.log(f'BUY CREATE (Short Cover - {exit_condition}), RSI={current_rsi:.2f}', doprint=True)
self.order = self.buy()
The Transformation: Backtesting the Filtered Strategy
The RsiMeanReversionTrendFiltered strategy, now enhanced with trend awareness, is backtested on the same historical data. The results, now, showe a remarkable turnaround:
Total Return: +15% (Turning a significant loss into a respectable profit)
Maximum Drawdown: 35%
The improvement is staggering. The 60% loss turns into a 15% gain. More importantly, the catastrophic 350% drawdown is slashed by 90% to a much more manageable (though still significant because we don’t have even a basic risk management!) 35%. By simply avoiding entries that directly fight strong trends, the strategy preserves capital during unfavourable periods and capitalizes on opportunities more aligned with the market’s flow.
Comparing the Results: Side-by-Side
Metric | Basic RSI Mean Reversion (RsiMeanReversion) | Trend-Filtered RSI (RsiMeanReversionTrendFiltered) | Improvement |
Total Return | -60% | +15% | Loss -> Profit |
Max Drawdown | 350% | 35% | Reduced by 90% |
Key Lessons and Caveats
This comparison underscores several critical points for strategy developers and traders:
Context is Crucial: Indicators like RSI rarely work well in isolation. Understanding the broader market context, especially the prevailing trend, is vital.
Simplicity Can Be Deceptive: While simple rules are appealing, they often lack the robustness to handle diverse market conditions.
Filters Can Add Value: Intelligent filters, like the EMA and ADX used here, can significantly improve a strategy’s risk-adjusted returns by adapting its behaviour.
Drawdown Matters Immensely: A strategy is only viable if its drawdowns are survivable, both financially and psychologically. Reducing drawdown is often more important than maximizing raw returns.
Backtesting is Essential (But Not Perfect): This experiment highlights the power of backtesting to reveal flaws and test enhancements. However, remember that these results are specific to the tested period, asset, and parameters. There’s always a risk of overfitting, and past performance doesn’t guarantee future results.
Conclusion
The journey from a -60% return and 350% drawdown to a +15% return and 35% drawdown showcases the power of adding adaptive logic to a simple trading concept. The basic RSI mean reversion strategy, applied blindly, was a recipe for disaster in trending markets, as shown in our hypothetical backtest. By incorporating basic trend direction and strength filters, the strategy was able to navigate market regimes more intelligently, dramatically improving its performance and survivability in this specific example. It serves as a powerful reminder that successful trading often lies not just in finding signals, but in understanding when and when not to act on them.**