Equity Mean Reversion

Bollinger-based mean reversion with Kelly sizing + moderate risk

A complete working example: mean-reverting strategy on a universe of US tech equities.

The full file

python
"""examples/equity_mean_reversion.py"""

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, StopLoss


def main():
    universe = StaticUniverse([
        Market(id=t, asset_class=AssetClass.Equity)
        for t in ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]
    ])

    data = SyntheticGBM(
        market_ids=["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"],
        n_bars=252,
        mu=0.10,
        sigma=0.22,
        seed=42,
    )

    result = hz.run(
        mode="backtest",
        strategies=[
            BollingerMeanRev(window=20, entry_z=2.0, edge_bps=60, horizon_days=3),
        ],
        asset_classes=[Equity],
        universe=universe,
        portfolio=KellyOptimizer(
            kelly_fraction=0.25,
            max_gross_leverage=1.0,
            transaction_cost_bps=5,
        ),
        risk=RiskProfile.moderate().override(
            stop_loss=StopLoss(per_position_pct=0.08, trailing_pct=0.05),
        ),
        data_source=data,
        backtest=hz.BacktestConfig(initial_cash_usd=100_000),
    )

    print("=" * 60)
    print("EQUITY MEAN REVERSION BACKTEST")
    print("=" * 60)
    print(f"  Ticks:        {len(result.equity_curve)}")
    print(f"  Trades:       {result.n_trades}")
    print(f"  Start equity: ${result.equity_curve[0][1]:,.2f}")
    print(f"  End equity:   ${result.equity_curve[-1][1]:,.2f}")
    print(f"  Peak equity:  ${max(e for _, e in result.equity_curve):,.2f}")
    print(f"  Total return: {result.total_return:+.2%}")
    print(f"  Sharpe:       {result.sharpe:+.3f}")
    print(f"  Sortino:      {result.sortino:+.3f}")
    print(f"  Max drawdown: {result.max_drawdown:.2%}")


if __name__ == "__main__":
    main()

Run it

bash
PYTHONPATH=. python3 examples/equity_mean_reversion.py

What it does

Universe

5 US tech tickers wrapped in Market objects with asset_class=Equity.

Data

SyntheticGBM generates 252 daily bars of GBM for each ticker with mu=10%, sigma=22%, seeded at 42. The same seed always produces the same price paths.

Strategy

BollingerMeanRev reads BollingerZ(window=20): distance from rolling mean in stddev units. When |z| > 2, emits an opposite-direction signal (long when below, short when above).

Portfolio

KellyOptimizer with quarter Kelly, 1.0 gross leverage, 5 bps transaction cost assumption. Converts each signal to USD notional using edge / vol² math.

Risk

RiskProfile.moderate() gives the standard 10% stop loss, 5/10/20% stacked drawdown guards, and kill switch at 25% drawdown. We override the stop to 8% per position with 5% trailing.

Run

hz.run(mode="backtest", ...) iterates the historical bars, runs the full pipeline each tick, produces a BacktestResult with equity curve, trades, and summary metrics.

Expected output

============================================================
EQUITY MEAN REVERSION BACKTEST
============================================================
  Ticks:        252
  Trades:       ~300 - 600
  Start equity: $100,000.00
  End equity:   $95,000 - $100,000
  Peak equity:  ~$100,000
  Total return: ~0% (GBM has no real MR edge)
  Sharpe:       ~-0.5 to 0
  Sortino:      ~-0.5 to 0
  Max drawdown: ~5% - 15%

Numbers may vary slightly by seed. Pure GBM has no exploitable mean reversion, so the strategy is expected to churn transaction costs and show negative-to-neutral Sharpe. The risk enforcement is the observable win: stops + drawdown guards bound max DD.

Variations

More aggressive sizing

python
portfolio=KellyOptimizer(
    kelly_fraction=0.5,      # half Kelly. more aggressive
    max_gross_leverage=1.5,  # up to 1.5x gross
)

Expect wider swings in both directions.

Tighter risk

python
risk=RiskConfig(
    max_gross_leverage=1.0,
    stop_loss=StopLoss(per_position_pct=0.03),  # 3% stops
    drawdown=[
        DrawdownGuard(daily_pct=0.03, action=DrawdownAction.HaltNew),
        DrawdownGuard(weekly_pct=0.06, action=DrawdownAction.Flatten),
    ],
)

Expect lower Sharpe (more stops firing) but bounded drawdown.

Regime-shift data

python
from horizon.data import SyntheticRegimes

data = SyntheticRegimes(
    market_ids=["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"],
    n_bars=300,
    regimes=[
        (0.4, 0.20, 0.15),   # uptrend
        (0.3, 0.0, 0.30),    # chop
        (0.3, -0.30, 0.40),  # crash
    ],
    seed=42,
)

Tests the strategy across multiple market environments.

Next