Bollinger Bands
Bollinger Bands wrap a moving average with volatility-scaled bands — directly answering "is price stretched relative to its own recent volatility?"
def bollinger_bands(series: pd.Series, period: int = 20, num_std: float = 2.0) -> pd.DataFrame:
middle = series.rolling(period).mean()
std = series.rolling(period).std()
upper = middle + num_std * std
lower = middle - num_std * std
return pd.DataFrame({"middle": middle, "upper": upper, "lower": lower}, index=series.index)
%B — where price sits within the bands, normalized 0-1
def percent_b(price: pd.Series, bands: pd.DataFrame) -> pd.Series:
return (price - bands["lower"]) / (bands["upper"] - bands["lower"])
%B > 1 means price is above the upper band, %B < 0 means below the lower band, %B = 0.5 means exactly at the middle — a normalized way to compare "band position" across instruments with very different absolute price/volatility.
Bandwidth — a volatility regime indicator in its own right
def bollinger_bandwidth(bands: pd.DataFrame) -> pd.Series:
return (bands["upper"] - bands["lower"]) / bands["middle"]
A "squeeze" (bandwidth compressing to a multi-period low) often precedes a volatility expansion/breakout — a widely used setup: wait for a squeeze, then trade the direction of the eventual breakout, rather than trading band touches directly.
The common misreading: touching a band is NOT automatically a reversal signal
# NAIVE — often wrong in trending markets
signal = pd.Series(0, index=price.index)
signal[price > bands["upper"]] = -1 # "overbought, sell" — dangerous in a strong uptrend
signal[price < bands["lower"]] = 1
In a strong trend, price can walk along the upper (or lower) band for extended periods — treating every touch as a reversal signal produces repeated losing trades fighting the trend. A more robust pattern combines band position with a trend filter (e.g. chapter 106's ADX, or simply the direction of a slower moving average) before treating a band touch as a mean-reversion signal rather than trend continuation.
def bollinger_mean_reversion_signal(price, bands, trend_ema, adx, adx_threshold=20):
signal = pd.Series(0, index=price.index)
ranging = adx < adx_threshold # only take mean-reversion signals when NOT strongly trending
signal[(price > bands["upper"]) & ranging] = -1
signal[(price < bands["lower"]) & ranging] = 1
return signal
This is a concrete instance of chapter 114-115's confluence logic — Bollinger Bands alone answer "is price stretched," ADX answers "is that stretch likely to mean-revert or keep trending" — combining them is more robust than either alone.