SignalEnsemble
IC-weighted signal combination with redundancy detection and staleness filtering
SignalEnsemble is the v1 module that combines multiple signal sources into a single composite signal with dynamic IC-based weighting and a redundancy penalty when sources are too correlated. It’s more sophisticated than Horizon’s built-in Ensemble: this is research-grade combination logic.
Import
python
from horizon.fund._signal_ensemble import (
SignalEnsemble,
Signal as EnsembleSignal, # different from horizon.Signal
SignalQuality,
EnsembleOutput,
)
What it does
Submit signals
Each source calls
ensemble.submit(signal) whenever it produces a new signal for a market.Staleness filter
Signals older than
stale_threshold_secs are dropped. Only fresh opinions contribute.Per-source IC
For each named source,
SignalEnsemble tracks historical predictions vs realized outcomes and computes a rolling information coefficient. Sources with higher IC get higher weight.Redundancy penalty
If two sources produce highly correlated signals, they're penalized: the combination weights down each of them. This prevents "double counting" when multiple sources express the same underlying view.
Combine
Weighted sum of signal values, weighted sum of confidences, with the redundancy penalty applied. Output is a single
EnsembleOutput with the combined signal, the combined confidence, the per-source weights used, and the redundancy penalty value.API
python
class SignalEnsemble:
def __init__(
self,
stale_threshold_secs: float = 300.0,
min_signals: int = 2,
lookback: int = 200,
) -> None:
...
def submit(self, signal: Signal) -> None:
"""Submit a new signal observation."""
def combine(self, market_id: str) -> EnsembleOutput | None:
"""Combine all fresh signals for a market. Returns None if too few signals."""
def record_outcome(self, market_id: str, signal_name: str, outcome: float) -> None:
"""Record realized outcome for IC tracking."""
def get_quality(self, signal_name: str) -> SignalQuality | None:
"""Get IC, hit rate, decay half-life for a named source."""
Data types
python
@dataclass(frozen=True)
class Signal:
name: str # source name
value: float # [-1, +1]
confidence: float # [0, 1]
timestamp: float
source: str
market_id: str
@dataclass(frozen=True)
class SignalQuality:
name: str
ic: float # rolling information coefficient
hit_rate: float # fraction of correct direction calls
decay_half_life: float # how fast the signal decays
n_observations: int
@dataclass(frozen=True)
class EnsembleOutput:
combined_signal: float # weighted combination [-1, +1]
combined_confidence: float
weights_used: dict[str, float] # per-source weights
redundancy_penalty: float # [0, 1], 0 = no redundancy
n_signals: int
Usage
python
import time
from horizon.fund._signal_ensemble import SignalEnsemble, Signal
ensemble = SignalEnsemble(
stale_threshold_secs=300, # 5 minutes
min_signals=2, # need at least 2 fresh signals
lookback=200, # rolling history per source
)
# Submit signals from multiple sources
ensemble.submit(Signal(
name="model_a",
value=0.6, # bullish
confidence=0.8,
timestamp=time.time(),
source="ml_ensemble",
market_id="btc-above-100k",
))
ensemble.submit(Signal(
name="model_b",
value=0.4,
confidence=0.7,
timestamp=time.time(),
source="stat_arb",
market_id="btc-above-100k",
))
# Combine
output = ensemble.combine("btc-above-100k")
if output is not None:
print(f"Combined: {output.combined_signal:+.3f}")
print(f"Confidence: {output.combined_confidence:.3f}")
print(f"Weights: {output.weights_used}")
print(f"Redundancy penalty: {output.redundancy_penalty:.3f}")
# Later, record the outcome for IC recalibration
ensemble.record_outcome("btc-above-100k", "model_a", outcome=1.0) # YES won
ensemble.record_outcome("btc-above-100k", "model_b", outcome=1.0)
# Check quality
quality_a = ensemble.get_quality("model_a")
print(f"IC: {quality_a.ic:+.3f}, hit rate: {quality_a.hit_rate:.1%}")
Wrapping into a Horizon strategy
SignalEnsemble wraps naturally as a meta-strategy that consumes multiple Horizon strategies’ outputs and produces a combined Horizon-style Signal:
python
from datetime import timedelta
import time
from horizon.fund._signal_ensemble import SignalEnsemble, Signal as V1Signal
from horizon import Strategy, Signal as HSignal, Direction
from horizon.asset_classes import Equity
from horizon.quant import TSMomentum, BollingerMeanRev
class ICWeightedEnsemble(Strategy):
asset_classes = [Equity]
features = {}
def __init__(self):
self.children = [
TSMomentum(lookback=20),
BollingerMeanRev(window=20, entry_z=2.0),
]
self.v1_ensemble = SignalEnsemble(
stale_threshold_secs=3600,
min_signals=1,
lookback=100,
)
# Merge features from children
self.features = {}
for child in self.children:
for k, v in child.features.items():
self.features[f"{child.name}__{k}"] = v
def evaluate(self, f, universe):
# Evaluate each child
for child in self.children:
child_ns = _extract_child_namespace(f, child)
for sig in child.evaluate(child_ns, universe):
self.v1_ensemble.submit(V1Signal(
name=child.name,
value=sig.direction.sign * sig.confidence,
confidence=sig.confidence,
timestamp=time.time(),
source=child.name,
market_id=sig.market_id,
))
# Combine per market
signals = []
for m in universe:
out = self.v1_ensemble.combine(m.id)
if out is None or abs(out.combined_signal) < 0.2:
continue
direction = Direction.Increase if out.combined_signal > 0 else Direction.Decrease
signals.append(HSignal(
market_id=m.id,
direction=direction,
confidence=out.combined_confidence,
expected_edge_bps=abs(out.combined_signal) * 100,
expected_expected_vol_bps=2000,
horizon=timedelta(days=3),
reason=f"ensemble redundancy={out.redundancy_penalty:.2f}",
))
return signals
When to use
Many signal sources If you have 5+ strategies or models producing opinions on the same markets, `SignalEnsemble` gives you correlation-aware combination.
Sources with different quality Not all sources are equally good. IC-based weighting automatically down-weights poor sources as their history accumulates.
Research. signal A/B testing Submit candidate signals alongside production ones. Track IC and hit rate over time to decide which to promote.
Pitfalls
Source
python/horizon/fund/_signal_ensemble.py. ~300 lines. Uses horizon._horizon.combine_signals, information_coefficient, and signal_half_life (Rust-backed).