---
title: "Signal Combiner"
description: "Combine multiple alpha signals with weighted averaging, rank, and z-score methods. Built-in extractors for price, spread, momentum, flow, and orderbook imbalance."
icon: "wave-pulse"
---

# 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()`.

<Note>
  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.
</Note>

## Overview

<CardGroup cols={2}>
  <Card title="Weighted Average" icon="scale-balanced">
    `hz.combine_signals()` with `"weighted_avg"` method for importance-weighted combination.
  </Card>
  <Card title="Built-in Extractors" icon="wand-magic-sparkles">
    5 ready-to-use signal extractors: price, spread, momentum, flow, and imbalance.
  </Card>
  <Card title="EMA Smoothing" icon="chart-line">
    `hz.ema()` for exponential smoothing of noisy signal values.
  </Card>
  <Card title="Time Decay" icon="clock">
    `hz.decay_weight()` for exponential half-life weighting of stale data.
  </Card>
</CardGroup>

---

## Core Functions

### hz.combine_signals

Combine multiple weighted signal values into a single score.

```python
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)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `signals` | `list[tuple[float, float]]` | List of `(value, weight)` pairs |
| `method` | `str` | Combination 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.

```python
result = hz.ema(values=[1.0, 2.0, 3.0, 4.0], span=3)
print(f"EMA: {result:.4f}")  # 3.125
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `values` | `list[float]` | Time series values |
| `span` | `int` | EMA 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.

```python
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.

```python
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)
```

| Parameter | Type | Description |
|-----------|------|-------------|
| `age_secs` | `float` | Age of the data point in seconds |
| `half_life_secs` | `float` | Half-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.

```python
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

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `signals` | `list[Signal]` | required | Signals to combine |
| `method` | `str` | `"weighted_avg"` | Combination method |
| `smoothing` | `int` | `0` | EMA smoothing span (0 = disabled) |
| `clip` | `tuple[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).

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

```python
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.

```python
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.

```python
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.

```python
sig = hz.imbalance_signal("book", levels=5, weight=0.3)
```

Returns 0.5 when no bid/ask data is available.

---

## Examples

### Basic Signal Combination

```python
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

```python
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

```python
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

```python
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:

```python
from horizon.signals import Signal

Signal(
    name="my_signal",     # Display name
    fn=my_function,       # Callable[[Context], float]
    weight=1.0,           # Combination weight
)
```

| Field | Type | Description |
|-------|------|-------------|
| `name` | `str` | Signal identifier (for logging) |
| `fn` | `Callable[[Context], float]` | Function that extracts a float from context |
| `weight` | `float` | Relative weight in combination |
