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).

Next