BollingerZ

Distance from rolling mean in stddev units, the Bollinger band indicator

BollingerZ computes the distance from the current price to its rolling mean, normalized by the rolling standard deviation. It’s the core Bollinger band indicator: when |BollingerZ| > 2, the price is outside the standard ±2σ Bollinger bands.

Import

python
from horizon.features import BollingerZ

Signature

python
BollingerZ(window: int = 20, market: str | None = None)
windowint
Window for computing rolling mean and stddev of prices.

Math

window_prices = last `window` prices
mean          = Σ window_prices / window
var           = Σ (p - mean)² / (window - 1)
std           = √var
BollingerZ    = (current_price - mean) / std

Note: this uses price levels, not returns. That makes it different from Zscore (which uses returns).

Behavior

python
def compute(self, market_id, history, feeds):
    prices = history.last_n_prices(self.window)
    if len(prices) < self.window:
        return float("nan")
    mean = sum(prices) / len(prices)
    var = sum((p - mean) ** 2 for p in prices) / max(len(prices) - 1, 1)
    std = math.sqrt(var)
    if std == 0:
        return 0.0
    return (prices[-1] - mean) / std

Interpretation

ValueMeaning
z > 2Price above upper Bollinger band (classic: overbought)
z under -2Price below lower Bollinger band (classic: oversold)
-1 to 1 (z near zero)Price within normal range
`z

Zscore vs BollingerZ

The crucial distinction:

FeatureInputWhat it measures
Zscore(window)Log returns“How unusual is the latest 1-bar move?”
BollingerZ(window)Price levels“How far is the current price level from its rolling average?”
  • Zscore captures instantaneous velocity anomalies (today’s return is unusual)
  • BollingerZ captures position anomalies (price has drifted away from its base)

A market that’s been grinding down for 20 days might have Zscore ≈ 0 (today’s return is normal) but BollingerZ ≈ -2 (price is far below the mean). The two tell different stories.

Use cases

Classic Bollinger mean reversion

python
from horizon import Strategy, Signal
from horizon.asset_classes import Equity
from horizon.features import BollingerZ, RealizedVol

class BollingerMR(Strategy):
    asset_classes = [Equity]
    features = {
        "z": BollingerZ(window=20),
        "vol": RealizedVol(window=60),
    }

    def evaluate(self, f, universe):
        signals = []
        for m in universe:
            z = f.z[m.id]
            if z > 2.0:
                signals.append(Signal.decrease(m, edge_bps=40, horizon="3d"))
            elif z < -2.0:
                signals.append(Signal.increase(m, edge_bps=40, horizon="3d"))
        return signals

This is what the built-in BollingerMeanRev does.

Combine with trend filter

python
features = {
    "bz": BollingerZ(window=20),
    "sma200": SMA(window=200),
    "price": Price(),
}

def evaluate(self, f, universe):
    # Mean-revert only when price is near its long-term average
    return [
        Signal.increase(m, edge_bps=40)
        for m in universe
        if f.bz[m.id] < -2.0                                      # oversold
        and abs(f.price[m.id] / f.sma200[m.id] - 1) < 0.10        # within 10% of 200-SMA
    ]

This avoids mean-reverting into a strong downtrend.

Pitfalls

Tests

python
def test_zero_at_mean(self, store_with_history: FeatureStore) -> None:
    bz = BollingerZ(window=20)
    ns = store_with_history.compute({"b": bz}, ["TEST"], {})
    # Flat prices → std is 0 → returns 0.0
    assert ns.b["TEST"] == 0.0

Next