Multi-outcome events are prediction markets with more than two possible outcomes - for example, “Who wins the 2024 election?” with candidates Trump, Biden, DeSantis, etc. Each outcome is a separate binary contract with its own YES/NO tokens.
Horizon models these as an Event containing multiple Outcome objects, layered on top of the existing binary Market model. Zero breaking changes to existing strategies.
Core Concepts
Each Outcome is a Binary Contract
Every outcome in a multi-outcome event is itself a standard binary contract. You can:
- Buy YES on an outcome (bet it will happen)
- Sell YES on an outcome (exit a YES position)
- Buy NO on an outcome (bet it won’t happen)
- Sell NO on an outcome (exit a NO position)
The full Side × OrderSide matrix works exactly like single-market trading.
Event Parity
For a well-formed event, the sum of all outcome YES prices should be approximately 1.0. If the sum deviates significantly, there may be an arbitrage opportunity:
- Sum under 1.0: Buy all outcomes = guaranteed profit (after fees)
- Sum over 1.0: Sell all outcomes (buy all NOs) = guaranteed profit (after fees)
Types
Outcome
A single named outcome in a multi-outcome event.
from horizon import Outcome, Side
outcome = Outcome(
name="Trump",
market_id="trump-wins-2024",
yes_token_id="token_yes_123",
no_token_id="token_no_456",
yes_price=0.55,
)
outcome.name # "Trump"
outcome.market_id # "trump-wins-2024"
outcome.yes_price # 0.55
outcome.token_id(Side.Yes) # "token_yes_123"
outcome.token_id(Side.No) # "token_no_456"
Event
Groups outcomes under a shared event/condition.
from horizon import Event, Outcome
trump = Outcome(name="Trump", market_id="trump-win", yes_token_id="t1", no_token_id="t2", yes_price=0.55)
biden = Outcome(name="Biden", market_id="biden-win", yes_token_id="t3", no_token_id="t4", yes_price=0.30)
desantis = Outcome(name="DeSantis", market_id="desantis-win", yes_token_id="t5", no_token_id="t6", yes_price=0.10)
event = Event(
id="election-2024",
name="Who wins the 2024 election?",
outcomes=[trump, biden, desantis],
neg_risk=True,
exchange="polymarket",
condition_id="0xabc...",
)
event.outcome_count() # 3
event.outcome_names() # ["Trump", "Biden", "DeSantis"]
event.outcome_by_name("Trump") # Outcome(name="Trump", ...)
Event → Markets
Event.to_markets() converts each outcome into a standard Market with event_id and outcome_name set:
markets = event.to_markets()
# Returns 3 Market objects, each with:
# market.event_id = "election-2024"
# market.outcome_name = "Trump" / "Biden" / "DeSantis"
# market.neg_risk = True
# market.yes_token_id / no_token_id set from the Outcome
These markets work with all existing engine methods - submit_order, submit_quotes, tick, etc.
Discovery
Discover multi-outcome events from Polymarket:
from horizon import discover_events
events = discover_events(
exchange="polymarket",
query="election",
active=True,
limit=10,
)
for event in events:
print(f"{event.name} ({event.outcome_count()} outcomes)")
for name in event.outcome_names():
outcome = event.outcome_by_name(name)
print(f" {name}: {outcome.yes_price:.0%}")
discover_events queries the Polymarket Gamma API and groups markets by condition_id into events. Each market question is parsed to extract the outcome name.
Engine Event Management
Registering Events
Register events on the engine to enable event-level risk limits and exposure tracking:
engine = hz.Engine()
# Register event with its outcome market IDs
engine.register_event("election-2024", ["trump-win", "biden-win", "desantis-win"])
# Or from an Event object
event = discover_events("polymarket", query="election")[0]
markets = event.to_markets()
engine.register_event(event.id, [m.id for m in markets])
Event Exposure & Positions
# Total exposure across all outcome markets in the event
exposure = engine.event_exposure("election-2024")
# All positions in the event
positions = engine.event_positions("election-2024")
# Reverse lookup: which event does a market belong to?
event_id = engine.market_event_id("trump-win") # "election-2024"
# All registered events
events = engine.registered_events() # {"election-2024": ["trump-win", ...]}
Event Parity Check
Check whether outcome prices sum to ~1.0 using feed data:
result = engine.event_parity_check("election-2024", threshold=0.02)
if result and result.is_violation:
print(f"Parity violation: sum={result.yes_price:.4f}, deviation={result.deviation:.4f}")
Event Risk Limits
Set max_position_per_event to cap total exposure across all outcomes in an event:
from horizon import RiskConfig
config = RiskConfig(
max_position_per_market=100.0,
max_position_per_event=200.0, # None = disabled (default)
)
hz.run(
risk=config,
...
)
hz.Risk() does not support max_position_per_event. Use RiskConfig directly for event-level position limits.
When a market is registered in an event, orders are checked against both the per-market and per-event limits. Markets not in any event are unaffected.
Event risk limits are optional. Set max_position_per_event=None (the default) to disable event-level risk checks entirely.
Running with Events
Pass events directly to hz.run():
import horizon as hz
events = hz.discover_events("polymarket", query="election", limit=5)
def fair_value(ctx: hz.Context) -> float:
feed = ctx.feeds.get("poly", hz.context.FeedData())
return feed.price
def quoter(ctx: hz.Context, fair: float) -> list[hz.Quote]:
# Access the current event
if ctx.event:
print(f"Quoting {ctx.market.outcome_name} in {ctx.event.name}")
return hz.quotes(fair, spread=0.04, size=5)
hz.run(
name="multi_outcome_mm",
exchange=hz.Polymarket(private_key="0x..."),
events=events, # Pass events instead of markets
feeds={"poly": hz.PolymarketBook("...")},
pipeline=[fair_value, quoter],
risk=hz.RiskConfig(max_position_per_market=50, max_position_per_event=150),
mode="live",
)
When events is provided:
- Each event is registered on the engine automatically
- Each outcome is converted to a
Market via to_markets()
- The pipeline runs once per outcome per cycle
ctx.event is set to the current event being processed
ctx.market.event_id and ctx.market.outcome_name are populated
events and markets are independent parameters. Events expand into markets automatically - don’t list the same market IDs in both.
Arbitrage Detection
Detect event-level arbitrage opportunities using EventArbitrageOpportunity:
from horizon import EventArbitrageOpportunity
# The engine's event_parity_check returns a ParityResult.
# For full arbitrage analysis with fee awareness, use the Rust detect_event_arbitrage:
result = engine.event_parity_check("election-2024", threshold=0.02)
if result and result.is_violation:
if result.deviation > 0:
print("Overround: sell all outcomes for guaranteed profit")
else:
print("Underround: buy all outcomes for guaranteed profit")
| Field | Type | Description |
|---|
event_id | str | Event identifier |
exchange | str | Exchange name |
price_sum | float | Sum of all outcome YES prices |
overround | float | price_sum - 1.0 |
direction | str | "buy_all" or "sell_all" |
net_edge | float | Edge after fees |
is_executable | bool | Whether the edge exceeds fees |
Example: Multi-Outcome Market Maker
import horizon as hz
def fair_value(ctx: hz.Context) -> float:
"""Use feed price as fair value for each outcome."""
feed = ctx.feeds.get(ctx.market.id, hz.context.FeedData())
if feed.price > 0:
return feed.price
return 0.5 # Default
def event_aware_quoter(ctx: hz.Context, fair: float) -> list[hz.Quote]:
"""Quote with event-aware inventory management."""
# Per-outcome inventory
market_pos = ctx.inventory.net_for_market(ctx.market.id)
# Total event inventory (if in an event)
event_pos = 0.0
if ctx.event:
event_market_ids = [o.market_id for o in ctx.event.outcomes]
event_pos = ctx.inventory.net_for_event(event_market_ids)
# Skew based on inventory
skew = market_pos * 0.002 + event_pos * 0.001
spread = 0.04
return hz.quotes(fair - skew, spread, size=5)
events = hz.discover_events("polymarket", query="election")
hz.run(
name="event_mm",
exchange=hz.Polymarket(private_key="0x..."),
events=events,
pipeline=[fair_value, event_aware_quoter],
risk=hz.RiskConfig(max_position_per_market=50, max_position_per_event=150),
mode="live",
dashboard=True,
)