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:
| Aspect | Listed | Ensembled |
|---|---|---|
| Signal attribution | Each strategy tags its own signals | All signals tagged with ensemble name |
| Edge weighting | None; each strategy’s edge_bps goes through as-is | Each signal’s edge multiplied by child weight |
| Portfolio optimizer | Sees them as separate signal sources | Sees them as one unified source |
| Turnover | Each strategy independently fires signals | Same |
| Replacement semantics | One strategy’s signal replaces its own | One 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] | NonePer-child multipliers applied to `expected_edge_bps` on emitted signals. Defaults to all-1.0 if not specified. Length must match `strategies`.
namestrThe 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.