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
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
Zscore, RealizedVol, Return, BollingerZ, MovingAverageCross, RSI. Each is computed once per tick.Each strategy evaluates
TSMomentum might fire a long signal while BollingerMeanRev fires a short: both get deposited into the signal store.Kelly sees the combined signal list
Execution dispatches per asset class
EquityExecutor which converts USD targets to share quantities.Risk checks apply uniformly
Attribution
Every signal is tagged with its strategy’s name. Every fill carries that tag forward. So you can ask:
# 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:
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], ...)