Skip to main content

Signal Combiner

Horizon includes a signal combination system for building composite alpha signals from multiple data sources. Core math is implemented in Rust for performance, with Python pipeline factories for ergonomic use in hz.run().
Signals are functions that extract a single float value (typically 0 to 1) from the current market context. The signal combiner merges multiple signals into a single composite score that can drive downstream quoting or sizing decisions.

Overview

Weighted Average

hz.combine_signals() with "weighted_avg" method for importance-weighted combination.

Built-in Extractors

5 ready-to-use signal extractors: price, spread, momentum, flow, and imbalance.

EMA Smoothing

hz.ema() for exponential smoothing of noisy signal values.

Time Decay

hz.decay_weight() for exponential half-life weighting of stale data.

Core Functions

hz.combine_signals

Combine multiple weighted signal values into a single score.
import horizon as hz

score = hz.combine_signals(
    signals=[(0.6, 1.0), (0.8, 2.0)],  # (value, weight) pairs
    method="weighted_avg",
)
print(f"Combined: {score:.4f}")  # 0.7333 (weighted average)
ParameterTypeDescription
signalslist[tuple[float, float]]List of (value, weight) pairs
methodstrCombination method: "weighted_avg", "rank", or "zscore"
Methods:
  • "weighted_avg" - Weighted mean: sum(v*w) / sum(w). Zero-weight signals are filtered out.
  • "rank" - Rank-based: converts values to percentile ranks, then weighted average.
  • "zscore" - Z-score normalization: standardizes values, then maps back to 0-1 via sigmoid.
Returns 0.0 for empty input.

hz.ema

Compute the exponential moving average of a series.
result = hz.ema(values=[1.0, 2.0, 3.0, 4.0], span=3)
print(f"EMA: {result:.4f}")  # 3.125
ParameterTypeDescription
valueslist[float]Time series values
spanintEMA span (alpha = 2/(span+1))
Returns 0.0 for empty input or zero span.

hz.zscore

Compute the z-score of a value given mean and standard deviation.
z = hz.zscore(value=10.0, mean=5.0, std=2.0)
print(f"Z-score: {z:.4f}")  # 2.5
Returns 0.0 if std is zero or value is NaN.

hz.decay_weight

Compute an exponential decay weight based on age and half-life.
w = hz.decay_weight(age_secs=60.0, half_life_secs=60.0)
print(f"Weight: {w:.4f}")  # 0.5 (one half-life = 50% weight)
ParameterTypeDescription
age_secsfloatAge of the data point in seconds
half_life_secsfloatHalf-life in seconds
Returns 0.0 for negative age or zero half-life.

Pipeline Factory: hz.signal_combiner

The signal_combiner() factory returns a pipeline function that evaluates and combines signals on each cycle.
import horizon as hz

combiner = hz.signal_combiner(
    signals=[
        hz.price_signal("btc", weight=0.4),
        hz.spread_signal("book", weight=0.3),
        hz.momentum_signal("btc", lookback=20, weight=0.2),
        hz.flow_signal("btc", window=50, weight=0.1),
    ],
    method="weighted_avg",
    smoothing=10,         # EMA smoothing span (0 = disabled)
    clip=(0.0, 1.0),     # Clamp output to this range
)

Parameters

ParameterTypeDefaultDescription
signalslist[Signal]requiredSignals to combine
methodstr"weighted_avg"Combination method
smoothingint0EMA smoothing span (0 = disabled)
cliptuple[float, float](0.0, 1.0)Output clamp range
If a signal raises an exception during evaluation, it is skipped (not propagated). This ensures robustness when feeds are temporarily unavailable.

Built-in Signal Extractors

Each extractor returns a Signal object with a name, fn, and weight.

hz.price_signal

Extracts the mid price from a feed. Returns the raw price (typically 0 to 1 for prediction markets).
sig = hz.price_signal("btc", weight=0.4)
Returns 0.0 if the feed is missing.

hz.spread_signal

Measures bid-ask tightness. Tighter spreads produce higher values (closer to 1.0).
sig = hz.spread_signal("book", weight=0.3)
Formula: 1.0 - min(spread / 0.20, 1.0) where spread = ask - bid.

hz.momentum_signal

Tracks rolling price momentum. Values above 0.5 indicate an uptrend, below 0.5 indicate a downtrend.
sig = hz.momentum_signal("btc", lookback=20, weight=0.2)
Maintains an internal price history. Returns 0.5 when insufficient data is available.

hz.flow_signal

Estimates buy/sell flow direction from price tick movements. Values above 0.5 indicate net buying pressure.
sig = hz.flow_signal("btc", window=50, weight=0.1)
Classifies each price tick as a buy (up-tick) or sell (down-tick) and computes the rolling ratio.

hz.imbalance_signal

Estimates orderbook imbalance from bid/ask prices. Values above 0.5 indicate buy-side pressure.
sig = hz.imbalance_signal("book", levels=5, weight=0.3)
Returns 0.5 when no bid/ask data is available.

Examples

Basic Signal Combination

import horizon as hz

def quoter(ctx: hz.Context, score: float) -> list[hz.Quote]:
    """Use the combined signal as fair value."""
    spread = 0.04 + (1.0 - score) * 0.04  # Wider spread when uncertain
    return hz.quotes(fair=score, spread=spread, size=5)

hz.run(
    name="signal_strategy",
    markets=["election-winner"],
    feeds={"book": hz.PolymarketBook("election-winner")},
    pipeline=[
        hz.signal_combiner([
            hz.price_signal("book", weight=0.5),
            hz.spread_signal("book", weight=0.3),
            hz.momentum_signal("book", lookback=20, weight=0.2),
        ]),
        quoter,
    ],
    risk=hz.Risk(max_position=100),
)

Custom Signal with Built-in Combiner

import horizon as hz
from horizon.signals import Signal

def my_model(ctx):
    """Custom probability model."""
    price = ctx.feeds.get("btc", hz.context.FeedData()).price or 0.5
    return min(max(price * 1.1 - 0.05, 0.0), 1.0)

custom = Signal(name="my_model", fn=my_model, weight=2.0)

combiner = hz.signal_combiner([
    custom,
    hz.price_signal("book", weight=1.0),
    hz.spread_signal("book", weight=0.5),
])

Signal + Kelly Sizing

import horizon as hz

hz.run(
    name="signal_kelly",
    markets=["election-winner"],
    feeds={"book": hz.PolymarketBook("election-winner")},
    pipeline=[
        hz.signal_combiner([
            hz.price_signal("book", weight=0.4),
            hz.momentum_signal("book", lookback=20, weight=0.3),
            hz.spread_signal("book", weight=0.3),
        ], smoothing=10),
        hz.kelly_sizer(fraction=0.25, bankroll=1000),
        quoter,
    ],
)

Signal + Market Maker

import horizon as hz

hz.run(
    name="signal_mm",
    markets=["election-winner"],
    feeds={"book": hz.PolymarketBook("election-winner")},
    pipeline=[
        hz.signal_combiner([
            hz.price_signal("book", weight=0.5),
            hz.imbalance_signal("book", levels=5, weight=0.3),
            hz.flow_signal("book", window=30, weight=0.2),
        ]),
        hz.market_maker(feed_name="book", gamma=0.5, size=5.0),
    ],
    risk=hz.Risk(max_position=100),
)

Signal Dataclass

The Signal dataclass is used to define custom signals:
from horizon.signals import Signal

Signal(
    name="my_signal",     # Display name
    fn=my_function,       # Callable[[Context], float]
    weight=1.0,           # Combination weight
)
FieldTypeDescription
namestrSignal identifier (for logging)
fnCallable[[Context], float]Function that extracts a float from context
weightfloatRelative weight in combination