Bollinger Bands have been a popular indicator by traders since they were invented in the early 1980’s. They’re calculated in four, easy steps and are intended to provide traders an idea of the price range of a security.

We can use these to develop a number of different algorithmic strategies to test. Below, we walk through 4 different trading strategies relying on the bands for mean reversion and trend following depending on how we decide to interpret the signals.

Bollinger Bands and Mean Reversion

For the standard Bollinger Band set-up, we’re looking at a 20-day moving average of the typical price (SMA(TP)) +/- 2 standard deviations (2\sigma). If the typical price follows a normal distribution (i.e. Bell curve), then it has a ~5% chance of moving 2 or more standard deviations away from the mean. In other words, we’ve got a 1/20 chance of reaching the edge of our standard Bollinger Band.

The mean reversion trader looks at this and wants to put a bet on that the price will move back towards the SMA(TP) in the near term. So if we hit the upper Bollinger Band (UBB) we go short, if we hit the lower (LBB) we go long and hold on until we reach the SMA(TP).

Hopefully that basic intuition makes sense to you because we’re going to jump into coding this strategy up and seeing how it performs.

Let’s import some basic packages and write a function to calculate our Bollinger Bands:

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import yfinance as yf

def calcBollingerBand(data, periods=20, m=2, label=None):
  '''Calculates Bollinger Bands'''
  keys = ['UBB', 'LBB', 'TP', 'STD', 'TP_SMA']
  if label is None:
    ubb, lbb, tp, std, tp_sma = keys
  else:
    ubb, lbb, tp, std, tp_sma = [i + '_' + label for i in keys]
  data[tp] = data.apply(
      lambda x: np.mean(x[['High', 'Low', 'Close']]), axis=1)
  data[tp_sma] = data[tp].rolling(periods).mean()
  data[std] = data[tp].rolling(periods).std(ddof=1)
  data[ubb] = data[tp_sma] + m * data[std]
  data[lbb] = data[tp_sma] - m * data[std]
  return data

This is going to take our data frame from YFinance and calculate all of our necessary intermediate values, then output typical price (TP), SMA(TP) (TP_SMA), upper Bollinger Band (UBB), and lower Bollinger Band (LBB). In addition to our data, it needs the number of periods (periods) and number of standard deviations (m) we use in our calculations. I also added an optional label argument that will update the keys in the data frame because some strategies we'll look at use two sets of Bollinger Bands and we don't want the values to be overwritten when we make our calculations.

Next, we'll write our mean reversion strategy.

def BBMeanReversion(data, periods=20, m=2, shorts=True):
  '''
  Buy/short when price moves outside of bands.
  Exit position when price crosses SMA(TP).
  '''
  data = calcBollingerBand(data, periods, m)
  # Get points where price crosses SMA(TP)
  xs = (data['Close'] - data['TP_SMA']) / \
    (data['Close'].shift(1) - data['TP_SMA'].shift(1))
  
  data['position'] = np.nan
  data['position'] = np.where(data['Close']<=data['LBB'], 1, 
                              data['position'])
  if shorts:
    data['position'] = np.where(data['Close']>=data['UBB'], -1, 
                                data['position'])
  else:
    data['position'] = np.where(data['Close']>=data['UBB'], 0, 
                                data['position'])
  
  # Exit when price crosses SMA(TP)
  data['position'] = np.where(np.sign(xs)==-1, 0, data['position'])

  data['position'] = data['position'].ffill().fillna(0)
  return calcReturns(data)

This strategy is going to go long when the price moves to the LBB and go short (if shorts=True) when the price reaches the UBB. It will sell if the price crosses the SMA(TP). We do this by looking for a sign change in the difference between the closing price and the SMA(TP) from one day to the next.

At the end, you’ll see I don’t simply return the data, but I wrap it in a calcReturns function. This is a helper function that makes it easy to get the returns for our strategy and the buy-and-hold baseline we'll benchmark ourselves against. The code for this is given below.

def calcReturns(df):
  # Helper function to avoid repeating too much code
  df['returns'] = df['Close'] / df['Close'].shift(1)
  df['log_returns'] = np.log(df['returns'])
  df['strat_returns'] = df['position'].shift(1) * df['returns']
  df['strat_log_returns'] = df['position'].shift(1) * \
    df['log_returns']
  df['cum_returns'] = np.exp(df['log_returns'].cumsum()) - 1
  df['strat_cum_returns'] = np.exp(df['strat_log_returns'].cumsum()) - 1
  df['peak'] = df['cum_returns'].cummax()
  df['strat_peak'] = df['strat_cum_returns'].cummax()
  return df

Now we just need our data and we can take a look at how this strategy performs. I'm going to just grab some data from the S&P 500 and test it over 21 years from 2000-2020.

table = pd.read_html('https://en.wikipedia.org/wiki/List_of_S%26P_500_companies')
df = table[0]
syms = df['Symbol']
# Sample symbols
ticker = np.random.choice(syms.values)
print(f"Ticker Symbol: {ticker}")

start = '2000-01-01'
end = '2020-12-31'

# Get Data
yfObj = yf.Ticker(ticker)
data = yfObj.history(start=start, end=end)
# Drop unused columns
data.drop(['Open', 'Volume', 'Dividends', 
    'Stock Splits'], inplace=True, axis=1)

df_rev = BBMeanReversion(data.copy(), shorts=True)
                    
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']

fig, ax = plt.subplots(2, figsize=(15, 10), sharex=True)

ax[0].plot(df_rev['Close'], label='Close')
ax[0].plot(df_rev['TP_SMA'], label='SMA(TP)')
ax[0].plot(df_rev['UBB'], color=colors[2])
ax[0].plot(df_rev['LBB'], color=colors[2])
ax[0].fill_between(df_rev.index, df_rev['UBB'], df_rev['LBB'],
                alpha=0.3, color=colors[2], label='Bollinger Band')

ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and Indicators for {ticker}')
ax[0].legend()

ax[1].plot(df_rev['cum_returns']*100, label='Buy and Hold')
ax[1].plot(df_rev['strat_cum_returns']*100, label='Mean Reversion')
ax[1].set_xlabel('Date')
ax[1].set_ylabel('Returns (%)')
ax[1].set_title(f'Buy and Hold and Mean Reversion Returns for {ticker}')
ax[1].legend()

plt.tight_layout()
plt.show()
bollinger-band-amd-mean-reversion-1024x680.png

It's a little difficult to see the Bollinger Band and its interface with the price at this 21-year scale. But it is easy to see that this strategy fell flat vs a simple, buy and hold approach.

Let's use another helper function to get the statistics for both of these so we can go a little deeper.

def getStratStats(log_returns: pd.Series, 
  risk_free_rate: float = 0.02):
  stats = {}  
  
  # Total Returns
  stats['tot_returns'] = np.exp(log_returns.sum()) - 1

  # Mean Annual Returns
  stats['annual_returns'] = np.exp(log_returns.mean() * 252) - 1
  
  # Annual Volatility
  stats['annual_volatility'] = log_returns.std() * np.sqrt(252)
  
  # Sortino Ratio
  annualized_downside = log_returns.loc[log_returns<0].std() * \
    np.sqrt(252)
  stats['sortino_ratio'] = (stats['annual_returns'] - \
    risk_free_rate) / annualized_downside  
  
  # Sharpe Ratio
  stats['sharpe_ratio'] = (stats['annual_returns'] - \
    risk_free_rate) / stats['annual_volatility']  
  
  # Max Drawdown
  cum_returns = log_returns.cumsum() - 1
  peak = cum_returns.cummax()
  drawdown = peak - cum_returns
  stats['max_drawdown'] = drawdown.max()  
  
  # Max Drawdown Duration
  strat_dd = drawdown[drawdown==0]
  strat_dd_diff = strat_dd.index[1:] - strat_dd.index[:-1]
  strat_dd_days = strat_dd_diff.map(lambda x: x.days).values
  strat_dd_days = np.hstack([strat_dd_days,
    (drawdown.index[-1] - strat_dd.index[-1]).days])
  stats['max_drawdown_duration'] = strat_dd_days.max()
  return stats

bh_stats = getStratStats(df_rev['log_returns'])
rev_stats = getStratStats(df_rev['strat_log_returns'])

df = pd.DataFrame(bh_stats, index=['Buy and Hold'])
df = pd.concat([df, pd.DataFrame(rev_stats, index=['Mean Reversion'])])
df
bollinger-band-amd-stats1-1024x98.png

Overall, this strategy had a bad go of it, losing just about all of our starting capital. It wasn't always a loser, below we can see the annual performance of the strategy vs the buy and hold approach.

df_rev['year'] = df_rev.index.map(lambda x: x.year)
ann_rets = df_rev.groupby('year')[['log_returns', 'strat_log_returns']].sum()
ann_rets.columns = ['Buy and Hold Returns', 'Mean Reversion Returns']
ann_rets.apply(lambda x: np.exp(x).round(4) -1, axis=1)* 100
bollinger-band-amd-mean-reversion-annual-returns.png

Our mean reversion model was either really hot or really cold. From 2003-2009, it was simply compounding at terrible rates year after year making it impossible to ever come back. Also, we can see that this stock, as well as the strategy, had very high volatility - often times a good thing for these strategies - but it was caught on the wrong side of the moves far too often.

long_entry = df_rev.loc[(df_rev['position']==1) &
                        (df_rev['position'].shift(1)==0)]['Close']
long_exit = df_rev.loc[(df_rev['position']==0) &
                        (df_rev['position'].shift(1)==1)]['Close']
short_entry = df_rev.loc[(df_rev['position']==-1) &
                        (df_rev['position'].shift(1)==0)]['Close']
short_exit = df_rev.loc[(df_rev['position']==0) &
                        (df_rev['position'].shift(1)==-1)]['Close']
                    
colors = plt.rcParams['axes.prop_cycle'].by_key()['color']

fig, ax = plt.subplots(1, figsize=(15, 10), sharex=True)

ax.plot(df_rev['Close'], label='Close')
ax.plot(df_rev['TP_SMA'], label='SMA(TP)')
ax.plot(df_rev['UBB'], color=colors[2])
ax.plot(df_rev['LBB'], color=colors[2])
ax.fill_between(df_rev.index, df_rev['UBB'], df_rev['LBB'],
                alpha=0.3, color=colors[2], label='Bollinger Band')

ax.scatter(long_entry.index, long_entry, c=colors[4], 
           s=100, marker='^', label='Long Entry',
           zorder=10)
ax.scatter(long_exit.index, long_exit, c=colors[4],
           s=100, marker='x', label='Long Exit',
           zorder=10)
ax.scatter(short_entry.index, short_entry, c=colors[3], 
           s=100, marker='^', label='Short Entry',
           zorder=10)
ax.scatter(short_exit.index, short_exit, c=colors[3],
           s=100, marker='x', label='Short Exit',
           zorder=10)
ax.set_ylabel('Price ($)')
ax.set_title(
  f'Price and Bollinger Band Mean Reversion Strategy for {ticker} in 2002')
ax.legend()

ax.set_ylim([0, 20])
ax.set_xlim([pd.to_datetime('2002-01-01'), pd.to_datetime('2002-12-31')])
plt.tight_layout()
plt.show()
bollinger-band-amd-mean-reversion-plot1-1024x680.png

Trading Bollinger Band Breakouts

Mean reversion performed poorly, but we can change to a trend-following model that buys when the price moves above the upper Band.

def BBBreakout(data, periods=20, m=1, shorts=True):
  '''
  Buy/short when price moves outside of the upper band.
  Exit when the price moves into the band.
  '''
  data = calcBollingerBand(data, periods, m)

  data['position'] = np.nan
  data['position'] = np.where(data['Close']>data['UBB'], 1, 0)
  if shorts:
    data['position'] = np.where(data['Close']<data['LBB'], -1, data['position'])
  data['position'] = data['position'].ffill().fillna(0)

  return calcReturns(data)
df_break = BBBreakout(data.copy())

fig, ax = plt.subplots(2, figsize=(15, 10), sharex=True)

ax[0].plot(df_break['Close'], label='Close')
ax[0].plot(df_break['UBB'], color=colors[2])
ax[0].plot(df_break['LBB'], color=colors[2])
ax[0].fill_between(df_break.index, df_break['UBB'], df_break['LBB'],
                alpha=0.3, color=colors[2], label='Bollinger Band')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and Bolling Bands for {ticker}')
ax[0].legend()

ax[1].plot(df_break['cum_returns'] * 100, label='Buy and Hold')
ax[1].plot(df_break['strat_cum_returns'] * 100, label='Breakout')
ax[1].set_xlabel('Date')
ax[1].set_ylabel('Returns (%)')
ax[1].set_title('Cumulative Returns for Breakout Strategy and Buy and Hold')
ax[1].legend()

plt.show()

break_stats = getStratStats(df_break['strat_log_returns'])
df = pd.concat([df,
  pd.DataFrame(break_stats, index=['Breakout'])])
df
bollinger-band-amd-breakout-plot1.png
bollinger-band-amd-stats2-1024x122.png

This strategy just blows away the baseline by multiplying the starting capital by 37 times! The Sharpe and Sortino ratios look reasonable as well. The big thing, however is that huge drawdown after the strategy spiked in 2018 and gave almost all of that back in the subsequent years.

Let’s look a little more closely.

long_entry = df_break.loc[(df_break['position']==1) &
                        (df_break['position'].shift(1)!=1)]['Close']
long_exit = df_rev.loc[(df_break['position']!=1) &
                        (df_break['position'].shift(1)==1)]['Close']
short_entry = df_rev.loc[(df_break['position']==-1) &
                        (df_break['position'].shift(1)!=-1)]['Close']
short_exit = df_rev.loc[(df_break['position']!=-1) &
                        (df_break['position'].shift(1)==-1)]['Close']

fig, ax = plt.subplots(2, figsize=(15, 10), sharex=True)

ax[0].plot(df_break['Close'], label='Close')
ax[0].plot(df_break['UBB'], color=colors[2])
ax[0].plot(df_break['LBB'], color=colors[2])
ax[0].fill_between(df_break.index, df_break['UBB'], df_break['LBB'],
                alpha=0.3, color=colors[2], label='Bollinger Band')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and Bolling Bands for {ticker} (2018)')
ax[0].legend()

ax[0].scatter(long_entry.index, long_entry, c=colors[4], 
           s=100, marker='^', label='Long Entry',
           zorder=10)
ax[0].scatter(long_exit.index, long_exit, c=colors[4],
           s=100, marker='x', label='Long Exit',
           zorder=10)
ax[0].scatter(short_entry.index, short_entry, c=colors[3], 
           s=100, marker='^', label='Short Entry',
           zorder=10)
ax[0].scatter(short_exit.index, short_exit, c=colors[3],
           s=100, marker='x', label='Short Exit',
           zorder=10)
ax[0].set_ylim([5, 35])

ax[1].plot(df_break['strat_cum_returns'] * 100, label='Breakout', c=colors[1])
ax[1].set_xlabel('Date')
ax[1].set_ylabel('Returns (%)')
ax[1].set_title('Cumulative Returns for Breakout Strategy')
ax[1].legend()

ax[1].set_xlim([pd.to_datetime('2018-01-01'), pd.to_datetime('2019-01-01')])

plt.show()
bollinger-band-amd-breakout-plot2.png

In the plot above, we see that 2018 was just about a perfect year for this model. As the underlying security moved up 3x in price, our model was on the right side of nearly every trade and was able to 3x its equity from the low to the peak. It gave back a little from the peak in late October when a short position moved against it and took away a little bit of the profit.

Then, the drawdown happened.

fig, ax = plt.subplots(2, figsize=(15, 10), sharex=True)

ax[0].plot(df_break['Close'], label='Close')
ax[0].plot(df_break['UBB'], color=colors[2])
ax[0].plot(df_break['LBB'], color=colors[2])
ax[0].fill_between(df_break.index, df_break['UBB'], df_break['LBB'],
                alpha=0.3, color=colors[2], label='Bollinger Band')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and Bolling Bands for {ticker} (2019-2020)')
ax[0].legend()

ax[0].scatter(long_entry.index, long_entry, c=colors[4], 
           s=100, marker='^', label='Long Entry',
           zorder=10)
ax[0].scatter(long_exit.index, long_exit, c=colors[4],
           s=100, marker='x', label='Long Exit',
           zorder=10)
ax[0].scatter(short_entry.index, short_entry, c=colors[3], 
           s=100, marker='^', label='Short Entry',
           zorder=10)
ax[0].scatter(short_exit.index, short_exit, c=colors[3],
           s=100, marker='x', label='Short Exit',
           zorder=10)
ax[0].set_ylim([15, 60])

ax[1].plot(df_break['strat_cum_returns'] * 100, label='Breakout', c=colors[1])
ax[1].set_xlabel('Date')
ax[1].set_ylabel('Returns (%)')
ax[1].set_title('Cumulative Returns for Breakout Strategy')
ax[1].legend()

ax[1].set_xlim([pd.to_datetime('2019-01-01'), pd.to_datetime('2020-06-01')])
ax[1].set_ylim([3500, 10000])
plt.show()
bollinger-band-amd-breakout-plot3.png

In 2019, our model just couldn’t get anything going. It came down quite a ways as it kept losing with a series of false breakouts to the upside early in the year. About midway through the year, it switched and couldn’t catch a break losing money to the downside again and again. It got a pair of nice upward moves late in the year, but it wasn’t enough to make up for the losses it had sustained so far.

When the COVID crash came, it consistently got caught on the wrong side by selling short after big down moves only to see the price reverse and being forced to close the position at a loss.

Overall, the performance of this model was tremendous. But let’s see if we can do a bit better by adding another Bollinger Band to sell when the price moves too far before it reverses.

Double Bollinger Band Breakout

For this next strategy, we're going to buy when the model breaks above the inner band which is set at [latex]1\sigma[/latex], but sell if the price moves beyond the second band at [latex]2\sigma[/latex]. We want to capture the upside of the breakout model, but close out positions before they reverse on us.

def DoubleBBBreakout(data, periods=20, m1=1, m2=2, shorts=True):
  '''
  Buy/short when price moves outside of the inner band (m1).
  Exit when the price moves into the inner band or to the outer bound (m2).
  '''
  assert m2 > m1, f'm2 must be greater than m1:\nm1={m1}\tm2={m2}'
  data = calcBollingerBand(data, periods, m1, label='m1')
  data = calcBollingerBand(data, periods, m2, label='m2')

  data['position'] = np.nan
  data['position'] = np.where(data['Close']>data['UBB_m1'], 1, 0)
  if shorts:
    data['position'] = np.where(data['Close']<data['LBB_m1'], -1, data['position'])
  data['position'] = np.where(data['Close']>data['UBB_m2'], 0, data['position'])
  data['position'] = np.where(data['Close']<data['LBB_m2'], 0, data['position'])
  data['position'] = data['position'].ffill().fillna(0)

  return calcReturns(data)
df_double = DoubleBBBreakout(data.copy())

fig, ax = plt.subplots(2, figsize=(15, 10), sharex=True)

ax[0].plot(df_double['Close'], label='Close', linewidth=0.5)
ax[0].plot(df_double['UBB_m1'], color=colors[2], linewidth=0.5)
ax[0].plot(df_double['LBB_m1'], color=colors[2], linewidth=0.5)
ax[0].fill_between(df_double.index, df_double['UBB_m1'], df_double['LBB_m1'],
                alpha=0.3, color=colors[2], label='Inner Bollinger Band')
ax[0].plot(df_double['UBB_m2'], color=colors[4], linewidth=0.5)
ax[0].plot(df_double['LBB_m2'], color=colors[4], linewidth=0.5)
ax[0].fill_between(df_double.index, df_double['UBB_m2'], df_double['LBB_m2'],
                alpha=0.3, color=colors[4], label='Outer Bollinger Band')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and Bolling Bands for {ticker}')
ax[0].legend()

ax[1].plot(df_double['cum_returns'] * 100, label='Buy and Hold')
ax[1].plot(df_double['strat_cum_returns'] * 100, label='Double Breakout')
ax[1].set_xlabel('Date')
ax[1].set_ylabel('Returns (%)')
ax[1].set_title('Cumulative Returns for Double Breakout Strategy and Buy and Hold')
ax[1].legend()

plt.show()

double_stats = getStratStats(df_double['strat_log_returns'])
df = pd.concat([df,
  pd.DataFrame(double_stats, index=['Double Breakout'])])
df
bollinger-band-amd-double-breakout-plot1.png
bollinger-band-amd-stats3-1024x144.png

As hoped, this model does reduce our volatility versus the buy and hold as well as the previous breakout model. However, we get a decrease in our total returns — still outperforming the buy and hold model and the mean reversion approaches. Surprisingly, we also reduce our Sortino Ratio versus the breakout model, but do increase our Sharpe Ratio.

Trading the Band Width

In our intro article to Bollinger Bands, we mentioned a strategy recommended by John Bollinger that relies on the band width. This is calculated by taking the difference between the UBB and LBB and dividing by the SMA(TP).

As the width decreases, volatility decreases. Bollinger states that when this occurs, we can expect to see an increase volatility following a low in the band width. Unfortunately, we don’t have any directional indicators associated with a low in volatility, so we need to combine this with something else to let us know whether we should be long or short.

I have no idea if this tendency occurs or could be a tradeable signal, so let’s put a strategy together that we can test. Whether this works or not here, does not prove or disprove Bollinger’s claim — we’re running a simple, vectorized backtest on a single security to demonstrate how these signals could be used in a more complete strategy. So don’t go crazy one way or the other, just take the results as a data point and do your own investigation (the same can be said for all of these simple backtests we’re running).

Anyway…to test this we’ll combine a low point in the Bollinger Band Width with an EMA cross over to get a directional signal. Why EMA? Well, it’s more responsive to recent price changes than SMA, for example, because of the heavier weighting given towards the last price. If we see an increase in volatility following a low (which is a silly way to put it because this is true by definition) that we can trade then we’d want to jump on it quickly and EMA is going to be more likely to pick that up.

To implement this strategy, we’ll need to use some code for calculating the EMA.

def _calcEMA(P, last_ema, N):
  return (P - last_ema) * (2 / (N + 1)) + last_ema

def calcEMA(data, N):
  # Initialize series
  data['SMA_' + str(N)] = data['Close'].rolling(N).mean()
  ema = np.zeros(len(data))
  for i, _row in enumerate(data.iterrows()):
    row = _row[1]
    if i < N:
      ema[i] += row['SMA_' + str(N)]
    else:
      ema[i] += _calcEMA(row['Close'], ema[i-1], N)
  data['EMA_' + str(N)] = ema.copy()
  return data

Next, we need a full definition for our strategy. We’ll use the standard Bollinger Band settings of 20 days and [latex]2\sigma[/latex]. We’ll be looking for 20-day lows in the Band Width and seeing if we get a short-term, 10-day EMA to move above a longer term 30-day EMA to go long. If we get a low in the Band Width and the short-term EMA to move below the long term EMA, we’ll go short. We exit a position whenever the short term EMA crosses back over the long term EMA.

Now to the code (with some additional doc strings for clarity):

def BBWidthEMACross(data, periods=20, m=2, N=20, EMA1=10, EMA2=30, shorts=True):
  '''
  Buys when Band Width reaches 20-day low and EMA1 > EMA2.
  Shorts when Band Width reaches 20-day low and EMA1 < EMA2.
  Exits position when EMA reverses.
  :periods: number of periods for Bollinger Band calculation.
  :m: number of standard deviations for Bollinger Band.
  :N: number of periods used to find a low.
  :EMA1: number of periods used in the short-term EMA signal.
  :EMA2: number of periods used in the long-term EMA signal.
  :shorts: boolean value to indicate whether or not shorts are allowed.
  '''
  assert EMA1 < EMA2, f"EMA1 must be less than EMA2."
  # Calculate indicators
  data = calcBollingerBand(data, periods, m)
  data['width'] = (data['UBB'] - data['LBB']) / data['TP_SMA']
  data['min_width'] = data['width'].rolling(N).min()
  data = calcEMA(data, EMA1)
  data = calcEMA(data, EMA2)

  data['position'] = np.nan
  data['position'] = np.where(
      (data['width']==data['min_width']) &
      (data[f'EMA_{EMA1}']>data[f'EMA_{EMA2}']), 1, 0)
  if shorts:
    data['position'] = np.where(
        (data['width']==data['min_width']) &
        (data[f'EMA_{EMA1}']<data[f'EMA_{EMA2}']), -1, 
        data['position'])
  data['position'] = data['position'].ffill().fillna(0)

  return calcReturns(data)
df_bw_ema = BBWidthEMACross(data.copy())

bw_mins = df_bw_ema.loc[df_bw_ema['width']==df_bw_ema['min_width']]['width']

fig, ax = plt.subplots(3, figsize=(20, 12), sharex=True)
ax[0].plot(df_bw_ema['Close'], label='Close')
ax[0].plot(df_bw_ema['EMA_10'], label='EMA-10')
ax[0].plot(df_bw_ema['EMA_30'], label='EMA-30')
ax[0].set_ylabel('Price ($)')
ax[0].set_title(f'Price and EMAs for {ticker}')
ax[0].legend()

ax[1].plot(df_bw_ema['width'], label='Band Width')
ax[1].scatter(bw_mins.index, bw_mins, s=100, marker='o', c=colors[1],
              label='20-Day Minima')
ax[1].set_ylabel('Bollinger Band Width')
ax[1].set_title('Bollinger Band Width and Local Minima')
ax[1].legend()

ax[2].plot(df_bw_ema['cum_returns'] * 100, label='Buy and Hold')
ax[2].plot(df_bw_ema['strat_cum_returns'] * 100, label='Strat Rets')
ax[2].set_xlabel('Date')
ax[2].set_ylabel('Returns (%)')
ax[2].set_title('Cumulative Returns for Band Width EMA and Buy and Hold')
ax[2].legend()

plt.show()

bw_ema_stats = pd.DataFrame(getStratStats(df_bw_ema['strat_log_returns']), 
  index=['Band Width and EMA'])
df = pd.concat([df, bw_ema_stats])
df
bollinger-band-amd-band-width-plot1-1024x621.png
bollinger-band-amd-stats4-1024x171.png

This strategy loses most of its starting capital over the time horizon and winds up under performing most other strategies. One of the issues we see is that the 20-day min might not be discriminatory enough. For example, there are times when the Band Width rises significantly (e.g. 2003, 2008, 2009) and so a 20-day minimum winds up being rather elevated.

We could update this to have an additional threshold in place before putting the trade on such as 20-day minimum and width < 0.2, for example. We could also extend the look-back period to be 30, 60, or more days, which would help us avoid buying in the midst of those high volatility periods. This is all assuming that Bollinger is correct about buying in low Band Width periods to begin with, of course.

What should you trade?

There are heaps of other ways you can use Bollinger Bands in your trading. You can combine it with other indicators and methods to build better (or worse) strategies than shown above.

One thing to keep in mind — don’t trade based on a single, vectorized backtest like we showed here. These are designed to give you a feel for how these models work, but there are too many simplifications, no diversity in instruments, no money management, no transaction costs — nothing that you need to develop a real strategy.

All those features are difficult to implement.

However, we are developing a complete, no-code trading system to allow you to build, test, and deploy your own algorithmic trading systems. Drop your email address below to get updates and a chance to be on our pre-release list!