Skip to main content

Automated Market Making

Horizon includes a complete Avellaneda-Stoikov market making engine. All math is implemented in Rust for maximum performance and exposed to Python via PyO3. The hz.market_maker() pipeline factory handles everything from feed ingestion to multi-level quote generation in a single call.
Market making in prediction markets involves continuously quoting bid and ask prices. The Avellaneda-Stoikov model adjusts quotes based on inventory risk, volatility, and competitive dynamics. Horizon’s implementation adds competitive spread blending and multi-level quoting on top of the base model.

Overview

Reservation Price

hz.reservation_price() computes the inventory-skewed fair value using Avellaneda-Stoikov.

Optimal Spread

hz.optimal_spread() computes the theoretically optimal bid-ask width.

Competitive Spread

hz.competitive_spread() blends model spread with live orderbook spread.

Inventory-Aware Sizing

hz.mm_size() skews bid/ask sizes based on current inventory.

Core Functions

hz.reservation_price

Compute the inventory-skewed fair value (Avellaneda-Stoikov reservation price).
import horizon as hz

r = hz.reservation_price(
    mid=0.50,           # Current mid price
    inventory=10.0,     # Current inventory (positive = long)
    gamma=0.5,          # Risk aversion parameter
    volatility=0.02,    # Estimated volatility
    time_horizon=1.0,   # Time horizon (normalized)
)
print(f"Reservation price: {r:.4f}")  # 0.4980 (skewed down for long inventory)
ParameterTypeDescription
midfloatCurrent mid price
inventoryfloatNet inventory (positive = long, negative = short)
gammafloatRisk aversion (higher = more inventory penalty)
volatilityfloatEstimated price volatility
time_horizonfloatTime horizon (1.0 = full period)
Formula: r = mid - inventory * gamma * volatility^2 * time_horizon
  • Long inventory skews fair value down (encourages selling)
  • Short inventory skews fair value up (encourages buying)
  • NaN/Inf inventory defaults to zero (returns mid)

hz.optimal_spread

Compute the theoretically optimal bid-ask spread.
spread = hz.optimal_spread(
    volatility=0.02,    # Estimated volatility
    inventory=10.0,     # Current inventory
    gamma=0.5,          # Risk aversion
    kappa=1.5,          # Order arrival intensity
    time_horizon=1.0,   # Time horizon
)
print(f"Optimal spread: {spread:.4f}")
ParameterTypeDescription
volatilityfloatEstimated price volatility
inventoryfloatCurrent inventory
gammafloatRisk aversion parameter
kappafloatOrder arrival intensity (higher = more competitive)
time_horizonfloatTime horizon

hz.competitive_spread

Blend the model-optimal spread with the live orderbook spread.
spread = hz.competitive_spread(
    base_spread=0.04,      # From optimal_spread()
    book_spread=0.02,      # Live orderbook spread
    book_imbalance=0.0,    # Orderbook imbalance (-1 to 1)
    aggression=0.5,        # 0 = pure model, 1 = pure book
)
print(f"Competitive spread: {spread:.4f}")  # 0.03 (blend)
ParameterTypeDescription
base_spreadfloatTheoretical optimal spread
book_spreadfloatCurrent orderbook spread
book_imbalancefloatOrderbook imbalance (not used in current version)
aggressionfloat0.0 = pure model, 1.0 = pure orderbook
The result is clamped to [0.001, 1.0].

hz.mm_size

Compute inventory-skewed bid and ask sizes.
bid_size, ask_size = hz.mm_size(
    base_size=5.0,       # Base order size
    inventory=50.0,      # Current inventory
    max_position=100.0,  # Maximum position limit
    fill_rate=0.3,       # Historical fill rate
    skew_factor=0.5,     # How aggressively to skew sizes
)
print(f"Bid: {bid_size:.1f}, Ask: {ask_size:.1f}")
ParameterTypeDescription
base_sizefloatBase order size for each side
inventoryfloatCurrent inventory
max_positionfloatMaximum position limit
fill_ratefloatHistorical fill rate (0 to 1)
skew_factorfloatSize skew intensity (0 = no skew, 1 = aggressive)
Returns (bid_size, ask_size):
  • Long inventory → smaller bids, larger asks (reduces inventory)
  • Short inventory → larger bids, smaller asks (builds inventory)
  • Returns (0.0, 0.0) if base_size is zero or NaN

hz.estimate_volatility

Estimate price volatility from a series of prices using log-return standard deviation.
vol = hz.estimate_volatility([0.50, 0.51, 0.49, 0.52, 0.48, 0.51])
print(f"Volatility: {vol:.4f}")
Returns 0.0 for fewer than 2 prices or constant prices.

Pipeline Factory: hz.market_maker

The market_maker() factory returns a pipeline function that generates multi-level quotes automatically.
import horizon as hz

hz.run(
    name="mm_strategy",
    markets=["will-btc-hit-100k"],
    feeds={"btc": hz.BinanceWS("btcusdt")},
    pipeline=[hz.market_maker(base_spread=0.04, gamma=0.3, size=5.0)],
    risk=hz.Risk(max_position=100),
)

Parameters

mm = hz.market_maker(
    base_spread=0.04,         # Base spread when no book data available
    gamma=0.5,                # Risk aversion (inventory penalty)
    kappa=1.5,                # Order arrival intensity
    max_position=100.0,       # Maximum position for sizing
    num_levels=1,             # Number of quote levels
    level_spacing=0.01,       # Price spacing between levels
    aggression=0.5,           # Spread blend: 0=model, 1=book
    volatility_window=50,     # Price history window for vol estimation
    feed_name=None,           # Feed key (None = use first available)
    size=5.0,                 # Base order size
    skew_factor=0.5,          # Inventory size skew intensity
    time_horizon=1.0,         # Time horizon parameter
)
ParameterTypeDefaultDescription
base_spreadfloat0.04Fallback spread
gammafloat0.5Risk aversion
kappafloat1.5Order arrival intensity
max_positionfloat100.0Max position for sizing
num_levelsint1Quote levels to generate
level_spacingfloat0.01Spacing between levels
aggressionfloat0.5Book vs model spread blend
volatility_windowint50Rolling vol window
feed_namestr or NoneNoneFeed key
sizefloat5.0Base order size
skew_factorfloat0.5Size skew factor
time_horizonfloat1.0Time horizon

How It Works

On each cycle, the market maker:
  1. Gets the mid price from the feed (bid/ask midpoint or price field)
  2. Estimates volatility from the rolling price history
  3. Computes reservation_price() with current inventory
  4. Computes optimal_spread() based on volatility and risk aversion
  5. Blends with live spread via competitive_spread() if bid/ask data is available
  6. Computes inventory-skewed mm_size()
  7. Generates num_levels quote levels with 0.8x size decay per level
  8. Clamps all prices to [0.01, 0.99] and skips crossed quotes
Returns list[Quote] compatible with the pipeline system.

Signal Chaining

When placed after hz.signal_combiner() in a pipeline, the market maker receives the combined signal value as its fair value estimate. This replaces the feed mid price for the reservation price calculation:
pipeline=[
    hz.signal_combiner([...]),       # returns float (e.g., 0.55)
    hz.market_maker(feed_name="book"),  # uses 0.55 as fair value
]
The signal value is only used when it falls in the valid range (0, 1). Otherwise the feed mid price is used as the fallback.

Examples

Single-Level Market Maker

import horizon as hz

hz.run(
    name="simple_mm",
    markets=["election-winner"],
    feeds={"poly": hz.PolymarketBook("election-winner")},
    pipeline=[hz.market_maker(
        feed_name="poly",
        base_spread=0.06,
        gamma=0.3,
        size=10.0,
    )],
    risk=hz.Risk(max_position=200, max_drawdown_pct=5),
)

Multi-Level with Aggressive Spread

mm = hz.market_maker(
    feed_name="book",
    base_spread=0.02,
    gamma=0.8,           # High risk aversion
    aggression=0.7,      # Lean toward book spread
    size=5.0,
    num_levels=3,        # Three levels
    level_spacing=0.02,  # 2 cents apart
)

Combined with Signal Combiner

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.spread_signal("book", weight=0.3),
            hz.momentum_signal("book", lookback=20, weight=0.2),
        ]),
        hz.market_maker(feed_name="book", gamma=0.5, size=5.0),
    ],
    risk=hz.Risk(max_position=100),
)

Mathematical Background

The model computes the dealer’s reservation price as:r = s - q * gamma * sigma^2 * TWhere s is the mid price, q is inventory, gamma is risk aversion, sigma is volatility, and T is the time horizon. This skews the fair value against inventory to encourage mean reversion.
The optimal spread balances adverse selection risk against the probability of getting filled. Higher volatility and lower order arrival rates lead to wider spreads.
Pure model spreads can be too wide or too narrow relative to the live orderbook. The aggression parameter lets you blend toward the market spread when you want to be competitive, or toward the model spread when you want to protect against adverse selection.
Market making in prediction markets carries inventory risk. Always use risk limits (max_position, max_drawdown_pct) and start with paper trading before deploying live. The gamma parameter controls how aggressively the model penalizes inventory, start with higher values (0.5-1.0) to be conservative.