Bollinger Bands
A moving average with bands at a fixed number of standard deviations.
bollinger_bands(prices, period: int = 20, std_dev: float = 2.0)Bollinger Bands are a moving average with an envelope drawn at some multiple of the rolling standard deviation. The bands widen when recent returns have been dispersed and narrow when they have not, so the visual is a channel that breathes with volatility.
The common reading — price at the upper band is overbought — is the weakest use. Under a normal distribution roughly 5% of observations fall outside two standard deviations, and returns are not normally distributed, so touches are both more frequent and more clustered than the statistics imply. The stronger use is the width itself: a sustained narrowing is a measurable statement that dispersion has fallen, and that is information regardless of direction.
The standard deviation is computed on the same window as the mean, so both parameters move together. Changing the period changes the band width even at a fixed multiple, which surprises people tuning one and not the other.
Band width has one property price does not: it is bounded below by zero and it tends to revert. Long quiet stretches compress it and then it expands again, which is the sequence people call a squeeze. That makes width a more tractable series to model than the price it wraps, because you can ask whether it sits in the bottom decile of its own recent history — a question with an answer, where asking whether price is high has none without a reference.
Where it misleads
- Treating a band touch as a mean-reversion signal in a trending market means fading the trend at every step of it.
- Standard deviation assumes a distribution that returns do not have. Tails are fatter, so "2 sigma" is not the 5% event the number implies.
- Both parameters interact. Tuning period and std_dev independently on one instrument is a two-dimensional overfit.
In code
from quantide import fetch_data, bollinger_bands
data = fetch_data("EURUSD=X", "2018-01-01", "2025-01-01")
upper, middle, lower = bollinger_bands(data["Close"], period=20, std_dev=2.0)
width = (upper - lower) / middle
squeeze = width < width.rolling(120).quantile(0.1)