Bollinger Bands are one of the most reliable volatility indicators in quant trading. Here's how to implement squeeze detection, band-width breakout signals, and backtest them with pandas โ then wire it into a live paper trading bot.
Bollinger Bands consist of three lines: a simple moving average (SMA) in the middle, and upper/lower bands set at ยฑ2 standard deviations from the SMA. When bands contract (squeeze), it signals low volatility โ often a precursor to a breakout. When price touches or breaks the upper band on high volume, it can indicate strong momentum.
import yfinance as yf
import pandas as pd
import numpy as np
def bollinger_bands(df, window=20, num_std=2):
"""Calculate Bollinger Bands for a price series."""
df = df.copy()
df['sma'] = df['Close'].rolling(window).mean()
df['std'] = df['Close'].rolling(window).std()
df['upper'] = df['sma'] + (num_std * df['std'])
df['lower'] = df['sma'] - (num_std * df['std'])
df['bandwidth'] = (df['upper'] - df['lower']) / df['sma']
return df
# Fetch data
ticker = yf.Ticker("SPY")
df = ticker.history(period="6mo")
df = bollinger_bands(df)
# Squeeze: bandwidth in bottom 20th percentile
squeeze_threshold = df['bandwidth'].quantile(0.20)
df['squeeze'] = df['bandwidth'] < squeeze_threshold
The most reliable BB signal: enter long when price breaks above the upper band after a squeeze. Exit when price crosses back below the SMA.
def generate_signals(df):
"""Generate BB breakout signals post-squeeze."""
signals = []
in_squeeze = False
for i in range(1, len(df)):
row = df.iloc[i]
prev = df.iloc[i - 1]
# Detect squeeze start
if row['squeeze']:
in_squeeze = True
# Breakout signal: price closes above upper band after squeeze
if in_squeeze and row['Close'] > row['upper']:
signals.append({'date': df.index[i], 'signal': 'BUY', 'price': row['Close']})
in_squeeze = False # reset
# Exit: price drops below SMA
elif row['Close'] < row['sma'] and prev['Close'] >= prev['sma']:
signals.append({'date': df.index[i], 'signal': 'SELL', 'price': row['Close']})
return pd.DataFrame(signals)
def backtest(df, signals, capital=10000):
"""Simple single-position backtest."""
position = 0
cash = capital
trades = []
for _, sig in signals.iterrows():
if sig['signal'] == 'BUY' and position == 0:
shares = cash / sig['price']
position = shares
cash = 0
trades.append({'action': 'BUY', 'price': sig['price'], 'shares': shares})
elif sig['signal'] == 'SELL' and position > 0:
cash = position * sig['price']
pnl = cash - capital
trades.append({'action': 'SELL', 'price': sig['price'], 'pnl': pnl})
position = 0
final = cash + (position * df['Close'].iloc[-1] if position else 0)
return_pct = (final - capital) / capital * 100
print(f"Return: {return_pct:.1f}% | Final: ${final:.0f}")
return trades
signals = generate_signals(df)
backtest(df, signals)
Past results do not guarantee future performance. Test on your own data.
| Parameter | Default | Try This |
|---|---|---|
| Window (SMA period) | 20 | 10 (mean reversion), 50 (trend) |
| Std Dev multiplier | 2.0 | 1.5 (more signals), 2.5 (fewer, cleaner) |
| Squeeze quantile | 20% | 10% (tighter squeeze filter) |
| Volume filter | None | Only enter if volume > 1.5x 20-day avg |
False breakouts are the #1 killer of BB strategies. Volume confirmation filters the noise significantly:
# Add to generate_signals():
volume_ma = df['Volume'].rolling(20).mean()
high_volume = df['Volume'] > (volume_ma * 1.5)
# Only take breakout if volume confirms
if in_squeeze and row['Close'] > row['upper'] and high_volume.iloc[i]:
signals.append({'date': df.index[i], 'signal': 'BUY', 'price': row['Close']})
in_squeeze = False
TradeSight runs strategies like this in a nightly tournament โ hundreds of parameter variations compete, losers get killed, winners paper-trade live. Wake up to results. One-time $49, runs on your machine.
Get TradeSight โ $49 โOne-time ยท Lifetime updates ยท MIT license ยท Self-hosted