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 AAA 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 pdimport numpy as npclose = pd.Series([100.0, 102.0, 101.0, 104.0, 103.0, 107.0, 106.0, 110.0]) # eight closes to check by handmid = close.rolling(5).mean() # middle line: mean of the last five closessd = close.rolling(5).std(ddof=0) # spread of those same five closesprint(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 # two standard deviations above the middle linelower = mid -2* sd # and two below itbands = pd.DataFrame({"lower": lower, "mid": mid, "upper": upper}) # the three lines togetherprint(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.
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=1print(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 AAA
prices.csv sits next to this lesson and holds simulated data: six years of daily closes for four tickers. I take AAA, put the dates on the index, and use the standard 20-day window.
prices = pd.read_csv("prices.csv", parse_dates=["Date"]) # Date read as dates, not textaapl = prices[prices["Ticker"] =="AAA"].set_index("Date").sort_index() # one ticker, oldest firstclose = aapl["Close"] # the closes alonen =20# the usual Bollinger windowmid = close.rolling(n).mean() # 20-day moving averagesd = close.rolling(n).std(ddof=0) # spread of those same 20 closesupper = mid +2* sd # two of those above the averagelower = mid -2* sd # and two belowbands = 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
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 # four standard deviations, band to bandprint(round(width.min(), 2)) # -> 2.87print(round(width.max(), 2)) # -> 43.23print(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.
Counting the days outside the bands is a comparison on pctb.
valid = pctb.dropna() # drops the first 19 days, which have no bandabove = (valid >1).sum() # days the close finished above the upper bandbelow = (valid <0).sum() # days it finished below the lower bandprint(len(valid)) # -> 1547print(above) # -> 134print(below) # -> 59print(f"{(above + below) /len(valid):.2%}") # -> 12.48%
1547
134
59
12.48%
AAA 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.
That is the indicator. Turning it into a position is Lesson 29.
Your turn
Build 20-day Bollinger bands on CCC from prices.csv and count the days its close finished outside them. Does CCC sit outside more or less often than AAA?
TipShow answer
import pandas as pdprices = pd.read_csv("prices.csv", parse_dates=["Date"])msft = prices[prices["Ticker"] =="CCC"].set_index("Date").sort_index() # CCC only, oldest firstclose = msft["Close"]mid = close.rolling(20).mean() # same three lines as Step 3sd = close.rolling(20).std(ddof=0) # ddof=0 againupper = mid +2* sdlower = mid -2* sdpctb = ((close - lower) / (upper - lower)).dropna() # where each close sits in its bandabove = (pctb >1).sum() # above the upper bandbelow = (pctb <0).sum() # below the lower bandprint(len(pctb)) # -> 1547print(above, below) # -> 158 64print(f"{(above + below) /len(pctb):.2%}") # -> 14.35%
CCC finished outside its bands on 14.35% of days against 12.48% for AAA.