DEV Community

Alper San
Alper San

Posted on Originally published at sifting.io

Bollinger Bands in Python: the pandas default that changes your chart

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

The final row is:

close            104.0000
middle           102.0000
upper            104.8284
lower             99.1716
bandwidth_pct      5.5459
percent_b          0.8536
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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],
)
Enter fullscreen mode Exit fullscreen mode

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)