5. A complete case: Bollinger Bands with RSI
Level 12: Backtesting and Real Risk Metrics
We are going to build a somewhat more complex strategy combining Bollinger Bands and the Relative Strength Index (RSI) with Backtrader. The logic is mean reversion with confirmation:
- Buy when the price closes below the lower Bollinger band and the RSI is below 30, which indicates an oversold condition.
- Sell when the price closes above the upper band and the RSI is above 70, an overbought condition.
The idea of requiring two simultaneous conditions is to reduce false positives: the price can pierce the lower band in the middle of a downtrend with nothing to revert, and the RSI acts as a filter.
import backtrader as bt
import yfinance as yf
import datetime
class BollingerRSIStrategy(bt.Strategy):
params = (
('bband_period', 20),
('rsi_period', 14),
('overbought', 70),
('oversold', 30),
('stddev', 2),
)
def __init__(self):
self.bband = bt.indicators.BollingerBands(
self.data.close,
period=self.params.bband_period,
devfactor=self.params.stddev
)
self.rsi = bt.indicators.RelativeStrengthIndex(
period=self.params.rsi_period
)
def next(self):
if self.data.close < self.bband.lines.bot and self.rsi < self.params.oversold:
self.buy()
elif self.data.close > self.bband.lines.top and self.rsi > self.params.overbought:
self.sell()
data_df = yf.download('AAPL', start='2020-01-01', end='2022-01-01')
data = bt.feeds.PandasData(dataname=data_df)
cerebro = bt.Cerebro(stdstats=False)
cerebro.addstrategy(BollingerRSIStrategy)
cerebro.adddata(data)
cerebro.broker.set_cash(100000.0)
cerebro.addsizer(bt.sizers.FixedSize, stake=1000)
cerebro.broker.setcommission(commission=0.001)
print('Starting portfolio value: %.2f' % cerebro.broker.getvalue())
cerebro.run()
print('Final portfolio value: %.2f' % cerebro.broker.getvalue())
cerebro.plot(iplot=True)What each piece does
BollingerBands builds three lines: mid (the simple moving average of the stated period), top and bot, placed devfactor standard deviations above and below the average. With devfactor=2 and roughly normal returns, around 5% of closes would fall outside the bands. Financial returns are not normal and have fat tails, so in practice the price leaves the bands considerably more often, especially during episodes of stress.
RelativeStrengthIndex with a period of 14 is Wilder's standard configuration. Watch out for a common trap: in an asset with a strong, sustained uptrend, the 14-period RSI can go months without dropping below 40. The strategy simply will not buy, and the backtest will return a flat result that can be mistaken for a prudent strategy when in reality it is an inactive one.
stdstats=False in Cerebro's constructor disables the standard observers (portfolio value, trades and buy/sell markers) on the chart. It is useful for keeping the plot clean when you already have several indicator panels, but it means you lose sight of the equity curve, which is precisely what you should be looking at most. Use it knowingly.
The author's warnings, which are worth taking seriously
- Validate the strategy with different data and under varied scenarios to ensure its robustness.
- Bear in mind that past performance is not indicative of future results, and that backtesting should be one tool among several when developing a strategy.
- Always apply sound risk management principles: set stop-loss levels and risk only a small percentage of the portfolio on each trade.
About this particular piece of code there are two shortcomings you should already be able to spot on your own. The first: there is no stop-loss and no profit target, so a losing position can stay open indefinitely. The second: because self.position is never checked, several consecutive buy signals accumulate an uncontrolled position. Adding if not self.position before buying and using self.close() to exit is the bare minimum before taking any number that comes out of here seriously.