← Back to Home
RSI Filtered Bollinger Band Squeeze Strategy

RSI Filtered Bollinger Band Squeeze Strategy

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.

Strategy Overview

This strategy focuses on identifying a “squeeze” in market volatility and then entering when price breaks out in a confirmed direction.

Entry Logic

  1. Identify Squeeze: The strategy first determines if the market is in a squeeze. A squeeze is characterized by a period of low volatility where the Bollinger Bands contract (narrow). This is identified by checking if the current Bollinger Bandwidth (the difference between the upper and lower bands, normalized by the middle band) is at its lowest point over a specified squeeze_period. This signals that volatility has compressed, and a significant move might be imminent.
  2. Enter on Breakout: Once a squeeze is detected, the strategy waits for a breakout from the Bollinger Bands.
    • Long Entry: If the closing price breaks above the upper Bollinger Band and the RSI is below its overbought threshold (e.g., 70), a buy order is placed. The RSI condition ensures there’s still room for upward momentum and the asset isn’t already “overbought.”
    • Short Entry: If the closing price breaks below the lower Bollinger Band and the RSI is above its oversold threshold (e.g., 30), a sell order is placed. The RSI condition suggests there’s still room for downward momentum and the asset isn’t already “oversold.”

Exit Logic

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.

Backtrader Implementation

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],
            period=self.p.bband_period,
            devfactor=self.p.bband_devfactor
        )

        # Calculate Bollinger Bandwidth
        bb_bandwidth = (self.bband.lines.top - self.bband.lines.bot) / self.bband.lines.mid

        # Find the lowest bandwidth over the squeeze_period
        self.lowest_bb_width = bt.indicators.Lowest(
            bb_bandwidth, period=self.p.squeeze_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
        is_squeeze = (self.bband.lines.top[0] - self.bband.lines.bot[0]) / self.bband.lines.mid[0] <= self.lowest_bb_width[0]

        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

Parameters (params)

The strategy’s behavior is configured through its parameters:

Initialization (__init__)

In the __init__ method, all necessary indicators are set up:

Order Notification (notify_order)

This method is automatically called by backtrader whenever an order’s status changes. It is crucial for managing the trailing stop-loss:

Main Logic (next)

The next method contains the core trading logic and is executed on each new bar of data:

Rolling Backtesting Setup

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,
    strategy_params=None
):
    strategy_params = strategy_params or {}
    all_results = []
    start_dt = pd.to_datetime(start)
    end_dt = pd.to_datetime(end)
    current_start = start_dt

    while True:
        current_end = current_start + rd.relativedelta(months=window_months)
        if current_end > end_dt:
            current_end = end_dt
            if current_start >= current_end:
                break

        print(f"\nROLLING BACKTEST: {current_start.date()} to {current_end.date()}")

        data = yf.download(ticker, start=current_start, end=current_end, auto_adjust=False, progress=False)

        if data.empty or len(data) < 90:
            print("Not enough data for this period. Skipping.")
            current_start += rd.relativedelta(months=window_months)
            continue

        if isinstance(data.columns, pd.MultiIndex):
            data = data.droplevel(1, 1)

        start_price = data['Close'].iloc[0]
        end_price = data['Close'].iloc[-1]
        benchmark_ret = (end_price - start_price) / start_price * 100

        feed = bt.feeds.PandasData(dataname=data)
        cerebro = bt.Cerebro()
        
        cerebro.addstrategy(strategy_class, **strategy_params)
        cerebro.adddata(feed)
        cerebro.broker.setcash(100000)
        cerebro.broker.setcommission(commission=0.001)
        cerebro.addsizer(bt.sizers.PercentSizer, percents=95)

        start_val = cerebro.broker.getvalue()
        try:
            cerebro.run()
        except Exception as e:
            print(f"Error running backtest for {current_start.date()} to {current_end.date()}: {e}")
            current_start += rd.relativedelta(months=window_months)
            continue

        final_val = cerebro.broker.getvalue()
        strategy_ret = (final_val - start_val) / start_val * 100

        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}%")

        current_start += rd.relativedelta(months=window_months)

        if current_start > end_dt:
            break

    return pd.DataFrame(all_results)

How the Rolling Backtest Works:

Pasted image 20250721213703.png Pasted image 20250721213708.png Pasted image 20250721213713.png

Conclusion

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.