Bollinger Bands are easy to calculate in pandas. A small default can still make your bands disagree with another chart: Rolling.std() uses ddof=1 unless you specify otherwise.
This tutorial uses a five-price example to check the arithmetic, then shows how to plot a rolling calculation and keep live price updates separate from completed candles. The code needs no API account or market-data subscription.
What are Bollinger Bands?
Bollinger Bands place an upper and lower line around a moving average. In the common 20-period, two-standard-deviation configuration, the middle line is the average of the latest 20 closes. The distance to each outer line is twice the standard deviation of those same closes.
The bands describe price dispersion within that window. They are not a forecast or a rule that price must return to the middle.
| Component | Calculation | Meaning on the chart |
|---|---|---|
| Middle band | Mean of the last N closes | The window's average close |
| Upper band | Middle + K × standard deviation | Upper boundary for that window |
| Lower band | Middle − K × standard deviation | Lower boundary for that window |
| BandWidth (%) | 100 × (upper − lower) / middle | Band width relative to the average price |
| %B | (close − lower) / (upper − lower) | Position of the close relative to the bands |
Here, N is the number of bars and K is the width multiplier. With hourly data, N = 20 means 20 observed hourly bars, not 20 calendar days. A weekend can make the elapsed time longer than 20 hours.
John Bollinger's rules for using the bands make two useful distinctions: 20 and 2 are defaults, and touching a band is not, by itself, a buy or sell signal. Nor should two standard deviations be read as a promise that 95% of future prices will remain inside the bands.
The pandas detail that changes the answer
Specify ddof rather than accepting the library default. This tutorial uses population standard deviation, ddof=0, so the variance divides by the number of observations in the window.
Pandas' Rolling.std defaults to ddof=1. That divides by N − 1 and makes the bands wider for the same closes. Neither setting should be hidden when comparing implementations.
Consider these five invented closing prices: 100, 102, 101, 103, 104. Their mean is 102. The squared deviations sum to 10, so population variance is 10 / 5 = 2 and standard deviation is √2.
| Quantity | Population calculation, ddof=0 | Sample calculation, ddof=1 |
|---|---|---|
| Mean | 102.0000 | 102.0000 |
| Standard deviation | 1.4142 | 1.5811 |
| Lower band, K = 2 | 99.1716 | 98.8377 |
| Upper band, K = 2 | 104.8284 | 105.1623 |
These are teaching values, not historical market observations. Five bars keep the arithmetic easy to check; the longer example below uses 20.
If another chart disagrees, first match its symbol, price source, timeframe, close field, moving-average type, window length and ddof convention. Changing the width multiplier until the lines look similar can hide the real difference.
Calculate Bollinger Bands with pandas
Install the packages for the examples in a Python virtual environment:
python -m pip install pandas numpy matplotlib
The function below accepts a numeric close series in chronological order. It deliberately requires a full window. The first 19 rows of a 20-bar calculation remain empty rather than pretending that three or four observations provide a 20-bar band.
import numpy as np
import pandas as pd
def bollinger_bands(close, window=20, multiplier=2.0):
if not isinstance(window, int) or window < 2:
raise ValueError("window must be an integer of at least 2")
if not np.isfinite(multiplier) or multiplier <= 0:
raise ValueError("multiplier must be finite and positive")
close = pd.to_numeric(close, errors="raise").astype(float)
if not np.isfinite(close).all() or (close <= 0).any():
raise ValueError("closes must be finite, positive numbers")
rolling = close.rolling(window, min_periods=window)
middle = rolling.mean()
deviation = rolling.std(ddof=0)
upper = middle + multiplier * deviation
lower = middle - multiplier * deviation
width = upper - lower
return pd.DataFrame({
"close": close,
"middle": middle,
"upper": upper,
"lower": lower,
"bandwidth_pct": 100 * width / middle,
"percent_b": (close - lower) / width.where(width != 0),
})
example = pd.Series([100, 102, 101, 103, 104])
print(bollinger_bands(example, window=5).iloc[-1].round(4))
The final row is:
close 104.0000
middle 102.0000
upper 104.8284
lower 99.1716
bandwidth_pct 5.5459
percent_b 0.8536
percent_b is a ratio, not a percentage. Zero is the lower band, one is the upper band, and values outside that range are possible. When every close in the window is identical, the band width is zero and %B is undefined. The code leaves it empty instead of dividing by zero or assigning an arbitrary position.
The function validates values, not the trading calendar. That distinction matters when loading real data.
Build a longer series for the chart
These 60 observations are invented test values, not historical prices or backtest results. They let you run the whole example without a provider account. Keep the calculation function above, then add:
time = pd.date_range("2026-01-05", periods=60, freq="h", tz="UTC")
close = pd.Series(
[100 + 0.1 * i + 2 * np.sin(i / 4) for i in range(60)],
index=time,
)
result = bollinger_bands(close)
For your own data, replace that series with one numeric closing price per completed bar, sorted oldest first, and a unique UTC timestamp index. Validate duplicates, nulls and unexpected gaps before calculating. Do not forward-fill missing bars merely to make the chart continuous.
An absent timestamp is not automatically an error: a scheduled market closure can also leave a gap. A 20-bar window means 20 observations, not necessarily 20 uninterrupted hours. If you need an open-session completeness check, compare timestamps with the relevant trading calendar.
Plot the result
import matplotlib.pyplot as plt
view = result.dropna(subset=["middle", "upper", "lower"])
fig, ax = plt.subplots(figsize=(10, 4))
ax.plot(view.index, view["close"], label="Invented close")
ax.plot(view.index, view["middle"], label="20-bar SMA")
ax.fill_between(view.index, view["lower"].to_numpy(),
view["upper"].to_numpy(), alpha=0.2, label="Bands (2 std)")
ax.set(title="Bollinger Bands: synthetic example, not market data",
xlabel="Time (UTC)", ylabel="Invented price")
ax.legend()
fig.tight_layout()
plt.show()
A live tick is not a new hourly candle
If you connect a WebSocket next, do not append every price update to the hourly close series. That turns a 20-bar indicator into a 20-update indicator. The lines may look plausible while answering a different question.
| Event | Correct state change |
|---|---|
| Historical bars loaded | Calculate the initial bands |
| Live price received | Update a separate price marker and its timestamp |
| New completed bar available | Add or reconcile the bar, then recalculate |
| Connection lost | Mark the live value stale; do not present it as current |
| Connection restored | Refresh history before resuming alerts |
Decide whether your UI shows fixed bands from completed bars or provisional bands for the open candle. Both can be useful, but they need different labels. Provisional calculations must replace the current bucket rather than append a new observation for each update.
Also distinguish freshness from connectivity. A subscription can send a cached price immediately after connection. Inspect the event timestamp and account for clock error; an open socket alone does not prove that the price is current.
Three tests worth keeping
constant = bollinger_bands(pd.Series([10.0] * 20)).iloc[-1]
assert constant["upper"] == constant["lower"] == 10
assert pd.isna(constant["percent_b"])
warmup = bollinger_bands(pd.Series(range(1, 26)))
assert warmup["middle"].iloc[:19].isna().all()
assert warmup["middle"].iloc[19:].notna().all()
original = pd.Series(range(1, 31), dtype=float)
changed = original.copy()
changed.iloc[-1] = 999
pd.testing.assert_frame_equal(
bollinger_bands(original).iloc[:-1],
bollinger_bands(changed).iloc[:-1],
)
The last test checks that changing the final observation does not rewrite earlier results. It does not validate an entire trading strategy. A band based on a final close is still unavailable before that close, and a backtest must respect that timing.
Before comparing two charts
Match the symbol, source, timezone, bar boundaries, close field, moving-average type, window length, multiplier and standard deviation convention. Then compare an identical window of raw closes. That is a much more useful debugging step than adjusting parameters until two pictures look alike.
This article is contributed by the SiftingIO team. The numerical examples are synthetic and the discussion is educational, not investment advice.
Top comments (0)