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.

Next: 105 — Stochastic Oscillator