What are Bollinger bands?

Draw a moving average with a band two standard deviations above and below it, and measure where the close sits inside that band.

Bollinger bands are three lines. The middle one is a moving average of the close. The other two sit a number of standard deviations above and below it, with the standard deviation measured over the same window as the average. Two standard deviations and a 20 day window are the usual settings.

In this lesson I build the three lines on eight prices I can check by hand, then on AAPL with a 20 day window, and I measure where each close sits inside its band.

Step 1. Three lines on eight prices

rolling(5).mean() gives the middle line and rolling(5).std(ddof=0) gives the standard deviation over the same five closes. Both are NaN until five closes exist.

import pandas as pd
import numpy as np

close = pd.Series([100.0, 102.0, 101.0, 104.0, 103.0, 107.0, 106.0, 110.0])

mid = close.rolling(5).mean()
sd  = close.rolling(5).std(ddof=0)

print(pd.DataFrame({"close": close, "mid": mid, "sd": sd}))
# ->    close    mid        sd
# -> 0  100.0    NaN       NaN
# -> 1  102.0    NaN       NaN
# -> 2  101.0    NaN       NaN
# -> 3  104.0    NaN       NaN
# -> 4  103.0  102.0  1.414214
# -> 5  107.0  103.4  2.059126
# -> 6  106.0  104.2  2.135416
# -> 7  110.0  106.0  2.449490
   close    mid        sd
0  100.0    NaN       NaN
1  102.0    NaN       NaN
2  101.0    NaN       NaN
3  104.0    NaN       NaN
4  103.0  102.0  1.414214
5  107.0  103.4  2.059126
6  106.0  104.2  2.135416
7  110.0  106.0  2.449490

The bands are the middle line plus and minus two of those standard deviations.

upper = mid + 2 * sd
lower = mid - 2 * sd

bands = pd.DataFrame({"lower": lower, "mid": mid, "upper": upper})

print(bands.round(4))
# ->       lower    mid     upper
# -> 0       NaN    NaN       NaN
# -> 1       NaN    NaN       NaN
# -> 2       NaN    NaN       NaN
# -> 3       NaN    NaN       NaN
# -> 4   99.1716  102.0  104.8284
# -> 5   99.2817  103.4  107.5183
# -> 6   99.9292  104.2  108.4708
# -> 7  101.1010  106.0  110.8990
      lower    mid     upper
0       NaN    NaN       NaN
1       NaN    NaN       NaN
2       NaN    NaN       NaN
3       NaN    NaN       NaN
4   99.1716  102.0  104.8284
5   99.2817  103.4  107.5183
6   99.9292  104.2  108.4708
7  101.1010  106.0  110.8990

Row 4 is the first complete row, so it uses closes 0 to 4. Here is that row worked out from those five numbers.

window = close.iloc[0:5]

print(list(window))                                          # -> [100.0, 102.0, 101.0, 104.0, 103.0]
print(round(window.mean(), 4))                               # -> 102.0
print(round(np.sqrt(((window - window.mean()) ** 2).mean()), 4))   # -> 1.4142

print(round(mid.iloc[4], 4))      # -> 102.0
print(round(sd.iloc[4], 4))       # -> 1.4142
print(round(upper.iloc[4], 4))    # -> 104.8284    <- 102.0 + 2 * 1.4142
print(round(lower.iloc[4], 4))    # -> 99.1716     <- 102.0 - 2 * 1.4142
[100.0, 102.0, 101.0, 104.0, 103.0]
102.0
1.4142
102.0
1.4142
104.8284
99.1716

Step 2. ddof=0 or ddof=1

ddof sets what the squared deviations are divided by: ddof=0 divides by the 5 closes in the window, ddof=1 divides by 4 and is what pandas uses if you say nothing. Bollinger bands are normally drawn with ddof=0, so I pass it every time.

print(round(close.iloc[0:5].std(), 4))          # -> 1.5811    <- pandas default, ddof=1
print(round(close.iloc[0:5].std(ddof=0), 4))    # -> 1.4142    <- what I use
1.5811
1.4142

On a 20 day window the ddof=1 figure is 2.6% larger, which moves the bands out slightly but does not change their shape.

Step 3. Bands on AAPL

prices.csv sits next to this lesson and holds simulated data: six years of daily closes for four tickers. I take AAPL, put the dates on the index, and use the standard 20 day window.

prices = pd.read_csv("prices.csv", parse_dates=["Date"])

aapl  = prices[prices["Ticker"] == "AAPL"].set_index("Date").sort_index()
close = aapl["Close"]

n     = 20
mid   = close.rolling(n).mean()
sd    = close.rolling(n).std(ddof=0)
upper = mid + 2 * sd
lower = mid - 2 * sd

bands = pd.DataFrame({"close": close, "lower": lower, "mid": mid, "upper": upper})

print(bands.dropna().head(5).round(2))
# ->             close  lower    mid  upper
# -> Date
# -> 2020-01-28  72.82  72.03  76.04  80.05
# -> 2020-01-29  75.19  72.04  76.05  80.05
# -> 2020-01-30  74.33  71.87  75.92  79.97
# -> 2020-01-31  74.69  71.76  75.78  79.81
# -> 2020-02-03  75.05  71.76  75.62  79.49
            close  lower    mid  upper
Date                                  
2020-01-28  72.82  72.03  76.04  80.05
2020-01-29  75.19  72.04  76.05  80.05
2020-01-30  74.33  71.87  75.92  79.97
2020-01-31  74.69  71.76  75.78  79.81
2020-02-03  75.05  71.76  75.62  79.49

The gap between the bands is four standard deviations, so it stretches when the last 20 closes are spread out and closes up when they are not.

width = upper - lower

print(round(width.min(), 2))     # -> 2.87
print(round(width.max(), 2))     # -> 43.23
print(round(width.mean(), 2))    # -> 12.78
2.87
43.23
12.78

Step 4. Where the close sits in the band

%B rescales the close so that the lower band is 0 and the upper band is 1: (close - lower) / (upper - lower). It is 0.5 when the close is on the moving average, above 1 when the close is above the upper band, and below 0 when it is under the lower band.

pctb = (close - lower) / (upper - lower)

print(pctb.dropna().head(5).round(4))
# -> Date
# -> 2020-01-28    0.0982
# -> 2020-01-29    0.3929
# -> 2020-01-30    0.3041
# -> 2020-01-31    0.3641
# -> 2020-02-03    0.4261
# -> Name: Close, dtype: float64
Date
2020-01-28    0.0982
2020-01-29    0.3929
2020-01-30    0.3041
2020-01-31    0.3641
2020-02-03    0.4261
Name: Close, dtype: float64

The first value repeats the first row of Step 3: a close of 72.82 in a band running from 72.03 to 80.05 is 9.82% of the way up.

print(round((72.82 - 72.03) / (80.05 - 72.03), 4))    # -> 0.0985
0.0985

Counting the days outside the bands is a comparison on pctb.

valid = pctb.dropna()

above = (valid > 1).sum()
below = (valid < 0).sum()

print(len(valid))                                  # -> 1547
print(above)                                       # -> 134
print(below)                                       # -> 59
print(f"{(above + below) / len(valid):.2%}")       # -> 12.48%
1547
134
59
12.48%

AAPL closed outside its bands on 193 of 1547 days, 134 of them above and 59 below. The average %B is 0.55, and the extremes over the whole sample are -0.23 and 1.28.

print(round(valid.mean(), 4))    # -> 0.5515
print(round(valid.min(), 4))     # -> -0.2317
print(round(valid.max(), 4))     # -> 1.2826
0.5515
-0.2317
1.2826

That is the indicator. Turning it into a position is Lesson 29.

Your turn

Build 20 day Bollinger bands on MSFT from prices.csv and count the days its close finished outside them. Does MSFT sit outside more or less often than AAPL?

import pandas as pd

prices = pd.read_csv("prices.csv", parse_dates=["Date"])

msft  = prices[prices["Ticker"] == "MSFT"].set_index("Date").sort_index()
close = msft["Close"]

mid   = close.rolling(20).mean()
sd    = close.rolling(20).std(ddof=0)
upper = mid + 2 * sd
lower = mid - 2 * sd

pctb  = ((close - lower) / (upper - lower)).dropna()

above = (pctb > 1).sum()
below = (pctb < 0).sum()

print(len(pctb))                                 # -> 1547
print(above, below)                              # -> 158 64
print(f"{(above + below) / len(pctb):.2%}")      # -> 14.35%

MSFT finished outside its bands on 14.35% of days against 12.48% for AAPL.