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)
windowintWindow 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
| Value | Meaning |
|---|---|
z > 2 | Price above upper Bollinger band (classic: overbought) |
z under -2 | Price below lower Bollinger band (classic: oversold) |
-1 to 1 (z near zero) | Price within normal range |
| ` | z |
Zscore vs BollingerZ
The crucial distinction:
| Feature | Input | What 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?” |
Zscorecaptures instantaneous velocity anomalies (today’s return is unusual)BollingerZcaptures 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