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.
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:
# 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:
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:
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:
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:
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.
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.