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
from horizon.quant import BollingerMeanRev
Signature
BollingerMeanRev(
window: int = 20,
entry_z: float = 2.0,
vol_window: int = 60,
edge_bps: float = 40.0,
horizon_days: int = 3,
)
windowintentry_zfloatvol_windowintedge_bpsfloathorizon_daysintFeatures it uses
features = {
"z": BollingerZ(window=window),
"vol": RealizedVol(window=vol_window),
}
See BollingerZ for the feature math.
Signal logic
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
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
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:
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:
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.