BollingerMeanRev

Mean reversion on price Bollinger z-score

BollingerMeanRev trades against price extremes. When a market’s price moves far from its rolling mean (measured in standard deviations, i.e. the Bollinger z-score), it bets on reversion to the mean.

Import

python
from horizon.quant import BollingerMeanRev

Signature

python
BollingerMeanRev(
    window: int = 20,
    entry_z: float = 2.0,
    vol_window: int = 60,
    edge_bps: float = 40.0,
    horizon_days: int = 3,
)
windowint
Lookback window for computing the rolling mean and standard deviation of prices.
entry_zfloat
Minimum absolute z-score to trigger a signal. `2.0` = 2 standard deviations from the mean.
vol_windowint
Number of bars for the `RealizedVol` feature used in signal vol estimation.
edge_bpsfloat
Base edge in bps. Scaled by `|z| / entry_z` so stronger deviations get proportionally larger edge.
horizon_daysint
Expected horizon for the mean-reversion to play out. Shorter than momentum horizons because mean reversion is typically faster.

Features it uses

python
features = {
    "z": BollingerZ(window=window),
    "vol": RealizedVol(window=vol_window),
}

See BollingerZ for the feature math.

Signal logic

python
def evaluate(self, f, universe):
    signals = []
    horizon = timedelta(days=self.horizon_days)
    for m in universe:
        z = f.z[m.id]
        vol = f.vol[m.id]
        if isnan(z):
            continue

        vol_bps = max((vol if not isnan(vol) else 0.20) * 10_000, 50)

        if z > self.entry_z:
            # Price is above the mean. bet on reversion DOWN
            signals.append(Signal(
                market_id=m.id,
                direction=Direction.Decrease,
                confidence=min(1.0, abs(z) / 4),
                expected_edge_bps=self.edge_bps * (abs(z) / self.entry_z),
                expected_expected_vol_bps=vol_bps,
                horizon=horizon,
                reason=f"bollinger z={z:.2f} (above mean)",
            ))
        elif z < -self.entry_z:
            # Price is below the mean. bet on reversion UP
            signals.append(Signal(
                market_id=m.id,
                direction=Direction.Increase,
                confidence=min(1.0, abs(z) / 4),
                expected_edge_bps=self.edge_bps * (abs(z) / self.entry_z),
                ...
            ))
    return signals

Key properties

  • Direction is opposite the price move: long when price is below mean, short when above
  • Edge scales with z-score magnitude: a 3σ deviation gets 1.5× the base edge
  • Confidence caps at 1.0 when |z| ≥ 4 (deep outliers)
  • No signal in the dead zone -entry_z < z < entry_z

Use it

python
import horizon as hz
from horizon.asset_classes import AssetClass, Equity
from horizon.data import SyntheticGBM
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.portfolio import KellyOptimizer
from horizon.quant import BollingerMeanRev
from horizon.risk import RiskProfile

result = hz.run(
    mode="backtest",
    strategies=[BollingerMeanRev(window=20, entry_z=2.0, edge_bps=50)],
    asset_classes=[Equity],
    universe=StaticUniverse([
        Market(id=t, asset_class=AssetClass.Equity)
        for t in ["AAPL", "MSFT", "NVDA"]
    ]),
    portfolio=KellyOptimizer(kelly_fraction=0.25, max_gross_leverage=1.0),
    risk=RiskProfile.moderate(),
    data_source=SyntheticGBM(
        ["AAPL", "MSFT", "NVDA"], n_bars=252, mu=0.08, sigma=0.20, seed=42,
    ),
    backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)

When to use

Range-bound markets Sideways, choppy price action. Prices oscillate around a level and repeatedly revert.
Pairs / spread trades Spread between cointegrated pairs. The z-score captures the relative mispricing.
Volatility grinding High-volatility but not trending: each spike provides a mean-reversion opportunity.

When NOT to use

Parameter tuning

Risk config companions

Mean-reversion strategies need tight stops. If the reversion doesn’t happen, the position keeps compounding losses:

python
from horizon.risk import RiskConfig, StopLoss

risk = RiskConfig(
    stop_loss=StopLoss(per_position_pct=0.04),  # tight. 4%
    max_gross_leverage=1.0,
)

A 4% stop is tighter than momentum because mean-reversion is inherently a “short-tail, long-tail” bet: most trades win small, a few lose big. Bound the tail.

Combining with other strategies

Mean reversion and momentum are natural complements:

python
strategies=[
    TSMomentum(lookback=20),
    BollingerMeanRev(window=20, entry_z=2.0),
]

In trending markets, TSMomentum wins. In chop, BollingerMeanRev wins. The portfolio optimizer sees both signal streams and reduces exposure when they disagree. effectively a regime-aware allocation.

Pitfalls

Source

horizon/quant/timeseries.py. ~40 lines.

Next