The RSIFilteredBollingerBandSqueeze
strategy is a
trading system designed to capitalize on volatility
expansion following periods of low volatility,
using Bollinger Bands to identify these periods and the Relative
Strength Index (RSI) to confirm the breakout momentum. It aims to enter
trades as a new trend begins and manage them with a built-in trailing
stop.
This strategy focuses on identifying a “squeeze” in market volatility and then entering when price breaks out in a confirmed direction.
squeeze_period
. This signals that
volatility has compressed, and a significant move might be
imminent.Upon successful entry, the strategy places a trailing
stop-loss order. This means that once a position is open, a
stop-loss order is automatically set to trail the market price by a
fixed percentage (trail_percent
). As the market moves
favorably, the stop-loss adjusts to lock in profits, but it never moves
against the trade if the market reverses, thus protecting gains.
The strategy is implemented in backtrader
as
follows:
import backtrader as bt
import numpy as np
class RSIFilteredBollingerBandSqueeze(bt.Strategy):
"""
A strategy that enters on a breakout after a period of low volatility,
filtered by RSI to confirm momentum, and uses a trailing stop.
1. Identify Squeeze: Bollinger Bandwidth is at a multi-period low.
2. Enter on Breakout: Price closes outside the bands AND RSI confirms direction.
3. Exit: A trailing stop-loss order is placed upon entry.
"""
= (
params 'bband_period', 7),
('bband_devfactor', 1.0),
('squeeze_period', 30),
('rsi_period', 14),
('rsi_overbought', 70),
('rsi_oversold', 30),
('trail_percent', 0.02),
(
)
def __init__(self):
self.order = None
self.dataclose = self.datas[0].close
# Add Bollinger Bands indicator
self.bband = bt.indicators.BollingerBands(
self.datas[0],
=self.p.bband_period,
period=self.p.bband_devfactor
devfactor
)
# Calculate Bollinger Bandwidth
= (self.bband.lines.top - self.bband.lines.bot) / self.bband.lines.mid
bb_bandwidth
# Find the lowest bandwidth over the squeeze_period
self.lowest_bb_width = bt.indicators.Lowest(
=self.p.squeeze_period
bb_bandwidth, period
)
# Add RSI indicator
self.rsi = bt.indicators.RSI(self.datas[0], period=self.p.rsi_period)
def notify_order(self, order):
if order.status in [order.Submitted, order.Accepted]:
return
if order.status in [order.Completed]:
if order.isbuy():
# If a buy order completed, place a sell trailing stop for protection
self.sell(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
elif order.issell():
# If a sell order completed, place a buy trailing stop for protection
self.buy(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
self.order = None # Clear the order reference after completion
def next(self):
# Skip if an order is pending or not enough data for indicators
if self.order or len(self) < max(self.p.squeeze_period, self.p.rsi_period):
return
# Check for Bollinger Band Squeeze condition
# Current bandwidth must be less than or equal to the lowest bandwidth in the lookback period
= (self.bband.lines.top[0] - self.bband.lines.bot[0]) / self.bband.lines.mid[0] <= self.lowest_bb_width[0]
is_squeeze
if not self.position: # If no position is currently open
if is_squeeze: # Only consider entries if a squeeze is active
# Long entry condition: close breaks above top band AND RSI is not overbought
if self.dataclose[0] > self.bband.top[0] and self.rsi[0] < self.p.rsi_overbought:
self.order = self.buy() # Place a buy order
# Short entry condition: close breaks below bottom band AND RSI is not oversold
elif self.dataclose[0] < self.bband.bot[0] and self.rsi[0] > self.p.rsi_oversold:
self.order = self.sell() # Place a sell order
params
)The strategy’s behavior is configured through its parameters:
bband_period
: The period for the Bollinger Bands
calculation.bband_devfactor
: The standard deviation factor for the
Bollinger Bands, typically 2.0, but set to 1.0 in this specific code for
potentially tighter bands.squeeze_period
: The lookback period over which the
lowest Bollinger Bandwidth is identified to detect a squeeze.rsi_period
: The period for the Relative Strength
Index.rsi_overbought
: The RSI threshold for overbought
conditions (e.g., 70). Used to ensure upward momentum potential on long
breakouts.rsi_oversold
: The RSI threshold for oversold conditions
(e.g., 30). Used to ensure downward momentum potential on short
breakouts.trail_percent
: The percentage at which the trailing
stop-loss trails the market price (e.g., 0.02 for 2%).__init__
)In the __init__
method, all necessary indicators are set
up:
self.order
: This variable keeps track of any pending
orders to prevent multiple orders being placed on the same bar.self.dataclose
: A convenient reference to the close
price line of the data feed.self.bband
: The Bollinger Bands
indicator is instantiated with the specified bband_period
and bband_devfactor
.bb_bandwidth
: This calculates the Bollinger
Bandwidth, which measures the width of the bands relative to
the middle band. This is a key component for identifying a
“squeeze.”self.lowest_bb_width
: This indicator finds the
lowest Bollinger Bandwidth over the
squeeze_period
, which is used to identify when the current
bandwidth is at a historical low, signaling a squeeze.self.rsi
: The Relative Strength Index
indicator is instantiated to confirm momentum during breakouts.notify_order
)This method is automatically called by backtrader
whenever an order’s status changes. It is crucial for managing the
trailing stop-loss:
buy
order is completed (meaning
a long position has just been opened), a corresponding sell
trailing stop order is immediately placed using
self.sell(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
.sell
order is completed
(meaning a short position has just been opened), a corresponding
buy trailing stop order is placed using
self.buy(exectype=bt.Order.StopTrail, trailpercent=self.p.trail_percent)
.self.order = None
ensures that the order reference is
cleared once the order is no longer in a submitted or accepted state,
allowing for new orders on subsequent bars if conditions are met.next
)The next
method contains the core trading logic and is
executed on each new bar of data:
if self.order or len(self) < max(self.p.squeeze_period, self.p.rsi_period): return
:
This line acts as a guard. It prevents new actions if there’s an order
already pending or if there isn’t enough historical data yet for the
indicators to warm up (i.e., data length is less than the maximum period
used by squeeze_period
or rsi_period
).is_squeeze = (self.bband.lines.top[0] - self.bband.lines.bot[0]) / self.bband.lines.mid[0] <= self.lowest_bb_width[0]
:
This checks if the current Bollinger Bandwidth is at or below its lowest
point over the squeeze_period
, indicating an active
squeeze.if not self.position
):
if is_squeeze:
: Entries are only considered if a
squeeze condition is met.dataclose[0]
(current
close price) breaks above bband.top[0]
(upper Bollinger Band) AND rsi[0]
(current RSI) is
less than rsi_overbought
, a buy order is
placed (self.order = self.buy()
). This seeks to capture
upward momentum breakout.dataclose[0]
breaks
below bband.bot[0]
(lower Bollinger Band)
AND rsi[0]
is greater than
rsi_oversold
, a sell order is placed
(self.order = self.sell()
). This seeks to capture downward
momentum breakout.To comprehensively evaluate the strategy’s performance, a rolling backtest is used. This method assesses the strategy over multiple, successive time windows, providing a more robust view of its consistency compared to a single, fixed backtest.
from collections import deque
import backtrader as bt
import numpy as np
import pandas as pd
import yfinance as yf
import dateutil.relativedelta as rd
# Assuming RSIFilteredBollingerBandSqueeze class is defined above this section.
def run_rolling_backtest(
ticker,
start,
end,
window_months,
strategy_class,=None
strategy_params
):= strategy_params or {}
strategy_params = []
all_results = pd.to_datetime(start)
start_dt = pd.to_datetime(end)
end_dt = start_dt
current_start
while True:
= current_start + rd.relativedelta(months=window_months)
current_end if current_end > end_dt:
= end_dt
current_end if current_start >= current_end:
break
print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")
= yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)
data
if data.empty or len(data) < 90:
print("Not enough data for this period. Skipping.")
+= rd.relativedelta(months=window_months)
current_start continue
if isinstance(data.columns, pd.MultiIndex):
= data.droplevel(1, 1)
data
= data['Close'].iloc[0]
start_price = data['Close'].iloc[-1]
end_price = (end_price - start_price) / start_price * 100
benchmark_ret
= bt.feeds.PandasData(dataname=data)
feed = bt.Cerebro()
cerebro
**strategy_params)
cerebro.addstrategy(strategy_class,
cerebro.adddata(feed)100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents
= cerebro.broker.getvalue()
start_val try:
cerebro.run()except Exception as e:
print(f"Error running backtest for {current_start.date()} to {current_end.date()}: {e}")
+= rd.relativedelta(months=window_months)
current_start continue
= cerebro.broker.getvalue()
final_val = (final_val - start_val) / start_val * 100
strategy_ret
all_results.append({'start': current_start.date(),
'end': current_end.date(),
'strategy_return_pct': strategy_ret,
'benchmark_return_pct': benchmark_ret,
'final_value': final_val,
})
print(f"Strategy Return: {strategy_ret:.2f}% | Buy & Hold Return: {benchmark_ret:.2f}%")
+= rd.relativedelta(months=window_months)
current_start
if current_start > end_dt:
break
return pd.DataFrame(all_results)
ticker
, start
, end
: The asset
symbol and the overall historical period for the backtest.window_months
: The duration of each individual
backtesting window in months.strategy_class
: The backtrader.Strategy
class to be tested (e.g.,
RSIFilteredBollingerBandSqueeze
).strategy_params
: A dictionary to pass specific
parameters to the chosen strategy for each run.yfinance
with
auto_adjust=False
and droplevel
applied.backtrader.Cerebro
instance.strategy_class
and its
parameters.Pandas DataFrame
containing the
comprehensive results for each rolling window.
The RSIFilteredBollingerBandSqueeze
strategy provides a
structured approach to trading volatility breakouts. By
combining the Bollinger Band Squeeze to identify
periods of potential expansion with RSI filtering for
momentum confirmation and a trailing stop-loss for risk
management, it aims to capture significant directional moves while
protecting capital. The use of a rolling backtest
offers a robust way to evaluate its performance consistency across
various market conditions. As with any quantitative strategy, thorough
backtesting and careful consideration of its parameters are essential
before application.