Skip to content

Bollinger Bands Strategy

Mean reversion using Bollinger Bands.

Strategy Overview

Bollinger Bands create dynamic support and resistance levels. Trade reversions when prices touch the bands.

Bollinger Bands

Text Only
Middle Band = 20-day SMA
Upper Band = Middle + 2 × 20-day Std Dev
Lower Band = Middle - 2 × 20-day Std Dev

The Signal

Text Only
signal bollinger:
  ma = rolling_mean(prices, 20)
  std = rolling_std(prices, 20)

  upper = ma + 2 * std
  lower = ma - 2 * std

  // Position within bands (0 to 1)
  band_position = (prices - lower) / (upper - lower)

  // Buy at lower band, sell at upper
  emit zscore(0.5 - band_position)

Complete Strategy

Text Only
data:
  source = "prices_with_sectors.parquet"
  format = parquet

// Bollinger Bands
signal bollinger_bands:
  ma_20 = rolling_mean(prices, 20)
  std_20 = rolling_std(prices, 20)

  upper = ma_20 + 2 * std_20
  lower = ma_20 - 2 * std_20

  // Normalized position (0 = lower, 1 = upper)
  position = (prices - lower) / (upper - lower)

  // Revert from extremes
  signal = 0.5 - position

  emit neutralize(zscore(signal), by=sectors)

portfolio bollinger_strategy:
  weights = rank(bollinger_bands).long_short(
    top = 0.2,
    bottom = 0.2,
    cap = 0.03
  )

  constraints:
    gross_exposure = 2.0
    net_exposure = 0.0
    max_sector = 0.20

  costs = tc.bps(10)

  backtest rebal=5 from 2015-01-01 to 2024-12-31

Variations

Band Width Filter

Text Only
signal bandwidth_filtered:
  ma = rolling_mean(prices, 20)
  std = rolling_std(prices, 20)

  upper = ma + 2 * std
  lower = ma - 2 * std

  // Band width
  bandwidth = (upper - lower) / ma

  // Wider bands = higher volatility = stronger signals
  wide_bands = bandwidth > rolling_mean(bandwidth, 60)

  position = (prices - lower) / (upper - lower)
  base_signal = 0.5 - position

  // Stronger signal when bands are wide
  emit where(wide_bands, base_signal * 1.3, base_signal)

Double Bollinger

Text Only
signal double_bollinger:
  ma = rolling_mean(prices, 20)
  std = rolling_std(prices, 20)

  // Inner bands (1 std)
  inner_upper = ma + 1 * std
  inner_lower = ma - 1 * std

  // Outer bands (2 std)
  outer_upper = ma + 2 * std
  outer_lower = ma - 2 * std

  // Zones
  extreme_high = prices > outer_upper
  extreme_low = prices < outer_lower
  moderate_high = prices > inner_upper and prices <= outer_upper
  moderate_low = prices < inner_lower and prices >= outer_lower

  signal = where(extreme_low, 2,
           where(moderate_low, 1,
           where(extreme_high, -2,
           where(moderate_high, -1, 0))))

  emit zscore(signal)

Bollinger Squeeze

Text Only
signal bollinger_squeeze:
  ma = rolling_mean(prices, 20)
  std = rolling_std(prices, 20)

  bandwidth = (2 * std) / ma

  // Squeeze = narrow bands (low volatility)
  squeeze = bandwidth < rolling_min(bandwidth, 120)

  // After squeeze, expect volatility expansion
  // Don't mean revert during squeeze
  position = (prices - (ma - 2*std)) / (4 * std)
  base_signal = 0.5 - position

  emit where(squeeze, 0, base_signal)

Multi-Period Bands

Text Only
signal multi_period:
  // Short-term bands
  ma_10 = rolling_mean(prices, 10)
  std_10 = rolling_std(prices, 10)
  pos_10 = (prices - (ma_10 - 2*std_10)) / (4 * std_10)

  // Medium-term bands
  ma_20 = rolling_mean(prices, 20)
  std_20 = rolling_std(prices, 20)
  pos_20 = (prices - (ma_20 - 2*std_20)) / (4 * std_20)

  // Long-term bands
  ma_50 = rolling_mean(prices, 50)
  std_50 = rolling_std(prices, 50)
  pos_50 = (prices - (ma_50 - 2*std_50)) / (4 * std_50)

  // Average position
  avg_pos = (pos_10 + pos_20 + pos_50) / 3

  emit zscore(0.5 - avg_pos)

With Trend Confirmation

Text Only
signal trend_confirmed:
  ma_20 = rolling_mean(prices, 20)
  std_20 = rolling_std(prices, 20)

  upper = ma_20 + 2 * std_20
  lower = ma_20 - 2 * std_20

  // Band touches
  at_lower = prices <= lower
  at_upper = prices >= upper

  // Trend filter
  ma_50 = rolling_mean(prices, 50)
  uptrend = prices > ma_50
  downtrend = prices < ma_50

  // In uptrend, buy at lower band is stronger
  // In downtrend, short at upper band is stronger
  signal = where(at_lower and uptrend, 1.5,
           where(at_lower, 1.0,
           where(at_upper and downtrend, -1.5,
           where(at_upper, -1.0, 0))))

  emit zscore(signal)

%B Indicator

Text Only
signal percent_b:
  ma = rolling_mean(prices, 20)
  std = rolling_std(prices, 20)

  upper = ma + 2 * std
  lower = ma - 2 * std

  // %B = position within bands
  // 0 = at lower, 1 = at upper, negative = below lower
  pct_b = (prices - lower) / (upper - lower)

  // Extreme readings
  oversold = pct_b < 0
  overbought = pct_b > 1

  signal = where(oversold, 1 - pct_b,
           where(overbought, 1 - pct_b,
           0.5 - pct_b))

  emit zscore(signal)

Expected Results

Text Only
Backtest Results: bollinger_strategy
====================================
Period: 2015-01-01 to 2024-12-31

Returns:
  Total Return: 52%
  Annual Return: 4.5%
  Annual Volatility: 7.5%
  Sharpe Ratio: 0.60

Band Statistics:
  Avg Position (longs): 0.18
  Avg Position (shorts): 0.82
  Band Touches: 15% of signals

See Also