> "The market maker is the unsung hero of prediction markets. Without liquidity, even the most brilliant forecasting mechanism produces nothing but wide spreads and frustrated traders."
In This Chapter
- 29.1 The Role of Market Makers
- 29.2 Market Making Economics
- 29.3 The Glosten-Milgrom Model
- 29.4 Inventory Management
- 29.5 Subsidized Market Making
- 29.6 Building a Market Making Bot
- 29.7 Quote Strategies
- 29.8 Adverse Selection Detection
- 29.9 Market Making in AMM-Based Markets
- 29.10 Risk Management for Market Makers
- 29.11 Multi-Market Market Making
- 29.12 Performance Measurement
- 29.13 Practical Considerations
- 29.14 Summary
Chapter 29: Liquidity Provision and Market Making
"The market maker is the unsung hero of prediction markets. Without liquidity, even the most brilliant forecasting mechanism produces nothing but wide spreads and frustrated traders." — Robin Hanson
Every prediction market faces the same bootstrapping problem: traders will not come if there is no liquidity, and there is no liquidity if traders do not come. Market makers break this deadlock. They stand ready to buy and sell at quoted prices, absorbing the temporary imbalances that would otherwise leave one side of the order book empty. In doing so, they perform a service that is as economically essential as it is mathematically subtle.
This chapter provides an exhaustive treatment of liquidity provision in prediction markets. We begin with the economic foundations of market making—the spread capture that compensates for adverse selection and inventory risk—then build toward practical implementation. Along the way we will construct a fully functional market-making bot, develop inventory management systems, model adverse selection with the Glosten-Milgrom framework, and explore the unique challenges that arise when market making is subsidized to improve information aggregation. By the end, you will have both the theoretical grounding and the working code to provide liquidity in real prediction markets.
29.1 The Role of Market Makers
29.1.1 Why Liquidity Matters
A prediction market's value proposition is information aggregation: it converts dispersed private beliefs into a single, publicly observable price that reflects the crowd's probability estimate for some future event. But information aggregation requires trade, and trade requires counterparties. If a trader who believes an event is under-priced at $0.40$ cannot find someone willing to sell, the market price remains stale, and the informational signal is lost.
Liquidity is the ease with which a market participant can execute a trade of reasonable size near the prevailing price without causing a large price impact. In liquid markets, bid-ask spreads are tight, order books are deep, and prices update quickly. In illiquid markets, spreads are wide, depth is thin, and prices may remain unchanged for hours or days despite material new information.
Key Insight: In prediction markets, illiquidity does not merely inconvenience traders—it undermines the market's core purpose. A market that cannot incorporate new information because no one is willing to take the other side of an informed trade is, in a meaningful sense, broken.
29.1.2 What Market Makers Do
A market maker is a participant who continuously posts both buy and sell orders (quotes) for a given contract. By maintaining a two-sided presence on the order book, the market maker ensures that:
- Immediacy — Any arriving trader can execute instantly rather than waiting for a natural counterparty.
- Price discovery — The market maker's quotes provide a continuous price signal, even during periods of low natural trading activity.
- Depth — The market maker's orders contribute to book depth, reducing the price impact of individual trades.
In exchange for providing this service, market makers earn the bid-ask spread: the difference between the price at which they buy (the bid) and the price at which they sell (the ask). If a market maker posts a bid at $0.48$ and an ask at $0.52$, and both sides execute, the market maker earns $0.04$ per round trip, regardless of where the true probability lies.
29.1.3 Market Making in Prediction Markets vs. Traditional Finance
While the fundamentals of market making are the same across asset classes, prediction markets introduce distinctive characteristics:
| Feature | Traditional Finance | Prediction Markets |
|---|---|---|
| Contract payoff | Unbounded (equities) or bounded (options) | Binary: pays \$1 or \$0 |
| Natural hedging | Correlated assets, derivatives | Limited; events are often idiosyncratic |
| Information sources | Earnings, macro data | News, polls, domain expertise |
| Adverse selection | Institutional flow, dark pools | Insiders, domain experts |
| Inventory risk | Can be hedged with related instruments | Usually must be warehoused |
| Market lifetime | Indefinite (equities) | Finite; expires at event resolution |
| Liquidity subsidies | Rare (designated market makers get fee rebates) | Common; platforms fund market makers |
The bounded payoff of binary contracts is a double-edged sword. On one hand, the maximum loss on any position is capped at $1$ per contract. On the other hand, as a contract's price approaches $0$ or $1$, the market maker faces extreme adverse selection: any trade against the market maker's position is very likely informed.
29.2 Market Making Economics
29.2.1 The Spread as Compensation
The bid-ask spread is not profit—it is compensation for three distinct costs:
- Adverse selection cost — Trading against informed participants who know more about the true probability than the market maker.
- Inventory carrying cost — Holding a net long or short position exposes the market maker to directional risk.
- Operational cost — Technology, capital, and opportunity cost of the market maker's time and infrastructure.
A market maker's expected profit per trade can be written as:
$$ \mathbb{E}[\text{profit per trade}] = \frac{s}{2} - \alpha \cdot \delta - c $$
where $s$ is the full bid-ask spread, $\alpha$ is the probability that the counterparty is informed, $\delta$ is the expected loss given an informed counterparty, and $c$ is the per-trade operational cost. When this expression is negative, market making is unprofitable and liquidity will dry up without subsidies.
29.2.2 Spread Decomposition
We can decompose the observed spread into its components. Let $m$ denote the mid-price (the market maker's best estimate of fair value). The bid and ask are:
$$ \text{bid} = m - \frac{s}{2}, \qquad \text{ask} = m + \frac{s}{2} $$
The total spread $s$ can be decomposed as:
$$ s = s_{\text{adverse}} + s_{\text{inventory}} + s_{\text{operational}} $$
In prediction markets, the adverse selection component $s_{\text{adverse}}$ is typically dominant, especially in markets where a small number of well-informed participants trade alongside uninformed speculators.
29.2.3 Realized vs. Quoted Spread
The quoted spread is the difference between the best bid and best ask on the order book. The realized spread (also called the effective spread) accounts for the price impact of trades:
$$ s_{\text{realized}} = 2 \cdot \text{sign}(q) \cdot (p_{\text{trade}} - m_{t+\Delta t}) $$
where $q$ is the trade direction ($+1$ for a buy, $-1$ for a sell), $p_{\text{trade}}$ is the execution price, and $m_{t+\Delta t}$ is the mid-price some time $\Delta t$ after the trade. When the mid-price moves against the market maker after a trade (as it does when the trade was informed), the realized spread is smaller than the quoted spread. In markets with high adverse selection, realized spreads can be negative, meaning the market maker loses money on average.
import numpy as np
import pandas as pd
def compute_realized_spread(trades: pd.DataFrame,
delay_seconds: float = 60.0) -> pd.Series:
"""
Compute realized spread for each trade.
Parameters
----------
trades : pd.DataFrame
Must contain columns: 'timestamp', 'price', 'side' (+1 buy, -1 sell),
'mid_price' (mid at time of trade).
delay_seconds : float
Time delay for measuring price impact.
Returns
-------
pd.Series
Realized spread for each trade.
"""
realized_spreads = []
for idx, trade in trades.iterrows():
# Find the mid-price delay_seconds after the trade
future_mask = (
trades['timestamp'] >= trade['timestamp'] + pd.Timedelta(seconds=delay_seconds)
)
if future_mask.any():
future_mid = trades.loc[future_mask, 'mid_price'].iloc[0]
else:
future_mid = trades['mid_price'].iloc[-1] # use last available
rs = 2.0 * trade['side'] * (trade['price'] - future_mid)
realized_spreads.append(rs)
return pd.Series(realized_spreads, index=trades.index, name='realized_spread')
29.3 The Glosten-Milgrom Model
29.3.1 Setup and Assumptions
The Glosten-Milgrom (1985) model is the foundational framework for understanding adverse selection in market making. Although originally developed for equity markets, it maps naturally onto prediction markets where the underlying asset has a binary payoff.
Setting. Consider a single prediction market contract that pays $\$1$ if event $E$ occurs and $\$0$ otherwise. The true probability of $E$ is $\mu$, which is known to informed traders but not to the market maker. The market maker knows only the prior distribution over $\mu$.
Trader types: - With probability $\alpha$, the next trader is informed and knows whether $E$ will occur. - With probability $1 - \alpha$, the next trader is uninformed and buys or sells with equal probability.
Market maker's problem: Set bid and ask prices such that the expected loss from informed traders is offset by the expected gain from uninformed traders.
29.3.2 Deriving the Bid and Ask
Let $\mu_t$ be the market maker's current estimate of $\Pr(E)$. The ask price $a_t$ must satisfy the zero-profit condition:
$$ a_t = \mathbb{E}[\text{value} \mid \text{buy order arrives}] $$
A buy order arrives from: - An informed trader (probability $\alpha$) who buys only if $E$ will occur (value = 1) - An uninformed trader (probability $1-\alpha$) who buys regardless (expected value = $\mu_t$)
Using Bayes' rule:
$$ a_t = \frac{\alpha \cdot 1 + (1-\alpha) \cdot \frac{1}{2} \cdot \mu_t}{\alpha \cdot 1 + (1-\alpha) \cdot \frac{1}{2}} $$
Wait—we need to be more careful. An informed trader buys only when the event will occur; so the probability that a buy arrives from an informed trader is $\alpha \cdot \mu_t$ (they show up with probability $\alpha$ and the event occurs with probability $\mu_t$). An uninformed trader buys with probability $\frac{1}{2}$, so the probability of a buy from an uninformed trader is $(1-\alpha) \cdot \frac{1}{2}$.
The ask price is the conditional expectation of the contract value given a buy order:
$$ a_t = \frac{\alpha \cdot \mu_t \cdot 1 + (1-\alpha) \cdot \frac{1}{2} \cdot \mu_t}{\alpha \cdot \mu_t + (1-\alpha) \cdot \frac{1}{2}} $$
Simplifying:
$$ a_t = \frac{\mu_t \left[\alpha + \frac{1-\alpha}{2}\right]}{\alpha \mu_t + \frac{1-\alpha}{2}} = \frac{\mu_t \cdot \frac{1+\alpha}{2}}{\alpha \mu_t + \frac{1-\alpha}{2}} $$
Similarly, the bid price is the conditional expectation given a sell order:
$$ b_t = \frac{\alpha \cdot (1-\mu_t) \cdot 0 + (1-\alpha) \cdot \frac{1}{2} \cdot \mu_t}{\alpha \cdot (1-\mu_t) + (1-\alpha) \cdot \frac{1}{2}} = \frac{(1-\alpha) \cdot \frac{1}{2} \cdot \mu_t}{\alpha(1-\mu_t) + \frac{1-\alpha}{2}} $$
29.3.3 Properties of the Glosten-Milgrom Spread
Several important properties emerge:
-
The spread is increasing in $\alpha$. As the fraction of informed traders rises, the market maker must widen the spread to compensate for greater adverse selection.
-
The spread is widest near $\mu = 0.5$. When the market maker is maximally uncertain, adverse selection is most costly because informed traders are most likely to trade in either direction.
-
The spread narrows as $\mu \to 0$ or $\mu \to 1$. When the market maker is nearly certain of the outcome, there is little room for informed traders to profit.
-
Sequential trades cause Bayesian updating. Each observed trade (buy or sell) causes the market maker to update $\mu_t$, which shifts both the bid and ask.
def glosten_milgrom(mu: float, alpha: float) -> tuple:
"""
Compute Glosten-Milgrom bid and ask prices.
Parameters
----------
mu : float
Market maker's prior probability of the event (0 < mu < 1).
alpha : float
Probability that the next trader is informed (0 <= alpha < 1).
Returns
-------
tuple
(bid, ask, spread)
"""
# Ask: E[value | buy order]
numerator_ask = mu * (alpha + (1 - alpha) / 2)
denominator_ask = alpha * mu + (1 - alpha) / 2
ask = numerator_ask / denominator_ask
# Bid: E[value | sell order]
numerator_bid = (1 - alpha) / 2 * mu
denominator_bid = alpha * (1 - mu) + (1 - alpha) / 2
bid = numerator_bid / denominator_bid
spread = ask - bid
return bid, ask, spread
def glosten_milgrom_update(mu: float, alpha: float, trade: str) -> float:
"""
Bayesian update of mu after observing a trade.
Parameters
----------
mu : float
Prior probability of the event.
alpha : float
Fraction of informed traders.
trade : str
'buy' or 'sell'.
Returns
-------
float
Updated posterior probability.
"""
if trade == 'buy':
# Posterior = P(E=1 | buy) = ask price
bid, ask, _ = glosten_milgrom(mu, alpha)
return ask
elif trade == 'sell':
# Posterior = P(E=1 | sell) = bid price
bid, ask, _ = glosten_milgrom(mu, alpha)
return bid
else:
raise ValueError("trade must be 'buy' or 'sell'")
# Demonstrate the model
if __name__ == "__main__":
print("Glosten-Milgrom Model Demonstration")
print("=" * 50)
mu = 0.5
alpha = 0.3
print(f"\nInitial mu = {mu}, alpha = {alpha}")
bid, ask, spread = glosten_milgrom(mu, alpha)
print(f"Bid = {bid:.4f}, Ask = {ask:.4f}, Spread = {spread:.4f}")
# Simulate a sequence of trades
trades = ['buy', 'buy', 'sell', 'buy', 'buy', 'buy']
print(f"\nSimulating trade sequence: {trades}")
for t in trades:
mu = glosten_milgrom_update(mu, alpha, t)
bid, ask, spread = glosten_milgrom(mu, alpha)
print(f" {t:4s} -> mu = {mu:.4f}, bid = {bid:.4f}, ask = {ask:.4f}, spread = {spread:.4f}")
29.3.4 Extensions to the Basic Model
The basic Glosten-Milgrom model assumes binary information (informed traders know the outcome with certainty). In practice, information is graded—a trader may have a signal that shifts the probability from $0.5$ to $0.7$, not all the way to $1.0$. The model can be extended to accommodate this:
Noisy signals. Suppose informed traders observe a signal $S$ with:
$$ \Pr(S = \text{high} \mid E = 1) = \theta, \qquad \Pr(S = \text{high} \mid E = 0) = 1 - \theta $$
where $\theta \in (0.5, 1]$ measures signal quality. Informed traders buy when $S = \text{high}$ and sell when $S = \text{low}$. The Glosten-Milgrom update becomes:
$$ a_t = \frac{\alpha \cdot \theta \cdot \mu_t + (1-\alpha) \cdot \frac{1}{2} \cdot \mu_t}{\alpha[\theta \cdot \mu_t + (1-\theta)(1-\mu_t)] \cdot \frac{1}{2} + (1-\alpha) \cdot \frac{1}{2}} $$
This generalization produces narrower spreads because the market maker faces less extreme adverse selection—even informed trades carry noise.
29.4 Inventory Management
29.4.1 The Inventory Problem
A market maker who simply posts symmetric quotes around the mid-price will accumulate inventory whenever order flow is asymmetric. In prediction markets, this is particularly dangerous because:
- Binary payoff risk. A market maker holding 1,000 "Yes" contracts in a market priced at $0.50$ faces a potential loss of $\$500$ if the event does not occur.
- No hedging. Unlike equity market makers who can hedge with index futures or options, prediction market makers typically cannot hedge idiosyncratic event risk.
- Event resolution. All positions must be unwound at expiry, so there is no possibility of "waiting out" an adverse move.
29.4.2 The Avellaneda-Stoikov Framework
The Avellaneda-Stoikov (2008) model provides an elegant framework for inventory-aware market making. While originally developed for equities, it adapts well to prediction markets.
The market maker chooses bid and ask quotes to maximize terminal wealth while penalizing inventory risk. The optimal quotes are:
$$ \text{bid} = m - \frac{s^*}{2} - \gamma \cdot q \cdot \sigma^2 \cdot (T - t) $$
$$ \text{ask} = m + \frac{s^*}{2} - \gamma \cdot q \cdot \sigma^2 \cdot (T - t) $$
where: - $m$ is the market maker's fair value estimate (mid-price) - $s^*$ is the base spread (from adverse selection considerations) - $\gamma$ is the risk aversion parameter - $q$ is the current inventory (positive = long, negative = short) - $\sigma^2$ is the variance of the contract price - $T - t$ is the time remaining until market resolution
Key Insight: The term $-\gamma \cdot q \cdot \sigma^2 \cdot (T-t)$ shifts both the bid and ask in the same direction. When the market maker is long ($q > 0$), both quotes shift down, making it more likely that the market maker sells (reducing inventory). When short ($q < 0$), both quotes shift up.
29.4.3 Quote Skewing
In practice, market makers implement inventory management through quote skewing: adjusting the bid-ask spread asymmetrically around the mid-price based on current inventory.
The skewing function can take many forms. A simple linear skew:
$$ \Delta_{\text{skew}} = -\kappa \cdot q $$
where $\kappa > 0$ is a tuning parameter. The adjusted quotes become:
$$ \text{bid} = m - \frac{s}{2} + \Delta_{\text{skew}}, \qquad \text{ask} = m + \frac{s}{2} + \Delta_{\text{skew}} $$
More sophisticated approaches use nonlinear skewing to become more aggressive as inventory approaches limits:
$$ \Delta_{\text{skew}} = -\kappa \cdot \text{sign}(q) \cdot \left(\frac{|q|}{Q_{\max}}\right)^\beta $$
where $Q_{\max}$ is the maximum acceptable inventory and $\beta > 1$ produces convex (increasingly aggressive) skewing.
import numpy as np
class InventoryManager:
"""
Manages market maker inventory and computes quote skews.
"""
def __init__(self, max_inventory: int = 1000, kappa: float = 0.001,
gamma: float = 0.1, beta: float = 1.5):
self.max_inventory = max_inventory
self.kappa = kappa # linear skew coefficient
self.gamma = gamma # risk aversion
self.beta = beta # skew convexity
self.inventory = 0
self.pnl = 0.0
self.trade_history = []
def compute_linear_skew(self) -> float:
"""Compute linear inventory skew."""
return -self.kappa * self.inventory
def compute_nonlinear_skew(self) -> float:
"""Compute nonlinear (convex) inventory skew."""
if self.inventory == 0:
return 0.0
normalized = abs(self.inventory) / self.max_inventory
skew = -self.kappa * np.sign(self.inventory) * (normalized ** self.beta)
return skew
def compute_avellaneda_stoikov_skew(self, sigma: float,
time_remaining: float) -> float:
"""Compute Avellaneda-Stoikov inventory adjustment."""
return -self.gamma * self.inventory * sigma**2 * time_remaining
def get_quotes(self, mid: float, base_spread: float, sigma: float,
time_remaining: float, method: str = 'avellaneda_stoikov') -> tuple:
"""
Compute inventory-adjusted bid and ask quotes.
Parameters
----------
mid : float
Fair value estimate.
base_spread : float
Base bid-ask spread.
sigma : float
Price volatility.
time_remaining : float
Time to market resolution (in appropriate units).
method : str
'linear', 'nonlinear', or 'avellaneda_stoikov'.
Returns
-------
tuple
(bid, ask)
"""
if method == 'linear':
skew = self.compute_linear_skew()
elif method == 'nonlinear':
skew = self.compute_nonlinear_skew()
elif method == 'avellaneda_stoikov':
skew = self.compute_avellaneda_stoikov_skew(sigma, time_remaining)
else:
raise ValueError(f"Unknown method: {method}")
bid = mid - base_spread / 2 + skew
ask = mid + base_spread / 2 + skew
# Clamp to [0, 1] for prediction markets
bid = max(0.01, min(0.99, bid))
ask = max(0.01, min(0.99, ask))
# Ensure bid < ask
if bid >= ask:
center = (bid + ask) / 2
bid = center - 0.005
ask = center + 0.005
return bid, ask
def record_fill(self, side: str, price: float, quantity: int):
"""Record a trade fill and update inventory."""
if side == 'buy':
self.inventory += quantity
self.pnl -= price * quantity
elif side == 'sell':
self.inventory -= quantity
self.pnl += price * quantity
self.trade_history.append({
'side': side,
'price': price,
'quantity': quantity,
'inventory_after': self.inventory,
'pnl_after': self.pnl
})
def should_quote(self) -> dict:
"""Determine whether to quote each side based on inventory limits."""
return {
'bid': self.inventory < self.max_inventory,
'ask': self.inventory > -self.max_inventory
}
29.4.4 Inventory Limits and Circuit Breakers
Prudent market makers enforce hard limits on inventory:
- Position limits. Never allow $|q| > Q_{\max}$. When the limit is reached, withdraw the quote on the side that would increase inventory.
- Loss limits. If unrealized P&L drops below a threshold, widen spreads or withdraw entirely.
- Correlation limits. When market-making across multiple correlated markets, limit aggregate directional exposure.
29.5 Subsidized Market Making
29.5.1 Why Subsidize?
In many prediction markets, natural trading volume is insufficient to support profitable market making. The adverse selection component of the spread exceeds what uninformed traders are willing to pay, leading to a market failure: no one provides liquidity, prices stagnate, and the market fails to aggregate information.
Subsidized market making addresses this by having the platform (or a sponsor) compensate market makers for expected losses. Common subsidy mechanisms include:
- Direct payments. The platform pays market makers a fixed fee per unit of time they maintain quotes within specified parameters (spread width, quantity, uptime).
- Loss protection. The platform reimburses market makers for losses up to a specified amount.
- Automated market makers (AMMs). The platform itself acts as the market maker, funding losses from a subsidy pool.
- Fee rebates. Market makers receive a rebate (or negative fee) on their trades, effectively narrowing their cost basis.
- Liquidity mining. Market makers earn tokens or rewards proportional to the liquidity they provide.
29.5.2 Optimal Subsidy Design
A well-designed subsidy should:
- Minimize cost while achieving a target level of liquidity (spread width, depth, uptime).
- Attract market makers without creating perverse incentives (e.g., quote stuffing, self-trading).
- Scale down as natural volume grows and market making becomes self-sustaining.
The platform's problem can be formalized as:
$$ \min_{s_{\text{sub}}} \quad \mathbb{E}[\text{subsidy cost}] \quad \text{s.t.} \quad s_{\text{quoted}} \leq s_{\max}, \quad \text{depth} \geq D_{\min}, \quad \text{uptime} \geq U_{\min} $$
where $s_{\text{sub}}$ is the subsidy rate, $s_{\max}$ is the maximum acceptable spread, $D_{\min}$ is the minimum depth requirement, and $U_{\min}$ is the minimum uptime fraction.
29.5.3 Hanson's Logarithmic Market Scoring Rule as Subsidized Market Making
Robin Hanson's Logarithmic Market Scoring Rule (LMSR) can be understood as a form of subsidized market making. The LMSR operates as an automated market maker with a cost function:
$$ C(\mathbf{q}) = b \cdot \ln\left(\sum_{i} e^{q_i / b}\right) $$
where $\mathbf{q}$ is the vector of outstanding shares for each outcome and $b$ is the liquidity parameter. The maximum loss of the LMSR is bounded by $b \cdot \ln(n)$ where $n$ is the number of outcomes. This bounded loss is the subsidy: the platform commits to losing at most this amount to ensure continuous liquidity.
Callout: LMSR Loss Bound
For a binary market ($n = 2$) with liquidity parameter $b$: $$\text{Maximum subsidy} = b \cdot \ln(2) \approx 0.693 \cdot b$$ Setting $b = 100$ means the platform commits to at most $\$69.31$ in losses to provide liquidity. Larger $b$ means tighter spreads but higher potential losses.
The LMSR price for outcome $i$ given current share vector $\mathbf{q}$ is:
$$ p_i(\mathbf{q}) = \frac{e^{q_i / b}}{\sum_j e^{q_j / b}} $$
The instantaneous spread (the cost of buying and then immediately selling an infinitesimal quantity) is related to the second derivative of the cost function and decreases with $b$.
29.5.4 Subsidized Market Making in Practice
Platforms like Polymarket, Metaculus, and various corporate prediction markets use different subsidy approaches:
Polymarket uses an AMM (based on constant-function principles) combined with a central limit order book. Liquidity providers deposit funds into pools and earn trading fees, but bear the risk of adverse selection. The platform occasionally provides additional incentives for key markets.
Metaculus uses a scoring-rule approach rather than a traditional order book, which implicitly subsidizes information revelation by rewarding accurate forecasts.
Corporate prediction markets often use LMSR or similar mechanisms with explicit subsidies, since the value of information to the organization far exceeds the subsidy cost.
import numpy as np
class LMSRMarketMaker:
"""
Logarithmic Market Scoring Rule automated market maker.
This is a subsidized market maker: the platform funds the maximum
possible loss of b * ln(n) to ensure continuous liquidity.
"""
def __init__(self, n_outcomes: int = 2, b: float = 100.0):
self.n = n_outcomes
self.b = b
self.shares = np.zeros(n_outcomes) # outstanding shares per outcome
self.max_loss = b * np.log(n_outcomes)
self.total_revenue = 0.0
def cost(self, shares: np.ndarray = None) -> float:
"""Compute the LMSR cost function C(q)."""
if shares is None:
shares = self.shares
return self.b * np.log(np.sum(np.exp(shares / self.b)))
def prices(self) -> np.ndarray:
"""Current prices for all outcomes."""
exp_q = np.exp(self.shares / self.b)
return exp_q / np.sum(exp_q)
def price_for_outcome(self, outcome: int) -> float:
"""Current price for a specific outcome."""
return self.prices()[outcome]
def cost_to_buy(self, outcome: int, quantity: float) -> float:
"""
Compute the cost to buy 'quantity' shares of 'outcome'.
The cost is C(q + delta) - C(q) where delta has 'quantity'
in the specified outcome position.
"""
new_shares = self.shares.copy()
new_shares[outcome] += quantity
return self.cost(new_shares) - self.cost()
def buy(self, outcome: int, quantity: float) -> float:
"""
Execute a purchase of 'quantity' shares of 'outcome'.
Returns the cost paid.
"""
cost = self.cost_to_buy(outcome, quantity)
self.shares[outcome] += quantity
self.total_revenue += cost
return cost
def sell(self, outcome: int, quantity: float) -> float:
"""
Sell 'quantity' shares of 'outcome'. Returns the revenue received.
"""
revenue = -self.cost_to_buy(outcome, -quantity)
self.shares[outcome] -= quantity
self.total_revenue -= revenue
return revenue
def current_loss(self) -> float:
"""Current unrealized loss of the market maker (subsidy used so far)."""
# Loss = cost function value - total revenue collected
return self.cost() - self.total_revenue
def spread(self, outcome: int, quantity: float = 1.0) -> float:
"""Compute the bid-ask spread for a given quantity."""
cost_buy = self.cost_to_buy(outcome, quantity)
cost_sell = self.cost_to_buy(outcome, -quantity)
# Spread = cost_to_buy - revenue_from_sell per unit
return (cost_buy + cost_sell) / quantity
def summary(self) -> dict:
"""Return a summary of the market maker's current state."""
return {
'prices': self.prices().tolist(),
'shares_outstanding': self.shares.tolist(),
'total_revenue': self.total_revenue,
'current_loss': self.current_loss(),
'max_possible_loss': self.max_loss,
'subsidy_utilization': self.current_loss() / self.max_loss
}
29.6 Building a Market Making Bot
29.6.1 Architecture Overview
A production market-making bot consists of several interacting components:
┌─────────────────┐
│ Market Data │
│ Feed Handler │
└────────┬────────┘
│
┌────────▼────────┐
│ Fair Value │
│ Estimator │
└────────┬────────┘
│
┌──────────────▼──────────────┐
│ Quote Engine │
│ ┌─────────┐ ┌──────────┐ │
│ │ Spread │ │ Inventory│ │
│ │ Model │ │ Manager │ │
│ └─────────┘ └──────────┘ │
└──────────────┬──────────────┘
│
┌────────▼────────┐
│ Order Manager │
│ (API Client) │
└────────┬────────┘
│
┌────────▼────────┐
│ Risk Manager │
└─────────────────┘
29.6.2 Core Components
Fair Value Estimator. The most critical component. The market maker must maintain a continuous estimate of the true probability, incorporating: - Current order book mid-price - Recent trade flow (order flow imbalance) - External signals (polls, news, model predictions) - Cross-market information (related markets, complements)
Spread Model. Determines the base spread width as a function of: - Estimated adverse selection ($\alpha$) - Current volatility ($\sigma$) - Time to resolution ($T - t$) - Target fill rate
Inventory Manager. Adjusts quotes to manage directional exposure (see Section 29.4).
Order Manager. Handles the mechanics of placing, canceling, and modifying orders via the exchange API.
Risk Manager. Monitors P&L, inventory, and market conditions, triggering spread widening or quote withdrawal when necessary.
29.6.3 A Complete Bot Implementation
The following code implements a simplified but functional market-making bot:
import time
import numpy as np
from dataclasses import dataclass, field
from typing import Optional, List, Dict
from enum import Enum
class Side(Enum):
BUY = 'buy'
SELL = 'sell'
@dataclass
class Order:
order_id: str
side: Side
price: float
quantity: int
filled: int = 0
status: str = 'open'
@dataclass
class MarketState:
best_bid: float
best_ask: float
mid_price: float
last_trade_price: float
last_trade_side: Optional[Side]
volume_24h: float
time_to_resolution: float # in days
class MarketMakerBot:
"""
A market-making bot for prediction markets.
"""
def __init__(self, config: dict):
# Configuration
self.market_id = config.get('market_id', 'default')
self.base_spread = config.get('base_spread', 0.04)
self.max_inventory = config.get('max_inventory', 500)
self.order_size = config.get('order_size', 10)
self.gamma = config.get('gamma', 0.1) # risk aversion
self.alpha_estimate = config.get('alpha_estimate', 0.2) # informed fraction
self.min_spread = config.get('min_spread', 0.02)
self.max_spread = config.get('max_spread', 0.15)
self.max_loss = config.get('max_loss', -200.0)
# State
self.inventory = 0
self.pnl = 0.0
self.realized_pnl = 0.0
self.fair_value = 0.5
self.active_orders: Dict[str, Order] = {}
self.trade_log: List[dict] = []
self.order_counter = 0
# Adverse selection tracking
self.recent_trades: List[dict] = []
self.adverse_selection_window = 50
def estimate_fair_value(self, market: MarketState) -> float:
"""
Estimate fair value from market data and recent trade flow.
Uses an exponentially weighted combination of mid-price
and order-flow-adjusted estimate.
"""
# Start with mid-price
fv = market.mid_price
# Adjust for order flow imbalance
if len(self.recent_trades) >= 5:
recent = self.recent_trades[-20:]
buy_volume = sum(t['quantity'] for t in recent if t['side'] == 'buy')
sell_volume = sum(t['quantity'] for t in recent if t['side'] == 'sell')
total = buy_volume + sell_volume
if total > 0:
imbalance = (buy_volume - sell_volume) / total
# Shift fair value toward the side with more flow
fv += 0.01 * imbalance
# Clamp to valid probability range
fv = max(0.01, min(0.99, fv))
# Smooth with previous estimate
self.fair_value = 0.7 * fv + 0.3 * self.fair_value
return self.fair_value
def compute_spread(self, market: MarketState) -> float:
"""
Compute the optimal spread width.
"""
# Base spread from adverse selection
base = self.base_spread
# Widen spread based on estimated adverse selection
alpha_adjustment = 1.0 + 2.0 * self.alpha_estimate
spread = base * alpha_adjustment
# Widen when inventory is large
inv_ratio = abs(self.inventory) / self.max_inventory
inventory_adjustment = 1.0 + inv_ratio
spread *= inventory_adjustment
# Narrow as time to resolution decreases (capture remaining spread)
if market.time_to_resolution < 1.0:
# Widen near expiry due to binary payoff risk
spread *= (1.0 + 2.0 * (1.0 - market.time_to_resolution))
# Widen when price is near 0 or 1 (extreme adverse selection)
p = self.fair_value
extremity = 1.0 + 4.0 * (0.25 - (p - 0.5)**2) # peaks at p=0.5
spread *= max(0.5, min(2.0, extremity))
return max(self.min_spread, min(self.max_spread, spread))
def compute_skew(self, market: MarketState) -> float:
"""
Compute inventory skew for bid/ask adjustment.
"""
sigma = self._estimate_volatility(market)
time_rem = max(market.time_to_resolution, 0.01)
skew = -self.gamma * self.inventory * sigma**2 * time_rem
return skew
def _estimate_volatility(self, market: MarketState) -> float:
"""Estimate price volatility from recent trades."""
if len(self.recent_trades) < 10:
# Default: use variance of Bernoulli with p = fair_value
p = self.fair_value
return np.sqrt(p * (1 - p))
prices = [t['price'] for t in self.recent_trades[-50:]]
returns = np.diff(prices)
return max(np.std(returns), 0.01)
def generate_quotes(self, market: MarketState) -> tuple:
"""
Generate bid and ask quotes.
Returns
-------
tuple
(bid_price, ask_price) or (None, None) if not quoting
"""
# Check risk limits
unrealized = self._unrealized_pnl(market)
if self.realized_pnl + unrealized < self.max_loss:
return None, None # Stop quoting
fv = self.estimate_fair_value(market)
spread = self.compute_spread(market)
skew = self.compute_skew(market)
bid = fv - spread / 2 + skew
ask = fv + spread / 2 + skew
# Don't quote bid if at max long inventory
if self.inventory >= self.max_inventory:
bid = None
# Don't quote ask if at max short inventory
if self.inventory <= -self.max_inventory:
ask = None
# Clamp to valid range
if bid is not None:
bid = max(0.01, min(0.98, bid))
if ask is not None:
ask = max(0.02, min(0.99, ask))
# Ensure bid < ask
if bid is not None and ask is not None and bid >= ask:
center = (bid + ask) / 2
bid = center - self.min_spread / 2
ask = center + self.min_spread / 2
return bid, ask
def on_fill(self, side: Side, price: float, quantity: int):
"""Handle a fill (execution) of our order."""
if side == Side.BUY:
self.inventory += quantity
self.pnl -= price * quantity
else:
self.inventory -= quantity
self.pnl += price * quantity
self.trade_log.append({
'side': side.value,
'price': price,
'quantity': quantity,
'inventory': self.inventory,
'pnl': self.pnl,
'timestamp': time.time()
})
self.recent_trades.append({
'side': side.value,
'price': price,
'quantity': quantity,
'timestamp': time.time()
})
# Trim trade history
if len(self.recent_trades) > 200:
self.recent_trades = self.recent_trades[-100:]
def _unrealized_pnl(self, market: MarketState) -> float:
"""Compute unrealized P&L based on current mid-price."""
return self.inventory * market.mid_price
def get_status(self, market: MarketState) -> dict:
"""Return current status of the bot."""
unrealized = self._unrealized_pnl(market)
return {
'market_id': self.market_id,
'fair_value': self.fair_value,
'inventory': self.inventory,
'realized_pnl': self.realized_pnl,
'unrealized_pnl': unrealized,
'total_pnl': self.realized_pnl + unrealized + self.pnl,
'num_trades': len(self.trade_log),
'active_orders': len(self.active_orders),
'alpha_estimate': self.alpha_estimate
}
29.7 Quote Strategies
29.7.1 Symmetric Quoting
The simplest strategy: post bid and ask symmetrically around the fair value estimate.
$$ \text{bid} = \hat{p} - \frac{s}{2}, \qquad \text{ask} = \hat{p} + \frac{s}{2} $$
Pros: Simple, easy to understand, no bias. Cons: Ignores inventory risk, accumulates positions rapidly under asymmetric flow.
29.7.2 Skewed Quoting (Inventory-Based)
As described in Section 29.4.3, shift both quotes in the direction that reduces inventory:
$$ \text{bid} = \hat{p} - \frac{s}{2} - \kappa q, \qquad \text{ask} = \hat{p} + \frac{s}{2} - \kappa q $$
29.7.3 Asymmetric Spread Quoting
Instead of shifting both quotes, widen the spread on the side that would increase inventory and narrow it on the other:
$$ \text{bid} = \hat{p} - \frac{s}{2} \cdot (1 + \eta \cdot q), \qquad \text{ask} = \hat{p} + \frac{s}{2} \cdot (1 - \eta \cdot q) $$
where $\eta$ controls the asymmetry. When $q > 0$ (long), the bid widens (less aggressive buying) and the ask narrows (more aggressive selling).
29.7.4 Layered Quoting
Post multiple price levels with smaller quantities at each:
| Level | Bid Price | Bid Qty | Ask Price | Ask Qty |
|---|---|---|---|---|
| 1 | 0.48 | 5 | 0.52 | 5 |
| 2 | 0.46 | 10 | 0.54 | 10 |
| 3 | 0.43 | 20 | 0.57 | 20 |
Layered quoting provides depth while limiting exposure at any single price. It also captures more spread from large orders that walk the book.
29.7.5 Dynamic Spread Width
Adjust spread width in real-time based on market conditions:
def dynamic_spread(base_spread: float, volatility: float,
adverse_selection_score: float,
time_to_resolution: float,
current_inventory: int,
max_inventory: int) -> float:
"""
Compute dynamically adjusted spread.
Parameters
----------
base_spread : float
Minimum spread width.
volatility : float
Recent price volatility.
adverse_selection_score : float
Score from 0 to 1 indicating adverse selection severity.
time_to_resolution : float
Time remaining until market resolution (days).
current_inventory : int
Current inventory position.
max_inventory : int
Maximum allowed inventory.
Returns
-------
float
Adjusted spread width.
"""
spread = base_spread
# Volatility component: wider spread in volatile markets
vol_multiplier = 1.0 + 5.0 * volatility
spread *= vol_multiplier
# Adverse selection component
as_multiplier = 1.0 + 3.0 * adverse_selection_score
spread *= as_multiplier
# Inventory component: wider when inventory is large
inv_ratio = abs(current_inventory) / max(max_inventory, 1)
inv_multiplier = 1.0 + 2.0 * inv_ratio ** 2
spread *= inv_multiplier
# Time component: wider near expiry (binary payoff risk)
if time_to_resolution < 2.0:
time_multiplier = 1.0 + (2.0 - time_to_resolution) * 0.5
spread *= time_multiplier
return spread
29.7.6 Signal-Based Quoting
Incorporate external signals to adjust the fair value estimate and asymmetrically position quotes:
- Poll updates. When a new poll is released, shift fair value before the market fully adjusts.
- News sentiment. Monitor news feeds and adjust quotes preemptively.
- Cross-market signals. If a related market moves, anticipate a corresponding move in the current market.
Warning: Signal-Based Quoting Risks
Signal-based quoting transforms the market maker into a speculative trader. If signals are wrong or stale, the market maker will accumulate inventory on the wrong side. Use signals cautiously and always maintain inventory limits.
29.8 Adverse Selection Detection
29.8.1 Measuring Adverse Selection
Market makers need to detect when they are being adversely selected—that is, when their counterparties have superior information. Key metrics include:
1. Trade-to-Mid Reversion. After a trade at the ask (a buy), does the mid-price subsequently rise (confirming the trade was informed) or revert (suggesting the trade was uninformed)?
$$ \text{Toxicity}(k) = \frac{1}{N} \sum_{i=1}^{N} \text{sign}(q_i) \cdot (m_{t_i + k} - m_{t_i}) $$
where $q_i$ is the direction of trade $i$ and $m_{t_i + k}$ is the mid-price $k$ trades later. Positive toxicity indicates adverse selection.
2. Volume-Synchronized Probability of Informed Trading (VPIN). VPIN estimates the fraction of trading volume that is information-driven:
$$ \text{VPIN} = \frac{\sum_{n} |V_n^{\text{buy}} - V_n^{\text{sell}}|}{n \cdot V_{\text{bucket}}} $$
where $V_n^{\text{buy}}$ and $V_n^{\text{sell}}$ are the buy and sell volumes in volume bucket $n$, and $V_{\text{bucket}}$ is the bucket size.
3. Kyle's Lambda. Estimates the price impact per unit of order flow:
$$ \Delta m_t = \lambda \cdot \text{OFI}_t + \epsilon_t $$
where $\text{OFI}_t$ is the signed order flow imbalance. Higher $\lambda$ indicates more informed trading.
4. Realized Spread vs. Quoted Spread. When the realized spread is consistently below the quoted spread, the market maker is losing to informed flow.
import numpy as np
import pandas as pd
from typing import List, Tuple
class AdverseSelectionDetector:
"""
Detects and quantifies adverse selection in prediction market trades.
"""
def __init__(self, lookback_window: int = 100):
self.lookback = lookback_window
self.trades: List[dict] = []
self.mid_prices: List[Tuple[float, float]] = [] # (timestamp, mid)
def record_trade(self, timestamp: float, side: str, price: float,
quantity: int, mid_price: float):
"""Record a trade observation."""
self.trades.append({
'timestamp': timestamp,
'side': side,
'price': price,
'quantity': quantity,
'mid_price': mid_price
})
self.mid_prices.append((timestamp, mid_price))
def compute_toxicity(self, forward_trades: int = 5) -> float:
"""
Compute trade toxicity: average signed mid-price change
after each trade.
A positive value indicates adverse selection (price moves
in the direction of the trade).
"""
if len(self.trades) < forward_trades + 10:
return 0.0
toxicities = []
trades = self.trades[-(self.lookback + forward_trades):-forward_trades]
for i, trade in enumerate(trades):
idx = len(self.trades) - (self.lookback + forward_trades) + i
if idx + forward_trades >= len(self.trades):
break
current_mid = trade['mid_price']
future_mid = self.trades[idx + forward_trades]['mid_price']
sign = 1.0 if trade['side'] == 'buy' else -1.0
toxicity = sign * (future_mid - current_mid)
toxicities.append(toxicity)
return np.mean(toxicities) if toxicities else 0.0
def compute_vpin(self, bucket_size: int = 50) -> float:
"""
Compute Volume-Synchronized Probability of Informed Trading.
Parameters
----------
bucket_size : int
Volume per bucket.
Returns
-------
float
VPIN estimate (0 to 1).
"""
if len(self.trades) < bucket_size * 3:
return 0.5 # default when insufficient data
recent = self.trades[-self.lookback:]
# Create volume buckets
buckets = []
current_buy = 0
current_sell = 0
current_volume = 0
for trade in recent:
qty = trade['quantity']
if trade['side'] == 'buy':
current_buy += qty
else:
current_sell += qty
current_volume += qty
if current_volume >= bucket_size:
buckets.append((current_buy, current_sell))
current_buy = 0
current_sell = 0
current_volume = 0
if not buckets:
return 0.5
# Compute VPIN
imbalances = [abs(b - s) for b, s in buckets]
total_volume = sum(b + s for b, s in buckets)
if total_volume == 0:
return 0.5
vpin = sum(imbalances) / total_volume
return vpin
def compute_kyles_lambda(self) -> float:
"""
Estimate Kyle's lambda (price impact per unit of order flow).
Uses OLS regression of mid-price changes on signed order flow.
"""
if len(self.trades) < 20:
return 0.0
recent = self.trades[-self.lookback:]
mid_changes = []
order_flows = []
for i in range(1, len(recent)):
dm = recent[i]['mid_price'] - recent[i-1]['mid_price']
sign = 1.0 if recent[i]['side'] == 'buy' else -1.0
ofi = sign * recent[i]['quantity']
mid_changes.append(dm)
order_flows.append(ofi)
X = np.array(order_flows)
y = np.array(mid_changes)
# OLS: lambda = cov(y, X) / var(X)
if np.var(X) < 1e-10:
return 0.0
lam = np.cov(y, X)[0, 1] / np.var(X)
return max(lam, 0.0) # lambda should be non-negative
def compute_realized_spread(self, delay_trades: int = 5) -> float:
"""
Compute average realized spread.
Returns the mean realized spread. A value smaller than the
quoted spread indicates adverse selection.
"""
if len(self.trades) < delay_trades + 10:
return 0.0
realized_spreads = []
trades = self.trades[-(self.lookback + delay_trades):-delay_trades]
for i, trade in enumerate(trades):
idx = len(self.trades) - (self.lookback + delay_trades) + i
if idx + delay_trades >= len(self.trades):
break
sign = 1.0 if trade['side'] == 'buy' else -1.0
future_mid = self.trades[idx + delay_trades]['mid_price']
rs = 2.0 * sign * (trade['price'] - future_mid)
realized_spreads.append(rs)
return np.mean(realized_spreads) if realized_spreads else 0.0
def adverse_selection_score(self) -> dict:
"""
Compute a comprehensive adverse selection score.
Returns
-------
dict
Dictionary with individual metrics and overall score.
"""
toxicity = self.compute_toxicity()
vpin = self.compute_vpin()
lam = self.compute_kyles_lambda()
realized = self.compute_realized_spread()
# Normalize metrics to [0, 1] scale
# Toxicity: typically in [-0.05, 0.05]
toxicity_score = min(max(toxicity / 0.05, 0), 1)
# VPIN: already in [0, 1], higher means more informed
vpin_score = vpin
# Lambda: normalize by typical value
lambda_score = min(lam / 0.01, 1)
# Realized spread: negative is bad
spread_score = max(0, 1 - realized / 0.02) if realized > 0 else 1.0
# Overall score: weighted average
overall = (
0.3 * toxicity_score +
0.3 * vpin_score +
0.2 * lambda_score +
0.2 * spread_score
)
return {
'toxicity': toxicity,
'toxicity_score': toxicity_score,
'vpin': vpin,
'vpin_score': vpin_score,
'kyles_lambda': lam,
'lambda_score': lambda_score,
'realized_spread': realized,
'spread_score': spread_score,
'overall_score': min(max(overall, 0), 1)
}
29.8.2 Responding to Adverse Selection
When adverse selection is detected, the market maker should:
- Widen spreads — The most direct response. Wider spreads reduce the market maker's loss per informed trade.
- Reduce order size — Smaller quotes limit the amount an informed trader can extract.
- Shift fair value — If trades consistently push the mid-price in one direction, the market maker should update its fair value estimate more aggressively.
- Temporarily withdraw — In extreme cases (e.g., VPIN spikes above 0.9), the market maker should pull all quotes until conditions normalize.
- Correlate with external events — If adverse selection spikes coincide with known information events (debate performances, court decisions), the market maker should anticipate these and pre-widen spreads.
29.9 Market Making in AMM-Based Markets
29.9.1 Providing Liquidity to Constant-Function AMMs
Many prediction market platforms use automated market makers (AMMs) based on constant-function designs (see Chapter 12). In these systems, providing liquidity means depositing tokens into a liquidity pool rather than posting individual orders.
The key AMM designs used in prediction markets include:
Constant Product (Uniswap-style):
$$ x \cdot y = k $$
where $x$ and $y$ are reserves of the two outcome tokens. The price of token $x$ in terms of token $y$ is $p_x = y / x$.
Constant Sum (bounded):
$$ x + y = k \quad \text{(within bounds)} $$
This provides zero-slippage trading within the bounds but can be fully depleted.
LMSR (as discussed in Section 29.5.3): The LMSR can be viewed as an AMM where the platform itself is the liquidity provider.
29.9.2 Impermanent Loss in Prediction Markets
Liquidity providers in AMM-based prediction markets face impermanent loss (IL): the difference between the value of their pool share and the value they would have obtained by simply holding the deposited tokens.
For a binary prediction market with constant-product AMM, impermanent loss as a function of price change is:
$$ \text{IL}(r) = \frac{2\sqrt{r}}{1 + r} - 1 $$
where $r = p_{\text{final}} / p_{\text{initial}}$ is the price ratio. For prediction markets, IL is bounded because prices are bounded in $[0, 1]$.
However, prediction markets have a unique wrinkle: at resolution, one outcome goes to $1$ and the other goes to $0$. This means the AMM liquidity provider is guaranteed to experience maximum impermanent loss at resolution:
$$ \text{IL at resolution} = \frac{2\sqrt{0}}{1 + 0} - 1 = -1 $$
In practice this means the LP loses their entire position in the losing outcome token. This loss must be offset by trading fees earned during the market's lifetime.
Key Insight: In prediction market AMMs, liquidity provision is fundamentally a bet that trading fees will exceed the guaranteed impermanent loss at resolution. This makes LP economics in prediction markets very different from perpetual AMMs like Uniswap.
29.9.3 Concentrated Liquidity for Prediction Markets
Concentrated liquidity (pioneered by Uniswap V3) allows LPs to focus their capital within specific price ranges. In prediction markets, this is particularly powerful:
- A market maker who believes the fair price is near $0.50$ can concentrate liquidity in the $[0.40, 0.60]$ range, earning fees more efficiently.
- As the probability shifts, the LP can rebalance their range to track the current price.
- Near resolution, when the price approaches $0$ or $1$, providing concentrated liquidity is extremely capital-efficient but also extremely risky.
class ConcentratedLiquidityProvider:
"""
Simulates concentrated liquidity provision in a prediction market AMM.
"""
def __init__(self, lower_tick: float, upper_tick: float,
liquidity: float):
"""
Parameters
----------
lower_tick : float
Lower bound of the price range (e.g., 0.40).
upper_tick : float
Upper bound of the price range (e.g., 0.60).
liquidity : float
Amount of liquidity to provide.
"""
self.lower = lower_tick
self.upper = upper_tick
self.liquidity = liquidity
self.fees_earned = 0.0
self.initial_value = None
def is_in_range(self, price: float) -> bool:
"""Check if current price is within the LP's range."""
return self.lower <= price <= self.upper
def compute_position_value(self, price: float) -> float:
"""
Compute the value of the LP position at a given price.
For a constant-product AMM with concentrated liquidity:
- If price < lower: all in token Y (the 'No' outcome)
- If price > upper: all in token X (the 'Yes' outcome)
- If lower <= price <= upper: mix of both tokens
"""
if price <= self.lower:
# All in "No" token, value = liquidity * (1/sqrt(lower) - 1/sqrt(upper))
value = self.liquidity * (1/np.sqrt(self.lower) - 1/np.sqrt(self.upper))
return value * (1 - price) # No token pays (1-price) at settlement
elif price >= self.upper:
# All in "Yes" token
value = self.liquidity * (np.sqrt(self.upper) - np.sqrt(self.lower))
return value * price
else:
# Mixed position
yes_tokens = self.liquidity * (np.sqrt(price) - np.sqrt(self.lower))
no_tokens = self.liquidity * (1/np.sqrt(price) - 1/np.sqrt(self.upper))
return yes_tokens * price + no_tokens * (1 - price)
def compute_impermanent_loss(self, current_price: float,
initial_price: float) -> float:
"""
Compute impermanent loss relative to holding.
"""
current_value = self.compute_position_value(current_price)
# Value of holding original tokens
# At initial_price, the position had some mix of tokens
if self.initial_value is None:
self.initial_value = self.compute_position_value(initial_price)
# Hold value: initial tokens at current prices
# Simplified: assume the LP deposited value equal to initial_value
hold_value = self.initial_value * (
current_price / initial_price * 0.5 +
(1 - current_price) / (1 - initial_price) * 0.5
)
if hold_value == 0:
return 0.0
il = (current_value / hold_value) - 1
return il
def earn_fees(self, trade_volume: float, fee_rate: float,
price: float):
"""Record fees earned from trading volume."""
if self.is_in_range(price):
self.fees_earned += trade_volume * fee_rate
def net_pnl(self, current_price: float) -> float:
"""Net P&L including fees and impermanent loss."""
position_value = self.compute_position_value(current_price)
return position_value + self.fees_earned - (self.initial_value or position_value)
29.10 Risk Management for Market Makers
29.10.1 Sources of Risk
Market makers in prediction markets face several distinct risks:
-
Inventory risk. The risk that the market moves against the market maker's accumulated position. In binary markets, this is existential: if the market maker is long 1,000 contracts at $0.50$ and the event does not occur, the loss is $500.
-
Adverse selection risk. The risk of consistently trading against informed counterparties. This manifests as negative realized spreads and persistent inventory buildup on the wrong side.
-
Liquidity risk. The risk that the market maker cannot exit a position at reasonable prices. In prediction markets, this is mitigated by the finite contract payoff but exacerbated by thin order books.
-
Operational risk. Technology failures, API outages, bugs in quoting logic, and network latency can all cause the market maker to be picked off at stale prices.
-
Resolution risk. The risk that the market resolves in an unexpected way (disputed outcomes, rule ambiguities, platform failures).
-
Correlation risk. When market-making across multiple markets, seemingly independent positions may be correlated (e.g., multiple markets on the same election).
29.10.2 Risk Metrics
Key risk metrics for prediction market makers:
Value at Risk (VaR):
$$ \text{VaR}_\alpha = -\inf\{x : \Pr(\text{P\&L} \leq x) \leq \alpha\} $$
For a binary contract position of $q$ contracts at price $p$:
$$ \text{VaR}_{0.05} = \begin{cases} q \cdot p & \text{if } q > 0 \text{ (long, event doesn't occur)} \\ |q| \cdot (1 - p) & \text{if } q < 0 \text{ (short, event occurs)} \end{cases} $$
Maximum Drawdown: The largest peak-to-trough decline in cumulative P&L.
Inventory Turnover: How frequently the market maker's inventory turns over. Low turnover suggests the market maker is warehousing risk rather than facilitating two-sided flow.
Sharpe Ratio of Market Making:
$$ \text{SR} = \frac{\mathbb{E}[\text{daily P\&L}]}{\text{std}[\text{daily P\&L}]} $$
29.10.3 Risk Limits and Controls
from dataclasses import dataclass
from typing import Optional
@dataclass
class RiskLimits:
"""Risk limits for a market-making operation."""
max_position_per_market: int = 500
max_total_position: int = 5000
max_daily_loss: float = -500.0
max_unrealized_loss: float = -300.0
max_adverse_selection_score: float = 0.8
min_spread_multiplier: float = 2.0 # multiply spread by this when AS is high
max_correlated_exposure: float = 2000.0
circuit_breaker_loss: float = -1000.0 # shut down all quoting
class RiskManager:
"""
Monitors and enforces risk limits for market-making operations.
"""
def __init__(self, limits: RiskLimits):
self.limits = limits
self.daily_pnl = 0.0
self.positions: dict = {} # market_id -> inventory
self.is_circuit_breaker_active = False
def check_order(self, market_id: str, side: str, quantity: int,
price: float) -> tuple:
"""
Check whether a proposed order passes risk limits.
Returns
-------
tuple
(approved: bool, reason: str or None)
"""
if self.is_circuit_breaker_active:
return False, "Circuit breaker active"
current_pos = self.positions.get(market_id, 0)
# Position limit per market
new_pos = current_pos + quantity if side == 'buy' else current_pos - quantity
if abs(new_pos) > self.limits.max_position_per_market:
return False, f"Position limit exceeded: {abs(new_pos)} > {self.limits.max_position_per_market}"
# Total position limit
total = sum(abs(v) for k, v in self.positions.items() if k != market_id) + abs(new_pos)
if total > self.limits.max_total_position:
return False, f"Total position limit exceeded: {total} > {self.limits.max_total_position}"
# Daily loss limit
if self.daily_pnl < self.limits.max_daily_loss:
return False, f"Daily loss limit exceeded: {self.daily_pnl:.2f}"
return True, None
def update_position(self, market_id: str, side: str, quantity: int,
price: float):
"""Update position tracking after a fill."""
current = self.positions.get(market_id, 0)
if side == 'buy':
self.positions[market_id] = current + quantity
else:
self.positions[market_id] = current - quantity
def update_pnl(self, realized_pnl_change: float):
"""Update daily P&L tracking."""
self.daily_pnl += realized_pnl_change
if self.daily_pnl < self.limits.circuit_breaker_loss:
self.is_circuit_breaker_active = True
def get_spread_multiplier(self, adverse_selection_score: float) -> float:
"""
Get spread multiplier based on adverse selection conditions.
"""
if adverse_selection_score > self.limits.max_adverse_selection_score:
return self.limits.min_spread_multiplier * 2
elif adverse_selection_score > 0.5:
# Linear interpolation
t = (adverse_selection_score - 0.5) / (self.limits.max_adverse_selection_score - 0.5)
return 1.0 + t * (self.limits.min_spread_multiplier - 1.0)
return 1.0
def reset_daily(self):
"""Reset daily counters (call at start of each trading day)."""
self.daily_pnl = 0.0
self.is_circuit_breaker_active = False
def get_risk_report(self, market_prices: dict) -> dict:
"""
Generate a risk report.
Parameters
----------
market_prices : dict
market_id -> current_mid_price
"""
positions = {}
total_exposure = 0.0
worst_case_loss = 0.0
for market_id, inventory in self.positions.items():
price = market_prices.get(market_id, 0.5)
exposure = abs(inventory) * price if inventory > 0 else abs(inventory) * (1 - price)
# Worst case: lose full value of position
wc = abs(inventory) * max(price, 1 - price)
positions[market_id] = {
'inventory': inventory,
'price': price,
'exposure': exposure,
'worst_case_loss': wc
}
total_exposure += exposure
worst_case_loss += wc
return {
'positions': positions,
'total_exposure': total_exposure,
'worst_case_loss': worst_case_loss,
'daily_pnl': self.daily_pnl,
'circuit_breaker_active': self.is_circuit_breaker_active,
'num_active_markets': len(self.positions)
}
29.11 Multi-Market Market Making
29.11.1 The Opportunity
Many prediction market platforms host hundreds or thousands of simultaneous markets. A market maker who can efficiently provide liquidity across many markets benefits from:
- Diversification. Losses in one market are offset by gains in others, reducing overall variance.
- Capital efficiency. The same capital can back positions across markets (subject to correlation limits).
- Information synergies. Cross-market relationships can improve fair value estimates.
29.11.2 Portfolio Approach to Market Making
The multi-market market maker treats their positions as a portfolio and optimizes at the portfolio level:
$$ \max_{\mathbf{s}, \mathbf{q}} \quad \mathbb{E}\left[\sum_{i=1}^{M} \text{P\&L}_i(\mathbf{s}_i, \mathbf{q}_i)\right] - \gamma \cdot \text{Var}\left[\sum_{i=1}^{M} \text{P\&L}_i\right] $$
where $\mathbf{s}_i$ is the spread vector for market $i$, $\mathbf{q}_i$ is the target inventory, $M$ is the number of markets, and $\gamma$ is risk aversion.
The variance term introduces correlations between markets:
$$ \text{Var}\left[\sum_{i} \text{P\&L}_i\right] = \sum_{i} \text{Var}[\text{P\&L}_i] + 2\sum_{i < j} \text{Cov}[\text{P\&L}_i, \text{P\&L}_j] $$
29.11.3 Correlation Structure in Prediction Markets
Markets may be correlated for several reasons:
- Same event, different framing. "Will party X win?" and "Will candidate Y become president?" are correlated if Y belongs to party X.
- Causal linkages. "Will the Fed raise rates?" and "Will inflation exceed 3%?" are correlated because they share underlying economic drivers.
- Common informed traders. If the same informed participants trade in multiple related markets, adverse selection is correlated.
import numpy as np
from typing import List, Dict
class MultiMarketMaker:
"""
Market making across multiple prediction markets with
portfolio-level risk management.
"""
def __init__(self, market_ids: List[str],
correlation_matrix: np.ndarray,
max_total_var: float = 10000.0,
gamma: float = 0.01):
self.market_ids = market_ids
self.n_markets = len(market_ids)
self.corr_matrix = correlation_matrix
self.max_total_var = max_total_var
self.gamma = gamma
# Per-market state
self.inventories = {m: 0 for m in market_ids}
self.fair_values = {m: 0.5 for m in market_ids}
self.base_spreads = {m: 0.04 for m in market_ids}
self.pnls = {m: 0.0 for m in market_ids}
def compute_portfolio_variance(self) -> float:
"""Compute portfolio variance of current inventory positions."""
inv_vector = np.array([self.inventories[m] for m in self.market_ids])
# Variance of each position: q^2 * p * (1-p) for binary outcomes
variances = np.array([
self.inventories[m]**2 * self.fair_values[m] * (1 - self.fair_values[m])
for m in self.market_ids
])
# Covariance matrix
std_devs = np.sqrt(np.maximum(variances, 1e-10))
cov_matrix = np.outer(std_devs, std_devs) * self.corr_matrix
# Portfolio variance
portfolio_var = inv_vector @ cov_matrix @ inv_vector
return portfolio_var
def compute_marginal_risk(self, market_id: str) -> float:
"""
Compute marginal risk contribution of adding inventory
in a specific market.
"""
idx = self.market_ids.index(market_id)
inv_vector = np.array([self.inventories[m] for m in self.market_ids])
variances = np.array([
self.inventories[m]**2 * self.fair_values[m] * (1 - self.fair_values[m])
for m in self.market_ids
])
std_devs = np.sqrt(np.maximum(variances, 1e-10))
cov_matrix = np.outer(std_devs, std_devs) * self.corr_matrix
# Marginal contribution to variance
marginal = 2 * cov_matrix[idx] @ inv_vector
return marginal
def adjust_spreads_for_correlation(self) -> Dict[str, float]:
"""
Adjust spreads based on correlation-adjusted risk.
Markets with high marginal risk contribution get wider spreads.
"""
adjusted_spreads = {}
portfolio_var = self.compute_portfolio_variance()
for m in self.market_ids:
marginal = abs(self.compute_marginal_risk(m))
base = self.base_spreads[m]
# Scale spread by marginal risk relative to average
if portfolio_var > 0:
risk_ratio = marginal / (portfolio_var / self.n_markets + 1e-10)
multiplier = 1.0 + self.gamma * max(0, risk_ratio - 1)
else:
multiplier = 1.0
adjusted_spreads[m] = base * min(multiplier, 3.0)
return adjusted_spreads
def allocate_capital(self, total_capital: float) -> Dict[str, float]:
"""
Allocate capital across markets using risk-parity approach.
Markets with lower risk get more capital (higher max inventory).
"""
# Compute per-market risk
risks = {}
for m in self.market_ids:
p = self.fair_values[m]
# Risk = volatility * abs(current inventory)
vol = np.sqrt(p * (1 - p))
risk = vol * (abs(self.inventories[m]) + 1)
risks[m] = risk
total_risk = sum(risks.values())
if total_risk == 0:
# Equal allocation
return {m: total_capital / self.n_markets for m in self.market_ids}
# Inverse-risk weighting (risk parity)
inv_risks = {m: 1.0 / max(r, 0.01) for m, r in risks.items()}
total_inv_risk = sum(inv_risks.values())
allocations = {
m: total_capital * inv_risks[m] / total_inv_risk
for m in self.market_ids
}
return allocations
def summary(self) -> dict:
"""Generate a summary of multi-market positions."""
portfolio_var = self.compute_portfolio_variance()
total_pnl = sum(self.pnls.values())
total_inventory = sum(abs(v) for v in self.inventories.values())
return {
'n_markets': self.n_markets,
'total_pnl': total_pnl,
'total_abs_inventory': total_inventory,
'portfolio_variance': portfolio_var,
'portfolio_std': np.sqrt(portfolio_var),
'sharpe_estimate': total_pnl / max(np.sqrt(portfolio_var), 0.01),
'per_market': {
m: {
'inventory': self.inventories[m],
'fair_value': self.fair_values[m],
'pnl': self.pnls[m]
}
for m in self.market_ids
}
}
29.11.4 Cross-Market Arbitrage and Information Flow
A multi-market maker is well-positioned to exploit cross-market inconsistencies:
-
If "Party X wins" is priced at $0.60$ and "Candidate Y (of Party X) wins" is priced at $0.65$, there may be an arbitrage (since the party winning is a necessary condition for the candidate winning in some electoral systems). The market maker can adjust quotes to profit from the mispricing.
-
Conditional markets: If a platform offers "X given Y" markets, the market maker can enforce consistency: $\Pr(X \mid Y) \cdot \Pr(Y) = \Pr(X \text{ and } Y)$.
-
Complement markets: Markets on mutually exclusive outcomes should sum to approximately 1. The market maker can provide tighter spreads when it can offset risk across complements.
29.12 Performance Measurement
29.12.1 P&L Attribution
A market maker's P&L can be decomposed into:
$$ \text{P\&L} = \underbrace{\text{Spread Capture}}_{\text{gross revenue}} - \underbrace{\text{Adverse Selection Loss}}_{\text{informed flow}} - \underbrace{\text{Inventory Marking}}_{\text{unrealized moves}} - \underbrace{\text{Costs}}_{\text{fees, tech}} $$
Spread capture is the sum of half-spreads earned on each trade:
$$ \text{Spread Capture} = \sum_{i: \text{buy fills}} \left(\frac{a_i - b_i}{2}\right) \cdot q_i + \sum_{j: \text{sell fills}} \left(\frac{a_j - b_j}{2}\right) \cdot q_j $$
Adverse selection loss is the difference between the trade price and the subsequent fair value:
$$ \text{AS Loss} = \sum_{i} |q_i| \cdot |\hat{p}_{t_i + \Delta} - p_{\text{fill},i}| $$
where $\hat{p}_{t_i + \Delta}$ is the fair value estimate some time after the trade.
29.12.2 Key Performance Indicators
| KPI | Formula | Target |
|---|---|---|
| Fill Rate | Fills / Quotes | 5-20% |
| Realized Spread | See Section 29.2.3 | > 0 |
| Inventory Turnover | Volume / Avg Inventory | > 2x/day |
| Win Rate | Profitable trades / Total trades | > 50% |
| Sharpe Ratio | Mean daily P&L / Std daily P&L | > 1.5 |
| Max Drawdown | Peak-to-trough P&L decline | < 20% of capital |
| Uptime | Time with active quotes / Total time | > 95% |
29.13 Practical Considerations
29.13.1 API Rate Limits and Latency
Prediction market platforms typically impose API rate limits that constrain how frequently a market maker can update quotes. Common approaches:
- Batch updates. Modify multiple orders in a single API call when supported.
- Event-driven updates. Only update quotes when market conditions change meaningfully (e.g., mid-price moves by more than a threshold).
- Stale quote detection. Monitor for situations where the market has moved but the market maker's quotes have not been updated due to rate limits.
29.13.2 Fee Structures
Platform fees significantly affect market-making profitability:
- Maker fees (charged on limit orders that provide liquidity) reduce spread capture.
- Taker fees (charged on market orders that remove liquidity) increase the effective cost for market makers' hedging trades.
- Fee tiers often reward high-volume market makers with lower fees.
A market maker with a base spread of $0.04$ and maker fees of $0.01$ only captures a net spread of $0.03$—a 25% reduction.
29.13.3 Market Selection
Not all markets are suitable for market making. A market maker should prefer markets with:
- Moderate volume. Enough flow to generate spread revenue but not so much that competition compresses spreads to zero.
- Moderate adverse selection. Some informed trading is necessary for price discovery, but excessive adverse selection makes market making unprofitable.
- Sufficient time to resolution. Markets resolving in hours give little time to earn back losses from adverse selection.
- Clear resolution criteria. Ambiguous resolution rules increase resolution risk.
- Low correlation with other positions. Diversification benefits are largest when markets are independent.
29.13.4 When to Stop Market Making
A disciplined market maker should stop quoting when:
- Daily loss limits are hit.
- Adverse selection metrics indicate toxic flow.
- Market conditions change fundamentally (e.g., a major news event makes the market maker's model obsolete).
- The market approaches resolution and binary payoff risk dominates.
- Platform issues (API outages, settlement concerns) create operational risk.
29.14 Summary
Market making is the mechanism that transforms prediction markets from theoretical constructs into practical information-aggregation tools. Without liquidity providers, prices stagnate, spreads widen, and the market's signal degrades.
The key insights from this chapter:
-
Market making is compensated risk-bearing. The bid-ask spread compensates for adverse selection, inventory risk, and operational costs. When these costs exceed what the market can support, subsidies are necessary.
-
The Glosten-Milgrom model provides the theoretical foundation for understanding how informed trading affects spreads. Each trade is a signal that updates the market maker's beliefs.
-
Inventory management is critical because prediction market positions cannot typically be hedged. The Avellaneda-Stoikov framework and quote skewing provide practical tools for managing directional exposure.
-
Subsidized market making (including LMSR and platform incentives) is essential for bootstrapping liquidity in thin markets. The subsidy is an investment in information aggregation.
-
Adverse selection detection allows market makers to dynamically adjust their behavior, widening spreads or withdrawing when informed flow is high.
-
Multi-market operations provide diversification benefits and capital efficiency, but require careful correlation management.
-
Risk management is non-negotiable. Position limits, loss limits, and circuit breakers prevent catastrophic losses in a domain where positions have binary payoffs.
The market maker occupies a unique position in the prediction market ecosystem: they are simultaneously a service provider (offering liquidity), a risk manager (warehousing temporary imbalances), and an information processor (updating prices based on order flow). Mastering all three roles is what separates profitable market makers from those who subsidize informed traders.
Next chapter: Chapter 30 — Scoring Rules and Proper Incentives