Multi-Strategy Backtest

Run multiple strategies side-by-side with unified portfolio optimization

One of the core benefits of the layered architecture: multiple strategies share the portfolio optimizer, which sees all signals across all strategies and allocates capital across them with correlation awareness.

The example

python
import horizon as hz
from horizon.asset_classes import AssetClass, Equity
from horizon.data import SyntheticRegimes
from horizon.discovery import StaticUniverse
from horizon.discovery.base import Market
from horizon.portfolio import KellyOptimizer
from horizon.quant import (
    BollingerMeanRev,
    MovingAverageCrossStrategy,
    RSIMeanRev,
    TSMomentum,
)
from horizon.risk import (
    DrawdownGuard,
    OrderRisk,
    RiskConfig,
    StopLoss,
)
from horizon.risk.drawdown import DrawdownAction


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

    # Regime-switching data stresses all strategy types
    data = SyntheticRegimes(
        market_ids=tickers,
        n_bars=300,
        regimes=[
            (0.35, 0.20, 0.15),   # trending up
            (0.30, 0.0, 0.30),    # chop
            (0.35, -0.25, 0.35),  # declining
        ],
        seed=2025,
    )

    # 4 different strategies. momentum + mean reversion + trend
    strategies = [
        TSMomentum(lookback=20, edge_bps=60, horizon_days=5),
        BollingerMeanRev(window=20, entry_z=2.0, edge_bps=50, horizon_days=3),
        MovingAverageCrossStrategy(fast=10, slow=30, edge_bps=40, horizon_days=5),
        RSIMeanRev(window=14, oversold=30, overbought=70, edge_bps=35, horizon_days=3),
    ]

    result = hz.run(
        mode="backtest",
        strategies=strategies,
        asset_classes=[Equity],
        universe=universe,
        portfolio=KellyOptimizer(
            kelly_fraction=0.20,              # diluted for multi-strategy
            max_gross_leverage=1.5,
            transaction_cost_bps=5,
        ),
        risk=RiskConfig(
            per_order=OrderRisk(max_order_notional_usd=30_000, rate_limit_per_sec=20),
            stop_loss=StopLoss(per_position_pct=0.10, trailing_pct=0.05),
            drawdown=[
                DrawdownGuard(daily_pct=0.05, action=DrawdownAction.HaltNew),
                DrawdownGuard(weekly_pct=0.10, action=DrawdownAction.ReduceHalf),
                DrawdownGuard(monthly_pct=0.20, action=DrawdownAction.Flatten),
            ],
            max_gross_leverage=1.5,
        ),
        data_source=data,
        backtest=hz.BacktestConfig(initial_cash_usd=100_000),
    )

    print("=" * 60)
    print("MULTI-STRATEGY BACKTEST")
    print(f"  Strategies: {len(strategies)}")
    print(f"  Universe:   {len(tickers)} tech equities")
    print(f"  Data:       {data.n_bars} bars, 3 regime shifts")
    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"  Total return: {result.total_return:+.2%}")
    print(f"  Sharpe:       {result.sharpe:+.3f}")
    print(f"  Max drawdown: {result.max_drawdown:.2%}")

    # Per-strategy trade attribution (via the ledger)
    strategy_ids = set(t.strategy_id for t in result.trades if t.strategy_id)
    print()
    print("Per-strategy trade counts:")
    for sid in sorted(strategy_ids):
        count = sum(1 for t in result.trades if t.strategy_id == sid)
        print(f"  {sid}: {count}")


if __name__ == "__main__":
    main()

What’s happening each tick

Features computed once

The feature store sees the deduplicated union of all strategies' requirements: Zscore, RealizedVol, Return, BollingerZ, MovingAverageCross, RSI. Each is computed once per tick.

Each strategy evaluates

Each runs on its own feature view. Some tick, TSMomentum might fire a long signal while BollingerMeanRev fires a short: both get deposited into the signal store.

Kelly sees the combined signal list

The optimizer handles conflict resolution naturally: if two signals cancel each other, the net target notional is small. If they agree, the net is large.

Execution dispatches per asset class

All signals are equities here, so everything goes through EquityExecutor which converts USD targets to share quantities.

Risk checks apply uniformly

Orders from all strategies pass through the same per-order pipeline and the same watchdog. A stop loss on a position doesn't care which strategy opened it.

Attribution

Every signal is tagged with its strategy’s name. Every fill carries that tag forward. So you can ask:

python
# How many trades did each strategy contribute?
from collections import Counter

counts = Counter(t.strategy_id for t in result.trades if t.strategy_id)
for sid, n in counts.most_common():
    print(f"{sid}: {n} trades")

And you can extend this to per-strategy P&L attribution by querying the PositionLedger directly (see State concept).

Why dilute kelly_fraction for multi-strategy?

Multiple strategies each produce signals. If each had kelly_fraction=0.5, the combined book could be 2x oversized. Using kelly_fraction=0.2 keeps the total book reasonable without explicit coordination.

Alternative: use a per-strategy weighted Ensemble so the portfolio sees one combined strategy:

python
from horizon.quant import Ensemble

ensemble = Ensemble(
    strategies=[
        TSMomentum(lookback=20),
        BollingerMeanRev(window=20),
    ],
    weights=[0.6, 0.4],
    name="trend_plus_mr",
)

hz.run(strategies=[ensemble], ...)

Next