Ensemble

Combine multiple strategies under one name with per-child weights

Ensemble lets you wrap multiple strategies as a single Strategy instance. It aggregates their features, evaluates each child, and returns the combined signal list with per-child weight scaling and proper attribution.

Why use an ensemble vs listing strategies?

python
# Option A: list them
hz.run(
    strategies=[
        TSMomentum(lookback=20),
        BollingerMeanRev(window=20),
    ],
    ...
)

# Option B: ensemble them
hz.run(
    strategies=[
        Ensemble(
            strategies=[
                TSMomentum(lookback=20),
                BollingerMeanRev(window=20),
            ],
            weights=[0.6, 0.4],
            name="trend_plus_mr",
        ),
    ],
    ...
)

Both work, but they behave differently:

AspectListedEnsembled
Signal attributionEach strategy tags its own signalsAll signals tagged with ensemble name
Edge weightingNone; each strategy’s edge_bps goes through as-isEach signal’s edge multiplied by child weight
Portfolio optimizerSees them as separate signal sourcesSees them as one unified source
TurnoverEach strategy independently fires signalsSame
Replacement semanticsOne strategy’s signal replaces its ownOne strategy’s signal doesn’t replace another’s

Import

python
from horizon.quant import Ensemble

Signature

python
Ensemble(
    strategies: list[Strategy],
    weights: list[float] | None = None,
    name: str = "ensemble",
)
strategieslist[Strategy]
A list of **instantiated** (not subclass) strategy objects. Each must implement the standard `Strategy` protocol.
weightslist[float] | None
Per-child multipliers applied to `expected_edge_bps` on emitted signals. Defaults to all-1.0 if not specified. Length must match `strategies`.
namestr
The ensemble's name, used as its `strategy_id` tag on all produced signals.

How it works

Merge child features

Features from all children are merged into the ensemble's feature dict. Name collisions are avoided by prefixing each feature with the child's name:
python
# If TSMomentum has features={"r": Return(20), "vol": RealizedVol(60)}
# And BollingerMeanRev has features={"z": BollingerZ(20), "vol": RealizedVol(60)}
# The ensemble's features become:
# {
#     "TSMomentum_20__r": Return(20),
#     "TSMomentum_20__vol": RealizedVol(60),
#     "BollingerMeanRev_20__z": BollingerZ(20),
#     "BollingerMeanRev_20__vol": RealizedVol(60),
# }

Evaluate each child

On each tick, the ensemble iterates through children. Each child sees a **view** of the feature namespace that exposes its features under their original names:
python
for child, weight in zip(self.children, self.weights):
    child_ns = _child_view(f, child_name, child.features)
    child_signals = child.evaluate(child_ns, universe)
    ...

Scale and tag signals

Each child's signals get: - `expected_edge_bps` multiplied by the child's weight - `strategy_id` set to the child's name (or the ensemble name if the child didn't set one) - `reason` prefixed with `[child_name]` for attribution
python
tagged = replace(sig,
    expected_edge_bps=sig.expected_edge_bps * weight,
    strategy_id=sig.strategy_id or child_name,
    reason=f"[{child_name}] {sig.reason}",
)

Return combined list

All tagged signals from all children are returned as a single list. The portfolio optimizer then sees the combined signal stream.

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,
    Ensemble,
    MovingAverageCrossStrategy,
    RSIMeanRev,
    TSMomentum,
)
from horizon.risk import RiskProfile

strategy = Ensemble(
    strategies=[
        TSMomentum(lookback=20, edge_bps=60),
        BollingerMeanRev(window=20, entry_z=2.0, edge_bps=50),
        MovingAverageCrossStrategy(fast=10, slow=30, edge_bps=30),
        RSIMeanRev(window=14, edge_bps=35),
    ],
    weights=[0.30, 0.30, 0.20, 0.20],
    name="four_classic",
)

result = hz.run(
    mode="backtest",
    strategies=[strategy],
    asset_classes=[Equity],
    universe=StaticUniverse([
        Market(id=t, asset_class=AssetClass.Equity)
        for t in ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"]
    ]),
    portfolio=KellyOptimizer(kelly_fraction=0.25, max_gross_leverage=1.5),
    risk=RiskProfile.moderate(),
    data_source=SyntheticGBM(
        ["AAPL", "MSFT", "NVDA", "GOOGL", "AMZN"],
        n_bars=252,
        seed=42,
    ),
    backtest=hz.BacktestConfig(initial_cash_usd=100_000),
)
print(f"Sharpe: {result.sharpe:+.3f}, Max DD: {result.max_drawdown:.2%}")

When to use

Regime diversification Mix strategies that work in different regimes (trend + mean reversion). The weights let you control how much each strategy contributes.
Style blending Combine strategies with different signal styles (technical + ML + event-driven) under one name.
A/B testing strategy composition Build multiple ensembles with different weights and run them side by side to measure which composition wins.
Attribution clarity When you want all signals attributed to a single "strategy" for reporting, the ensemble's name becomes the attribution key.

Weight selection

Asset class inference

The ensemble’s asset_classes is inferred from the union of its children:

python
ens = Ensemble(
    strategies=[
        TSMomentum(),                                 # Equity
        BollingerMeanRev(),                            # Equity
    ],
)
# ens.asset_classes == [Equity]

If you mixed, say, an equity strategy with a prediction market strategy, the ensemble would have [Equity, Prediction].

Validation

Ensemble construction validates at initialization:

python
Ensemble(strategies=[])
# ValueError: Ensemble needs at least one child strategy

Ensemble(strategies=[TSMomentum()], weights=[0.5, 0.5])
# ValueError: weights length must match strategies length

Fails loudly: errors can’t propagate into runtime surprises.

Pitfalls

Source

horizon/quant/ensemble.py. ~80 lines.

Next