Signals

The opinion type, not orders

What is a Signal?

A Signal is an opinion about a market. It says “I think X will move in direction D, with confidence C, by about E basis points over horizon H, with volatility V basis points.” It says nothing about sizing, order types, bids, or asks. those are other layers’ jobs.

python
from datetime import timedelta
from horizon import Signal, Direction

signal = Signal(
    market_id="AAPL",
    direction=Direction.Increase,
    confidence=0.65,                    # [0, 1]
    expected_edge_bps=30.0,             # bps per $1 over horizon
    expected_expected_vol_bps=100.0,             # std dev of return in bps
    horizon=timedelta(days=1),
    reason="z-score -2.3 in MR regime",
    features={"z": -2.3, "vol": 0.18},
)

The fields

Constructor helpers

Building a Signal(...) with 8+ fields every time is noise. Helpers flatten common patterns:

python
# Direction-only shortcuts
Signal.increase("AAPL", edge_bps=30, horizon="1d")
Signal.decrease("AAPL", edge_bps=20, confidence=0.55)
Signal.flatten("AAPL", reason="risk exit")

# From a z-score-like scalar (sign → direction, magnitude → confidence)
Signal.from_score("AAPL", score=-2.3, edge_per_stdev=15)

# From a probability forecast (for prediction markets)
Signal.from_probability(
    "polymarket:trump-2028",
    p_true=0.65,
    current_price=0.52,
)

# From an ML classifier output
Signal.from_classifier("AAPL", model_output=(0.28, 0.72), horizon="1d")

# From a regression forecast
Signal.from_forecast(
    "AAPL",
    forecast_return=0.015,
    forecast_vol=0.08,
    horizon="5d",
)

# Carver-style scaled forecast
Signal.from_carver_forecast(
    "AAPL",
    forecast=15,
    instrument_vol=0.20,
    horizon="20d",
)

All helpers return the same Signal object; they just differ in which input shape they accept.

String horizons

Any helper or constructor that accepts a horizon also accepts a string:

python
Signal.increase("AAPL", horizon="3d")    # 3 days
Signal.increase("AAPL", horizon="1h")    # 1 hour
Signal.increase("AAPL", horizon="15m")   # 15 minutes
Signal.increase("AAPL", horizon="2w")    # 2 weeks

Validation

The Signal dataclass validates at construction time. These raise ValueError:

python
Signal(confidence=1.5, ...)         # ✗ must be in [0, 1]
Signal(expected_expected_vol_bps=-1, ...)    # ✗ must be ≥ 0
Signal(horizon=timedelta(0), ...)   # ✗ must be positive
Signal(carver_forecast=25, ...)     # ✗ must be in [-20, 20]

Fail loudly at the boundary so bugs don’t propagate downstream.

Immutability

Signal is a frozen dataclass. Once constructed, fields can’t be mutated. Use dataclasses.replace() to derive a new signal:

python
from dataclasses import replace

original = Signal.increase("AAPL", edge_bps=30)
tagged = replace(original, strategy_id="my_strategy")

The engine uses this pattern to tag signals with their producing strategy before depositing them in the signal store.

Asset-class-agnostic edge

The critical design choice: every signal uses the same normalized fields regardless of asset class. This is what lets the portfolio optimizer compare an equity signal to a prediction-market signal without asset-class branching.

Sharpe shortcut

Every Signal exposes a sharpe property for ranking:

python
sig = Signal.increase("AAPL", edge_bps=30, expected_vol_bps=100, horizon="1d")
print(sig.sharpe)   # 0.30

This is expected_edge_bps / expected_vol_bps: a unit-less ratio that lets you rank signals without thinking about sizing.

What strategies return

Strategy.evaluate() returns a list[Signal]. Zero signals is fine: the optimizer just has nothing to do that tick.

python
class MyStrategy(Strategy):
    asset_classes = [Equity]
    features = {"z": Zscore(20)}

    def evaluate(self, f, universe):
        return [
            Signal.from_score(m, score=-f.z[m.id], edge_per_stdev=15)
            for m in universe
            if abs(f.z[m.id]) > 2
        ]

See Writing strategies for the full recipe.

Next