> "The order book is the beating heart of any market. Every bid, every ask, every trade tells a story about what participants believe and how urgently they believe it."
In This Chapter
- 7.1 What Is an Order Book?
- 7.2 The Continuous Double Auction
- 7.3 Order Types in Depth
- 7.4 Reading and Interpreting Depth Charts
- 7.5 Building an Order Book in Python
- 7.6 The Matching Engine
- 7.7 Market Quality Metrics
- 7.8 Order Book Dynamics and Information
- 7.9 Level 1 vs Level 2 Data
- 7.10 Prediction Market Order Books vs Traditional Finance
- 7.11 Practical Considerations
- 7.12 Chapter Summary
- What's Next
Chapter 7: Order Books and the Limit Order Market
"The order book is the beating heart of any market. Every bid, every ask, every trade tells a story about what participants believe and how urgently they believe it." -- Maureen O'Hara, Market Microstructure Theory
In Chapters 4 and 6 we explored how prediction markets translate beliefs into prices and how those prices carry informational value. But we glossed over something fundamental: the mechanism by which those prices actually form. When you place a bet on Polymarket that the next US presidential election will go to a particular candidate, what happens to your order? How does it find a counterparty? How does the price you see on your screen get determined?
The answer, for most modern prediction markets, is the order book -- a data structure that collects and organizes all outstanding buy and sell orders, matching them according to precise rules. Understanding the order book is not optional if you want to trade prediction markets seriously. It is the lens through which you see the market's true state: not just the last price, but the full landscape of supply and demand at every price level.
This chapter takes you deep inside the order book. We will start with definitions and visual intuition, work through the continuous double auction mechanism that drives order matching, catalog the zoo of order types you will encounter, and then build a fully functional order book and matching engine in Python. Along the way, we will develop tools to measure market quality and learn to read order book data the way experienced traders do.
By the end of this chapter, you will be able to:
- Explain what an order book is and why prediction markets use them
- Describe the continuous double auction and its matching rules
- Distinguish between limit orders, market orders, and other order types
- Read and interpret depth charts
- Build a working order book in Python from scratch
- Implement a price-time priority matching engine
- Calculate market quality metrics like spread, depth, and VWAP
- Identify information signals embedded in order book dynamics
7.1 What Is an Order Book?
7.1.1 Definition
An order book is an electronic ledger that records all outstanding buy and sell orders for a particular instrument -- in our case, a prediction market contract. Each entry in the book specifies at minimum:
- Side: whether it is a buy (bid) or sell (ask/offer)
- Price: the price the participant is willing to pay or accept
- Quantity: how many contracts (or shares) they want to trade
- Timestamp: when the order was placed
The order book is organized with bids (buy orders) on one side and asks (sell orders, also called offers) on the other. Bids are sorted from highest to lowest price -- the most aggressive buyers are at the top. Asks are sorted from lowest to highest -- the most aggressive sellers are at the top.
7.1.2 Visual Representation
Here is what a simple prediction market order book might look like for a contract "Will Event X occur?" priced between $0.00 and $1.00:
BIDS (Buy Orders) ASKS (Sell Orders)
Price Quantity Cum. Price Quantity Cum.
───── ──────── ──── ───── ──────── ────
$0.55 200 200 | $0.57 150 150
$0.54 350 550 | $0.58 100 250
$0.53 500 1,050 | $0.59 300 550
$0.52 150 1,200 | $0.60 200 750
$0.51 100 1,300 | $0.65 400 1,150
$0.50 800 2,100 | $0.70 250 1,400
In this example:
- The best bid (highest buy price) is $0.55 with 200 contracts waiting.
- The best ask (lowest sell price) is $0.57 with 150 contracts waiting.
- The bid-ask spread is $0.57 - $0.55 = $0.02 (2 cents).
- The midpoint price is ($0.55 + $0.57) / 2 = $0.56.
- The "Cum." column shows cumulative depth -- the total quantity available at that price or better.
7.1.3 Bid Side vs Ask Side
The bid side represents demand. Every bid is a statement: "I am willing to buy this contract at this price or lower." In a prediction market where "Yes" contracts pay $1.00 if an event occurs, a bid at $0.55 says: "I believe the probability of this event is at least 55%, and I am willing to put money behind that belief."
The ask side represents supply. Every ask says: "I am willing to sell this contract at this price or higher." An ask at $0.57 says: "I own this contract (or am willing to short it), and I will part with it for at least $0.57."
The gap between the best bid and best ask -- the spread -- represents the cost of immediacy. If you want to buy right now without waiting, you must pay the ask price. If you want to sell right now, you must accept the bid price. The spread is, in effect, the price you pay for the convenience of immediate execution.
7.1.4 The Spread
The spread is one of the most important metrics in any market. A narrow spread indicates:
- High liquidity: many participants competing to offer the best prices
- Agreement: buyers and sellers are close to consensus on value
- Low transaction costs: trading is cheap
A wide spread indicates:
- Low liquidity: few participants, or participants uncertain about value
- Disagreement: buyers and sellers have very different views
- High transaction costs: trading is expensive
In prediction markets, spreads tend to be wider than in highly liquid stock markets. A 2-5 cent spread on a prediction market contract is common, whereas major stocks might have spreads of a fraction of a penny. We will explore why this is the case in Section 7.10.
7.1.5 Why Prediction Markets Use Order Books
Not all prediction markets use order books. Some use automated market makers (AMMs), which we will cover in Chapter 8. But many of the most prominent platforms -- including Polymarket (via its CLOB, or Central Limit Order Book), Kalshi, and Betfair -- use order book mechanisms. There are several reasons:
-
Price discovery: Order books allow prices to be set by the actual supply and demand of participants, rather than by an algorithm. This tends to produce more accurate prices, especially when informed traders are present.
-
Flexibility: Traders can express precise price preferences. You can say "I want to buy at exactly $0.55" rather than accepting whatever price an AMM gives you.
-
Transparency: The full order book reveals the market's supply and demand landscape, giving participants valuable information about market sentiment.
-
Capital efficiency: In an order book, your capital is only committed when your order is matched. In many AMM designs, liquidity providers must lock capital continuously.
-
Tighter spreads with competition: When multiple market makers compete in an order book, they naturally tighten the spread, reducing costs for all participants.
7.1.6 Comparison to AMM-Based Markets
| Feature | Order Book (CLOB) | AMM |
|---|---|---|
| Price setting | By traders' orders | By mathematical formula |
| Spread | Variable, competition-driven | Formula-determined |
| Liquidity provision | Active (must place orders) | Passive (deposit into pool) |
| Capital efficiency | High (only committed on match) | Lower (locked in pool) |
| Complexity | Higher for participants | Lower for participants |
| Best for | Active traders, liquid markets | Bootstrapping, long-tail markets |
We will do a thorough comparison in Chapter 8. For now, just understand that order books are the gold standard for price discovery in markets with sufficient participation.
7.2 The Continuous Double Auction
7.2.1 How the CDA Works
The continuous double auction (CDA) is the mechanism that governs how orders are submitted, stored, and matched in an order book market. The word "continuous" means that orders can arrive at any time -- there is no batching or discrete auction periods. The word "double" means that both buyers and sellers can submit orders (as opposed to a single-sided auction like a traditional English auction).
The CDA operates according to the following rules:
- Order arrival: A new order arrives at the exchange (buy or sell, with a specified price and quantity).
- Matching check: The matching engine checks whether the new order can be immediately matched against existing orders on the opposite side of the book.
- If matchable: The order is executed (partially or fully) against the resting orders, generating one or more trades.
- If not matchable: The order is added to the book as a resting (passive) order, waiting for a future counterparty.
The key question is: when can an order be matched?
- A buy order at price $P_b$ can be matched against a sell order at price $P_a$ if $P_b \geq P_a$ (the buyer is willing to pay at least as much as the seller is asking).
- The trade executes at the resting order's price (the order that was already in the book), not the incoming order's price.
7.2.2 Price-Time Priority
When multiple resting orders could match against an incoming order, price-time priority determines which gets filled first:
- Price priority: The order with the best price gets matched first. For sell orders, the lowest price is best. For buy orders, the highest price is best.
- Time priority: Among orders at the same price, the one that arrived first gets matched first. This is also called FIFO (first in, first out).
This creates a fair and transparent system: if you want your order filled before others, you either offer a better price or you get in line earlier.
7.2.3 Order Matching Rules
Let us formalize the matching rules for a buy (bid) order arriving at the exchange:
function process_incoming_buy(order):
while order.remaining_quantity > 0:
best_ask = get_best_ask() # lowest-priced sell order
if best_ask is None:
break # no sell orders in the book
if order.price < best_ask.price:
break # buyer's price is below the best ask; no match
# Match found! Execute at the resting order's price
trade_quantity = min(order.remaining_quantity, best_ask.remaining_quantity)
trade_price = best_ask.price
execute_trade(buyer=order, seller=best_ask,
price=trade_price, quantity=trade_quantity)
# Update remaining quantities
order.remaining_quantity -= trade_quantity
best_ask.remaining_quantity -= trade_quantity
if best_ask.remaining_quantity == 0:
remove_from_book(best_ask)
if order.remaining_quantity > 0:
add_to_book(order) # rest the remaining quantity
The logic for an incoming sell (ask) order is symmetric: it matches against the best bid (highest buy price), and the trade executes at the bid price.
7.2.4 Partial Fills
Orders do not have to be filled all at once. Consider this scenario:
- Resting ask: 100 contracts at $0.60
- Incoming buy: 250 contracts at $0.60
The incoming buy will consume the entire resting ask (100 contracts at $0.60), and the remaining 150 contracts of the buy order will either: - Match against the next-best ask (if it exists and is at or below $0.60), or - Rest in the book as a bid at $0.60
This is a partial fill -- the incoming order was only partially matched.
7.2.5 Worked Example: Step-by-Step Order Processing
Let us trace through a sequence of orders arriving at an empty order book for a prediction market contract. All prices are in dollars, and quantities are in contracts.
Initial state: Empty order book.
Order 1: Alice places a BUY order: 100 contracts at $0.50. - No asks in the book. Order rests. - Book: Bids = [{Alice, $0.50, 100}], Asks = []
Order 2: Bob places a SELL order: 80 contracts at $0.55. - Best bid is $0.50 (Alice). Bob's ask at $0.55 > $0.50. No match. - Book: Bids = [{Alice, $0.50, 100}], Asks = [{Bob, $0.55, 80}]
Order 3: Carol places a BUY order: 50 contracts at $0.53. - Best ask is $0.55 (Bob). Carol's bid at $0.53 < $0.55. No match. - Book: Bids = [{Carol, $0.53, 50}, {Alice, $0.50, 100}], Asks = [{Bob, $0.55, 80}] - (Carol's bid is higher than Alice's, so Carol is at the top of the bid side.)
Order 4: Dave places a SELL order: 30 contracts at $0.52. - Best bid is $0.53 (Carol). Dave's ask at $0.52 <= $0.53. Match! - Trade: 30 contracts at $0.53 (Carol's resting price). Carol is partially filled. - Carol's remaining: 50 - 30 = 20 contracts at $0.53. - Dave's order is fully filled. - Book: Bids = [{Carol, $0.53, 20}, {Alice, $0.50, 100}], Asks = [{Bob, $0.55, 80}]
Order 5: Eve places a BUY order: 100 contracts at $0.56. - Best ask is $0.55 (Bob). Eve's bid at $0.56 >= $0.55. Match! - Trade: 80 contracts at $0.55 (Bob's resting price). Bob is fully filled. - Eve's remaining: 100 - 80 = 20 contracts. - No more asks in the book. Eve's remaining 20 contracts rest as a bid at $0.56. - Book: Bids = [{Eve, $0.56, 20}, {Carol, $0.53, 20}, {Alice, $0.50, 100}], Asks = []
Order 6: Frank places a SELL order: 200 contracts at $0.51. - Best bid is $0.56 (Eve). Frank's ask at $0.51 <= $0.56. Match! - Trade 1: 20 contracts at $0.56 (Eve's resting price). Eve is fully filled. - Frank's remaining: 200 - 20 = 180. - Next best bid is $0.53 (Carol). Frank's ask at $0.51 <= $0.53. Match! - Trade 2: 20 contracts at $0.53 (Carol's resting price). Carol is fully filled. - Frank's remaining: 180 - 20 = 160. - Next best bid is $0.50 (Alice). Frank's ask at $0.51 > $0.50. No match. - Frank's remaining 160 contracts rest as an ask at $0.51. - Book: Bids = [{Alice, $0.50, 100}], Asks = [{Frank, $0.51, 160}] - Spread: $0.51 - $0.50 = $0.01
This example illustrates several important concepts: price-time priority (Carol's higher bid gets matched before Alice's), partial fills (Carol and Eve are partially filled at different stages), and the trade price always being the resting order's price.
7.3 Order Types in Depth
7.3.1 Limit Orders
A limit order specifies a maximum buy price or minimum sell price. It says: "I want to trade, but only at this price or better."
- **Buy limit at $0.55**: "I will buy at $0.55 or lower."
- **Sell limit at $0.60**: "I will sell at $0.60 or higher."
Limit orders that do not immediately match rest in the order book, providing liquidity. This is why limit order submitters are often called makers -- they "make" the market by placing orders that others can trade against.
When a limit order does immediately match (because it crosses the spread), the submitter is acting as a taker -- they "take" liquidity from the book. For example, if the best ask is $0.57 and you submit a buy limit at $0.58, your order will immediately match against the ask at $0.57.
The distinction between maker and taker is economically important because many exchanges charge different fees:
| Role | Action | Typical Fee |
|---|---|---|
| Maker | Adds liquidity (order rests in book) | Lower (sometimes rebate) |
| Taker | Removes liquidity (order matches immediately) | Higher |
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
class Side(Enum):
BUY = "BUY"
SELL = "SELL"
class OrderType(Enum):
LIMIT = "LIMIT"
MARKET = "MARKET"
@dataclass
class LimitOrder:
"""A limit order with a specified price."""
order_id: str
side: Side
price: float
quantity: int
timestamp: datetime = field(default_factory=datetime.now)
def __repr__(self):
return (f"LimitOrder({self.side.value} {self.quantity}x "
f"@ ${self.price:.2f}, id={self.order_id})")
# Example usage:
buy_order = LimitOrder(
order_id="ORD001",
side=Side.BUY,
price=0.55,
quantity=100
)
print(buy_order)
# LimitOrder(BUY 100x @ $0.55, id=ORD001)
7.3.2 Market Orders
A market order says: "I want to trade right now at the best available price, whatever that is." It has no price limit -- it will match against whatever resting orders are available, sweeping through price levels if necessary.
Market orders guarantee execution (assuming there is liquidity) but not price. This makes them risky in illiquid markets, where a large market order can "walk the book," executing at progressively worse prices.
@dataclass
class MarketOrder:
"""A market order -- execute immediately at best available price."""
order_id: str
side: Side
quantity: int
timestamp: datetime = field(default_factory=datetime.now)
def __repr__(self):
return (f"MarketOrder({self.side.value} {self.quantity}x "
f"@ MARKET, id={self.order_id})")
Example of walking the book:
Suppose the ask side looks like this:
$0.55 100 contracts
$0.57 50 contracts
$0.60 200 contracts
A market buy order for 200 contracts would execute as: - 100 contracts at $0.55 - 50 contracts at $0.57 - 50 contracts at $0.60
The average price would be: (100 * 0.55 + 50 * 0.57 + 50 * 0.60) / 200 = $0.5685
This is significantly worse than the best ask of $0.55. The phenomenon is called slippage and is a major concern for large orders in thin markets.
7.3.3 Stop Orders
A stop order becomes active only when a specified trigger price is reached. Once triggered, it converts into either a market order (stop-market) or a limit order (stop-limit).
- **Stop-market buy at $0.65**: If the price rises to $0.65, submit a market buy.
- Stop-limit sell at $0.40, limit $0.38: If the price drops to $0.40, submit a limit sell at $0.38.
Stop orders are used for: - Stop-loss: Automatically exit a position if the market moves against you. - Breakout entry: Enter a position when the price moves past a key level.
@dataclass
class StopOrder:
"""A stop order that triggers when price reaches stop_price."""
order_id: str
side: Side
stop_price: float # Trigger price
quantity: int
limit_price: float = None # If None, becomes market order when triggered
is_triggered: bool = False
timestamp: datetime = field(default_factory=datetime.now)
def check_trigger(self, last_trade_price: float) -> bool:
"""Check if the stop condition has been met."""
if self.is_triggered:
return False # Already triggered
if self.side == Side.BUY and last_trade_price >= self.stop_price:
self.is_triggered = True
return True
elif self.side == Side.SELL and last_trade_price <= self.stop_price:
self.is_triggered = True
return True
return False
def to_active_order(self):
"""Convert to limit or market order once triggered."""
if self.limit_price is not None:
return LimitOrder(
order_id=self.order_id,
side=self.side,
price=self.limit_price,
quantity=self.quantity,
timestamp=self.timestamp
)
else:
return MarketOrder(
order_id=self.order_id,
side=self.side,
quantity=self.quantity,
timestamp=self.timestamp
)
7.3.4 Iceberg Orders
An iceberg order (also called a reserve order) is a large order that only shows a small "visible" portion in the order book, with the rest hidden. When the visible portion is filled, the next slice is automatically revealed.
Why use iceberg orders? If you want to buy 10,000 contracts and you show the full size, other traders will front-run you -- buying before you to sell to you at a higher price. By showing only 500 at a time, you reduce your market impact.
@dataclass
class IcebergOrder:
"""An order that reveals only a portion of its true size."""
order_id: str
side: Side
price: float
total_quantity: int
visible_quantity: int # Amount shown in the book
filled_quantity: int = 0
timestamp: datetime = field(default_factory=datetime.now)
@property
def remaining_quantity(self) -> int:
return self.total_quantity - self.filled_quantity
@property
def current_visible(self) -> int:
"""How many contracts are currently visible in the book."""
remaining = self.remaining_quantity
return min(self.visible_quantity, remaining)
def fill(self, quantity: int):
"""Fill a portion of the visible quantity."""
self.filled_quantity += quantity
# If visible portion is fully consumed, the next slice
# becomes visible automatically (handled by the matching engine)
Most prediction markets do not support iceberg orders natively, but the concept is important for understanding professional trading strategies.
7.3.5 Time-in-Force Conditions
Time-in-force (TIF) determines how long an order remains active:
GTC (Good 'Til Cancelled): The order stays in the book until it is either filled or explicitly cancelled by the trader. This is the most common default.
IOC (Immediate or Cancel): The order must be filled immediately (partially or fully). Any unfilled portion is cancelled. This is useful when you want to take whatever liquidity is available without leaving a resting order.
FOK (Fill or Kill): The order must be filled completely and immediately, or it is cancelled entirely. No partial fills allowed. This is useful when partial execution would be unacceptable (e.g., hedging strategies that require exact sizes).
class TimeInForce(Enum):
GTC = "GTC" # Good 'til cancelled
IOC = "IOC" # Immediate or cancel
FOK = "FOK" # Fill or kill
@dataclass
class Order:
"""Complete order with all attributes."""
order_id: str
side: Side
order_type: OrderType
price: float # Ignored for MARKET orders
quantity: int
time_in_force: TimeInForce = TimeInForce.GTC
timestamp: datetime = field(default_factory=datetime.now)
remaining_quantity: int = None
def __post_init__(self):
if self.remaining_quantity is None:
self.remaining_quantity = self.quantity
@property
def is_filled(self) -> bool:
return self.remaining_quantity == 0
@property
def filled_quantity(self) -> int:
return self.quantity - self.remaining_quantity
7.4 Reading and Interpreting Depth Charts
7.4.1 How to Read an Order Book Display
A typical order book display shows the bid and ask sides arranged around the spread. Platforms differ in layout, but the information is the same:
┌─────────────────────────────────────────┐
│ ORDER BOOK: EVENT X │
├──────────────┬───┬───────────────────────┤
│ BID (Buy) │ │ ASK (Sell) │
├──────────────┤ ├───────────────────────┤
│ 200 @ $0.55 │ ← │ $0.57 @ 150 │
│ 350 @ $0.54 │ │ $0.58 @ 100 │
│ 500 @ $0.53 │ │ $0.59 @ 300 │
│ 150 @ $0.52 │ │ $0.60 @ 200 │
│ 100 @ $0.51 │ │ $0.65 @ 400 │
│ 800 @ $0.50 │ │ $0.70 @ 250 │
└──────────────┴───┴───────────────────────┘
Spread: $0.02 | Midpoint: $0.56
The arrow (←) points to the best bid and best ask -- the most competitive prices. Everything flows outward from there: lower bids below, higher asks above.
7.4.2 Depth Charts
A depth chart is a graphical representation of the order book. It plots cumulative quantity on the Y-axis against price on the X-axis, creating two curves:
- The bid curve slopes upward to the left (more cumulative quantity at lower prices).
- The ask curve slopes upward to the right (more cumulative quantity at higher prices).
The gap between the two curves at the center is the spread.
Quantity
(cumulative)
│
2,100 ┤ ██
│ ██
1,400 ┤ ██ ████
1,200 ┤ ██ █████
1,050 ┤ ████ █████
750 ┤ ████ ████
550 ┤ ██████ ████
250 ┤ ██████ ████
200 ┤ ████████ ████
150 ┤ ████████ ██
│ ↑
├──────────┼─────────────────────────
$0.50 $0.56 $0.70
(midpoint)
Depth charts reveal at a glance:
- Where the liquidity is: Thick sections have lots of orders.
- Where the gaps are: Thin sections may indicate price levels where the market could move quickly.
- Support and resistance: Large clusters of bids act as "support" (buying pressure that may prevent the price from falling). Large clusters of asks act as "resistance" (selling pressure that may prevent the price from rising).
7.4.3 Identifying Support and Resistance
In the order book display from Section 7.4.1, notice that there are 800 contracts bid at $0.50. This is a significant "wall" of buy orders. For the price to drop below $0.50, someone would need to sell more than 2,100 contracts (the total cumulative bid depth). This concentration of orders at $0.50 represents a support level.
Similarly, the 400 contracts offered at $0.65 represent a **resistance level**. For the price to rise above $0.65, someone would need to buy through all the ask depth below it.
However, be cautious: order book support and resistance can be illusory. Large orders can be cancelled at any moment (this is called spoofing -- see Section 7.8), and the depth chart can change dramatically in seconds.
7.4.4 Thin vs Thick Books
A thick book has many orders at many price levels, creating smooth depth curves. It indicates a healthy, liquid market where large trades can be executed without significant price impact.
A thin book has few orders, large gaps between price levels, and steep depth curves. It indicates an illiquid market where even modest trades can cause large price swings.
Prediction markets, particularly for niche events, often have thin books. This creates both risk (slippage on execution) and opportunity (potential for profit if you provide liquidity).
7.4.5 Python Depth Chart Visualization
import matplotlib.pyplot as plt
import numpy as np
def plot_depth_chart(bids, asks, title="Order Book Depth Chart"):
"""
Plot a depth chart from bid and ask data.
Parameters:
bids: list of (price, quantity) tuples, sorted by price descending
asks: list of (price, quantity) tuples, sorted by price ascending
title: chart title
"""
# Calculate cumulative depths
bid_prices = [b[0] for b in bids]
bid_cum = np.cumsum([b[1] for b in bids])
ask_prices = [a[0] for a in asks]
ask_cum = np.cumsum([a[1] for a in asks])
fig, ax = plt.subplots(figsize=(10, 6))
# Plot bid side (step plot going left)
ax.fill_between(bid_prices, bid_cum, step='post',
alpha=0.4, color='green', label='Bids')
ax.step(bid_prices, bid_cum, where='post', color='green', linewidth=2)
# Plot ask side (step plot going right)
ax.fill_between(ask_prices, ask_cum, step='pre',
alpha=0.4, color='red', label='Asks')
ax.step(ask_prices, ask_cum, where='pre', color='red', linewidth=2)
# Add spread annotation
if bids and asks:
best_bid = bids[0][0]
best_ask = asks[0][0]
midpoint = (best_bid + best_ask) / 2
ax.axvline(x=midpoint, color='gray', linestyle='--',
alpha=0.5, label=f'Midpoint: ${midpoint:.2f}')
ax.annotate(f'Spread: ${best_ask - best_bid:.2f}',
xy=(midpoint, max(max(bid_cum), max(ask_cum)) * 0.9),
ha='center', fontsize=11, color='gray')
ax.set_xlabel('Price ($)', fontsize=12)
ax.set_ylabel('Cumulative Quantity', fontsize=12)
ax.set_title(title, fontsize=14)
ax.legend(fontsize=11)
ax.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Example data
bids = [(0.55, 200), (0.54, 350), (0.53, 500),
(0.52, 150), (0.51, 100), (0.50, 800)]
asks = [(0.57, 150), (0.58, 100), (0.59, 300),
(0.60, 200), (0.65, 400), (0.70, 250)]
plot_depth_chart(bids, asks, "Prediction Market Depth Chart: Event X")
This will produce a classic depth chart with green (bid) and red (ask) filled step curves meeting at the spread.
7.5 Building an Order Book in Python
Now we get to the heart of this chapter: building a fully functional order book from scratch. This is not a toy example -- it is a working implementation that handles all the core operations you would find in a real matching engine.
7.5.1 Data Structures
We need several building blocks:
from dataclasses import dataclass, field
from enum import Enum
from datetime import datetime
from typing import Optional, List, Dict, Tuple
from collections import defaultdict
import heapq
import time
class Side(Enum):
BUY = "BUY"
SELL = "SELL"
class OrderStatus(Enum):
NEW = "NEW"
PARTIALLY_FILLED = "PARTIALLY_FILLED"
FILLED = "FILLED"
CANCELLED = "CANCELLED"
class TimeInForce(Enum):
GTC = "GTC"
IOC = "IOC"
FOK = "FOK"
@dataclass
class Order:
"""Represents a single order in the order book."""
order_id: str
side: Side
price: float
quantity: int
timestamp: float = field(default_factory=time.time)
time_in_force: TimeInForce = TimeInForce.GTC
remaining_quantity: int = None
status: OrderStatus = OrderStatus.NEW
def __post_init__(self):
if self.remaining_quantity is None:
self.remaining_quantity = self.quantity
@property
def is_filled(self) -> bool:
return self.remaining_quantity == 0
@property
def filled_quantity(self) -> int:
return self.quantity - self.remaining_quantity
def __repr__(self):
return (f"Order({self.side.value} {self.remaining_quantity}/"
f"{self.quantity} @ ${self.price:.2f}, "
f"id={self.order_id}, status={self.status.value})")
@dataclass
class Trade:
"""Represents an executed trade."""
trade_id: str
buy_order_id: str
sell_order_id: str
price: float
quantity: int
timestamp: float = field(default_factory=time.time)
def __repr__(self):
return (f"Trade({self.quantity}x @ ${self.price:.2f}, "
f"buyer={self.buy_order_id}, seller={self.sell_order_id})")
7.5.2 The PriceLevel Class
Orders at the same price are grouped into a price level. Within a price level, orders are sorted by time (FIFO).
class PriceLevel:
"""A collection of orders at the same price, ordered by time."""
def __init__(self, price: float):
self.price = price
self.orders: List[Order] = []
@property
def total_quantity(self) -> int:
return sum(o.remaining_quantity for o in self.orders)
@property
def order_count(self) -> int:
return len(self.orders)
def add_order(self, order: Order):
"""Add an order to the back of the queue (time priority)."""
self.orders.append(order)
def remove_order(self, order_id: str) -> Optional[Order]:
"""Remove an order by ID. Returns the removed order or None."""
for i, order in enumerate(self.orders):
if order.order_id == order_id:
return self.orders.pop(i)
return None
def peek(self) -> Optional[Order]:
"""Look at the first order without removing it."""
return self.orders[0] if self.orders else None
def is_empty(self) -> bool:
return len(self.orders) == 0
def __repr__(self):
return (f"PriceLevel(${self.price:.2f}, "
f"qty={self.total_quantity}, "
f"orders={self.order_count})")
7.5.3 The OrderBook Class
The OrderBook class maintains the two sides of the book using sorted dictionaries of price levels.
from sortedcontainers import SortedDict
class OrderBook:
"""
A complete order book implementation with price-time priority.
The bid side is stored in reverse order (highest price first).
The ask side is stored in normal order (lowest price first).
"""
def __init__(self, instrument: str = "EVENT_X"):
self.instrument = instrument
self.bids: SortedDict = SortedDict() # price -> PriceLevel
self.asks: SortedDict = SortedDict() # price -> PriceLevel
self.orders: Dict[str, Order] = {} # order_id -> Order
self.trades: List[Trade] = []
self._trade_counter = 0
# ── Order management ──────────────────────────────────
def add_order(self, order: Order) -> List[Trade]:
"""
Add an order to the book. Returns a list of trades if the
order matches against resting orders.
"""
if order.order_id in self.orders:
raise ValueError(f"Duplicate order ID: {order.order_id}")
# Try to match the order first
trades = self._match_order(order)
# Handle time-in-force after matching attempt
if order.time_in_force == TimeInForce.FOK:
if order.remaining_quantity > 0:
# FOK: if not fully filled, cancel everything
# Reverse any trades that occurred (simplified: we
# prevent partial FOK by checking before matching)
# In practice, FOK is checked pre-match.
# Here we use a simpler approach:
order.status = OrderStatus.CANCELLED
return [] # No trades executed
if order.time_in_force == TimeInForce.IOC:
if order.remaining_quantity > 0:
# IOC: cancel any unfilled remainder
order.status = OrderStatus.CANCELLED
self.orders[order.order_id] = order
return trades # Return partial fills
# If there is remaining quantity and TIF allows, rest in book
if order.remaining_quantity > 0 and order.time_in_force == TimeInForce.GTC:
self._add_to_book(order)
return trades
def cancel_order(self, order_id: str) -> Optional[Order]:
"""Cancel an order and remove it from the book."""
if order_id not in self.orders:
return None
order = self.orders[order_id]
if order.status in (OrderStatus.FILLED, OrderStatus.CANCELLED):
return None # Already terminal
# Remove from the appropriate price level
book_side = self.bids if order.side == Side.BUY else self.asks
price_key = -order.price if order.side == Side.BUY else order.price
if price_key in book_side:
level = book_side[price_key]
level.remove_order(order_id)
if level.is_empty():
del book_side[price_key]
order.status = OrderStatus.CANCELLED
return order
def modify_order(self, order_id: str, new_quantity: int = None,
new_price: float = None) -> Optional[Order]:
"""
Modify an existing order. Price changes lose time priority.
Quantity decreases preserve time priority.
Quantity increases lose time priority.
"""
old_order = self.cancel_order(order_id)
if old_order is None:
return None
# Create a new order with modified parameters
new_order = Order(
order_id=f"{order_id}_mod",
side=old_order.side,
price=new_price if new_price is not None else old_order.price,
quantity=new_quantity if new_quantity is not None else old_order.remaining_quantity,
time_in_force=old_order.time_in_force
)
self.add_order(new_order)
return new_order
# ── Internal methods ──────────────────────────────────
def _add_to_book(self, order: Order):
"""Add a resting order to the appropriate side of the book."""
if order.side == Side.BUY:
# Use negative price for bids so SortedDict gives us
# highest price first
price_key = -order.price
book_side = self.bids
else:
price_key = order.price
book_side = self.asks
if price_key not in book_side:
book_side[price_key] = PriceLevel(order.price)
book_side[price_key].add_order(order)
self.orders[order.order_id] = order
order.status = (OrderStatus.PARTIALLY_FILLED
if order.filled_quantity > 0
else OrderStatus.NEW)
def _match_order(self, incoming: Order) -> List[Trade]:
"""Attempt to match an incoming order against resting orders."""
trades = []
if incoming.side == Side.BUY:
book_side = self.asks
else:
book_side = self.bids
while incoming.remaining_quantity > 0 and len(book_side) > 0:
# Get the best price level
best_key = book_side.keys()[0]
level = book_side[best_key]
best_price = level.price
# Check if prices cross
if incoming.side == Side.BUY and incoming.price < best_price:
break
if incoming.side == Side.SELL and incoming.price > best_price:
break
# Match against orders at this price level
while (incoming.remaining_quantity > 0
and not level.is_empty()):
resting = level.peek()
trade_qty = min(incoming.remaining_quantity,
resting.remaining_quantity)
trade_price = resting.price # Execute at resting price
# Execute the trade
trade = self._execute_trade(incoming, resting,
trade_price, trade_qty)
trades.append(trade)
# Remove filled resting orders
if resting.is_filled:
level.orders.pop(0)
resting.status = OrderStatus.FILLED
# Remove empty price levels
if level.is_empty():
del book_side[best_key]
# Update incoming order status
if incoming.is_filled:
incoming.status = OrderStatus.FILLED
self.orders[incoming.order_id] = incoming
return trades
def _execute_trade(self, incoming: Order, resting: Order,
price: float, quantity: int) -> Trade:
"""Execute a trade between two orders."""
self._trade_counter += 1
trade_id = f"T{self._trade_counter:06d}"
# Determine buyer and seller
if incoming.side == Side.BUY:
buy_id, sell_id = incoming.order_id, resting.order_id
else:
buy_id, sell_id = resting.order_id, incoming.order_id
trade = Trade(
trade_id=trade_id,
buy_order_id=buy_id,
sell_order_id=sell_id,
price=price,
quantity=quantity
)
# Update quantities
incoming.remaining_quantity -= quantity
resting.remaining_quantity -= quantity
# Update statuses
for order in (incoming, resting):
if order.is_filled:
order.status = OrderStatus.FILLED
else:
order.status = OrderStatus.PARTIALLY_FILLED
self.trades.append(trade)
return trade
# ── Query methods ─────────────────────────────────────
def best_bid(self) -> Optional[float]:
"""Return the best (highest) bid price, or None."""
if not self.bids:
return None
return self.bids.values()[0].price
def best_ask(self) -> Optional[float]:
"""Return the best (lowest) ask price, or None."""
if not self.asks:
return None
return self.asks.values()[0].price
def spread(self) -> Optional[float]:
"""Return the bid-ask spread, or None if either side is empty."""
bb, ba = self.best_bid(), self.best_ask()
if bb is None or ba is None:
return None
return ba - bb
def midpoint(self) -> Optional[float]:
"""Return the midpoint price."""
bb, ba = self.best_bid(), self.best_ask()
if bb is None or ba is None:
return None
return (bb + ba) / 2.0
def get_bids(self, depth: int = None) -> List[Tuple[float, int]]:
"""Return bid levels as (price, total_quantity) tuples."""
result = []
for level in self.bids.values():
result.append((level.price, level.total_quantity))
if depth and len(result) >= depth:
break
return result
def get_asks(self, depth: int = None) -> List[Tuple[float, int]]:
"""Return ask levels as (price, total_quantity) tuples."""
result = []
for level in self.asks.values():
result.append((level.price, level.total_quantity))
if depth and len(result) >= depth:
break
return result
def total_bid_depth(self) -> int:
"""Total quantity on the bid side."""
return sum(level.total_quantity for level in self.bids.values())
def total_ask_depth(self) -> int:
"""Total quantity on the ask side."""
return sum(level.total_quantity for level in self.asks.values())
def __repr__(self):
bb = f"${self.best_bid():.2f}" if self.best_bid() else "None"
ba = f"${self.best_ask():.2f}" if self.best_ask() else "None"
sp = f"${self.spread():.4f}" if self.spread() else "None"
return (f"OrderBook({self.instrument}: "
f"bid={bb}, ask={ba}, spread={sp})")
def display(self, levels: int = 5):
"""Print a formatted order book display."""
asks = self.get_asks(levels)
bids = self.get_bids(levels)
print(f"\n{'=' * 50}")
print(f" ORDER BOOK: {self.instrument}")
print(f"{'=' * 50}")
print(f" {'BIDS':<20} | {'ASKS':>20}")
print(f" {'Price':>8} {'Qty':>8} | {'Price':>8} {'Qty':>8}")
print(f" {'-' * 18} | {'-' * 20}")
max_levels = max(len(bids), len(asks))
for i in range(max_levels):
bid_str = ""
ask_str = ""
if i < len(bids):
bp, bq = bids[i]
bid_str = f" ${bp:>7.2f} {bq:>7}"
else:
bid_str = " " * 18
if i < len(asks):
ap, aq = asks[i]
ask_str = f"${ap:>7.2f} {aq:>7}"
else:
ask_str = ""
print(f"{bid_str} | {ask_str}")
print(f" {'-' * 18} | {'-' * 20}")
sp = self.spread()
mp = self.midpoint()
print(f" Spread: ${sp:.4f} | Midpoint: ${mp:.4f}" if sp else
" Spread: N/A")
print(f"{'=' * 50}\n")
7.5.4 Using the Order Book
Let us replay the worked example from Section 7.2.5:
book = OrderBook("Will Event X Occur?")
# Order 1: Alice BUY 100 @ $0.50
order1 = Order("ALICE_1", Side.BUY, 0.50, 100)
trades = book.add_order(order1)
print(f"Order 1 trades: {trades}")
book.display()
# Order 2: Bob SELL 80 @ $0.55
order2 = Order("BOB_1", Side.SELL, 0.55, 80)
trades = book.add_order(order2)
print(f"Order 2 trades: {trades}")
# Order 3: Carol BUY 50 @ $0.53
order3 = Order("CAROL_1", Side.BUY, 0.53, 50)
trades = book.add_order(order3)
print(f"Order 3 trades: {trades}")
# Order 4: Dave SELL 30 @ $0.52
order4 = Order("DAVE_1", Side.SELL, 0.52, 30)
trades = book.add_order(order4)
print(f"Order 4 trades: {trades}")
# Expect: Trade 30 @ $0.53 (Carol's resting price)
# Order 5: Eve BUY 100 @ $0.56
order5 = Order("EVE_1", Side.BUY, 0.56, 100)
trades = book.add_order(order5)
print(f"Order 5 trades: {trades}")
# Expect: Trade 80 @ $0.55 (Bob's resting price), 20 rests
# Order 6: Frank SELL 200 @ $0.51
order6 = Order("FRANK_1", Side.SELL, 0.51, 200)
trades = book.add_order(order6)
print(f"Order 6 trades: {trades}")
# Expect: Trade 20 @ $0.56 (Eve), Trade 20 @ $0.53 (Carol), 160 rests
book.display()
Note on dependencies: The
SortedDictclass is from thesortedcontainerslibrary. Install it withpip install sortedcontainers. If you prefer to avoid external dependencies, you can replaceSortedDictwith a regular dictionary and sort keys manually, though this is less efficient.
7.6 The Matching Engine
7.6.1 What Is a Matching Engine?
The matching engine is the core component of any exchange. It receives incoming orders, determines whether and how they should be matched against resting orders, and generates trades. Everything else -- the user interface, the data feeds, the risk management -- is built around the matching engine.
In Section 7.5, the matching logic was embedded in the OrderBook class. In a more realistic architecture, the matching engine is a separate component that manages the order book and enforces business rules.
7.6.2 Price-Time Priority Implementation
We already implemented price-time priority in Section 7.5. Let us formalize the algorithm:
Algorithm: Price-Time Priority Matching
Input: incoming order O, order book B
Output: list of trades T
T = []
opposite_side = B.asks if O.side == BUY else B.bids
while O.remaining_quantity > 0 and opposite_side is not empty:
best_level = opposite_side.first() // Best price level
// Check price compatibility
if O.side == BUY and O.price < best_level.price: break
if O.side == SELL and O.price > best_level.price: break
// Match against orders at this level (FIFO)
while O.remaining > 0 and best_level is not empty:
resting = best_level.first_order() // Earliest order
qty = min(O.remaining, resting.remaining)
price = resting.price // Trade at resting price
trade = execute(O, resting, price, qty)
T.append(trade)
if resting.remaining == 0:
remove resting from best_level
if best_level is empty:
remove best_level from opposite_side
return T
7.6.3 Pro-Rata Matching
Some markets use pro-rata matching instead of (or in addition to) time priority. Under pro-rata matching, all orders at the same price level share the incoming order proportionally to their size.
Example: Three resting sell orders at $0.55: - Order A: 100 contracts - Order B: 200 contracts - Order C: 300 contracts - Total: 600 contracts
An incoming buy for 300 contracts at $0.55 would be allocated: - Order A: 300 * (100/600) = 50 contracts - Order B: 300 * (200/600) = 100 contracts - Order C: 300 * (300/600) = 150 contracts
def pro_rata_match(incoming_qty: int,
resting_orders: List[Order]) -> List[Tuple[Order, int]]:
"""
Allocate incoming quantity proportionally across resting orders.
Returns list of (order, allocated_quantity) tuples.
"""
total_resting = sum(o.remaining_quantity for o in resting_orders)
if total_resting == 0:
return []
allocations = []
remaining = incoming_qty
# First pass: calculate proportional allocations
raw_allocations = []
for order in resting_orders:
proportion = order.remaining_quantity / total_resting
raw_alloc = incoming_qty * proportion
floored = int(raw_alloc) # Round down
raw_allocations.append((order, floored, raw_alloc - floored))
remaining -= floored
# Second pass: distribute remainder by largest fractional part
raw_allocations.sort(key=lambda x: -x[2])
for i in range(remaining):
order, alloc, _ = raw_allocations[i]
raw_allocations[i] = (order, alloc + 1, 0)
# Build final result
for order, alloc, _ in raw_allocations:
if alloc > 0:
allocations.append((order, alloc))
return allocations
Pro-rata matching is more common in futures markets. Most prediction market exchanges use price-time priority, which is simpler and gives an advantage to speed -- an important incentive for market makers to provide continuous liquidity.
7.6.4 The MatchingEngine Class
Let us build a more complete matching engine that wraps the order book with additional functionality:
class MatchingEngine:
"""
A matching engine that wraps an OrderBook with additional features:
- Order validation
- Stop order management
- Trade notification callbacks
- FOK pre-check
"""
def __init__(self, instrument: str = "EVENT_X"):
self.order_book = OrderBook(instrument)
self.stop_orders: List[StopOrder] = []
self.trade_callbacks = []
self._order_counter = 0
def generate_order_id(self) -> str:
self._order_counter += 1
return f"ORD{self._order_counter:06d}"
def register_trade_callback(self, callback):
"""Register a function to be called when a trade executes."""
self.trade_callbacks.append(callback)
def _notify_trade(self, trade: Trade):
"""Notify all registered callbacks of a new trade."""
for callback in self.trade_callbacks:
callback(trade)
def submit_limit_order(self, side: Side, price: float,
quantity: int,
tif: TimeInForce = TimeInForce.GTC) -> Tuple[str, List[Trade]]:
"""Submit a limit order. Returns (order_id, trades)."""
# Validate
if price <= 0.0 or price >= 1.0:
raise ValueError(
f"Price must be between $0.00 and $1.00 "
f"(exclusive) for prediction markets. Got ${price:.2f}")
if quantity <= 0:
raise ValueError("Quantity must be positive.")
order_id = self.generate_order_id()
# FOK pre-check
if tif == TimeInForce.FOK:
if not self._can_fill_completely(side, price, quantity):
return order_id, [] # Cannot fill; no order placed
order = Order(
order_id=order_id,
side=side,
price=price,
quantity=quantity,
time_in_force=tif
)
trades = self.order_book.add_order(order)
# Notify and check stops
for trade in trades:
self._notify_trade(trade)
self._check_stop_orders(trade.price)
return order_id, trades
def submit_market_order(self, side: Side,
quantity: int) -> Tuple[str, List[Trade]]:
"""
Submit a market order. Uses an extreme price to ensure
matching against all available liquidity.
"""
# Market orders: use price 0.99 for buys, 0.01 for sells
if side == Side.BUY:
price = 0.99
else:
price = 0.01
order_id = self.generate_order_id()
order = Order(
order_id=order_id,
side=side,
price=price,
quantity=quantity,
time_in_force=TimeInForce.IOC # Market orders are IOC
)
trades = self.order_book.add_order(order)
for trade in trades:
self._notify_trade(trade)
self._check_stop_orders(trade.price)
return order_id, trades
def submit_stop_order(self, side: Side, stop_price: float,
quantity: int,
limit_price: float = None) -> str:
"""Submit a stop order. Returns order ID."""
order_id = self.generate_order_id()
stop = StopOrder(
order_id=order_id,
side=side,
stop_price=stop_price,
quantity=quantity,
limit_price=limit_price
)
self.stop_orders.append(stop)
return order_id
def cancel_order(self, order_id: str) -> bool:
"""Cancel an order. Returns True if successful."""
result = self.order_book.cancel_order(order_id)
if result:
return True
# Check stop orders
for i, stop in enumerate(self.stop_orders):
if stop.order_id == order_id:
self.stop_orders.pop(i)
return True
return False
def _can_fill_completely(self, side: Side, price: float,
quantity: int) -> bool:
"""Check if an order can be completely filled (for FOK)."""
available = 0
if side == Side.BUY:
for level in self.order_book.asks.values():
if level.price > price:
break
available += level.total_quantity
if available >= quantity:
return True
else:
for level in self.order_book.bids.values():
if level.price < price:
break
available += level.total_quantity
if available >= quantity:
return True
return available >= quantity
def _check_stop_orders(self, last_price: float):
"""Check if any stop orders should be triggered."""
triggered = []
remaining = []
for stop in self.stop_orders:
if stop.check_trigger(last_price):
triggered.append(stop)
else:
remaining.append(stop)
self.stop_orders = remaining
# Submit triggered orders
for stop in triggered:
active_order = stop.to_active_order()
if isinstance(active_order, LimitOrder):
self.submit_limit_order(
active_order.side,
active_order.price,
active_order.quantity
)
else:
self.submit_market_order(
active_order.side,
active_order.quantity
)
def get_order_book_snapshot(self) -> dict:
"""Return a dictionary snapshot of the current order book."""
return {
'instrument': self.order_book.instrument,
'best_bid': self.order_book.best_bid(),
'best_ask': self.order_book.best_ask(),
'spread': self.order_book.spread(),
'midpoint': self.order_book.midpoint(),
'bids': self.order_book.get_bids(),
'asks': self.order_book.get_asks(),
'total_bid_depth': self.order_book.total_bid_depth(),
'total_ask_depth': self.order_book.total_ask_depth(),
'trade_count': len(self.order_book.trades),
}
7.6.5 Using the Matching Engine
# Create engine
engine = MatchingEngine("2024 Presidential Election: Candidate A Wins")
# Register a trade notification
def on_trade(trade):
print(f" >> TRADE EXECUTED: {trade}")
engine.register_trade_callback(on_trade)
# Market makers post liquidity
engine.submit_limit_order(Side.BUY, 0.52, 500)
engine.submit_limit_order(Side.BUY, 0.50, 1000)
engine.submit_limit_order(Side.SELL, 0.55, 500)
engine.submit_limit_order(Side.SELL, 0.58, 1000)
# A trader sends a market buy
print("Submitting market buy for 200 contracts:")
order_id, trades = engine.submit_market_order(Side.BUY, 200)
print(f"Filled {sum(t.quantity for t in trades)} contracts in "
f"{len(trades)} trade(s)")
# Check the book
engine.order_book.display()
7.7 Market Quality Metrics
Understanding order book data requires quantitative tools. This section covers the most important metrics that traders and researchers use to assess market quality.
7.7.1 Bid-Ask Spread
The absolute spread is:
$$S_{abs} = P_{ask} - P_{bid}$$
The relative spread (also called the percentage spread) normalizes by the midpoint:
$$S_{rel} = \frac{P_{ask} - P_{bid}}{(P_{ask} + P_{bid}) / 2} = \frac{2(P_{ask} - P_{bid})}{P_{ask} + P_{bid}}$$
In prediction markets, the absolute spread is more commonly cited because prices are bounded between $0 and $1. A 2-cent spread on a 50-cent contract is very different economically from a 2-cent spread on a 5-cent contract, so the relative spread can be more informative for comparing across contracts.
7.7.2 Depth at Best
Depth at best is the total quantity available at the best bid and best ask prices. It measures how much you can trade at the current quoted prices without moving the market.
$$D_{best} = Q_{best\_bid} + Q_{best\_ask}$$
A high depth at best suggests that the quoted prices are "real" -- there is genuine willingness to trade at those levels. A low depth at best suggests that the quoted prices are fragile -- even a small order could move them.
7.7.3 Total Depth
Total depth (also called market depth) sums all quantities across all price levels:
$$D_{total} = \sum_{i} Q_{bid_i} + \sum_{j} Q_{ask_j}$$
This gives an overall measure of how much interest exists in the market.
7.7.4 Order Imbalance
Order imbalance measures the asymmetry between bid and ask sides:
$$OI = \frac{D_{bid} - D_{ask}}{D_{bid} + D_{ask}}$$
- $OI > 0$: More buying interest (bullish signal)
- $OI < 0$: More selling interest (bearish signal)
- $OI = 0$: Balanced book
Order imbalance at the top of the book (best bid vs best ask depth) is often a stronger short-term predictor of price direction than total depth imbalance.
7.7.5 Volume-Weighted Average Price (VWAP)
VWAP is the average price of all trades, weighted by their volume:
$$VWAP = \frac{\sum_{i} P_i \cdot Q_i}{\sum_{i} Q_i}$$
VWAP is used as a benchmark for execution quality. If you bought at a price below VWAP, you got a better-than-average deal.
7.7.6 Implementation: Market Quality Calculator
class MarketQualityMetrics:
"""Calculate market quality metrics from order book data."""
def __init__(self, order_book: OrderBook):
self.book = order_book
def absolute_spread(self) -> Optional[float]:
"""Bid-ask spread in absolute terms."""
return self.book.spread()
def relative_spread(self) -> Optional[float]:
"""Bid-ask spread as a fraction of the midpoint."""
sp = self.book.spread()
mp = self.book.midpoint()
if sp is None or mp is None or mp == 0:
return None
return sp / mp
def depth_at_best(self) -> dict:
"""Quantity available at the best bid and ask."""
bid_depth = 0
ask_depth = 0
bids = self.book.get_bids(1)
asks = self.book.get_asks(1)
if bids:
bid_depth = bids[0][1]
if asks:
ask_depth = asks[0][1]
return {
'bid_depth': bid_depth,
'ask_depth': ask_depth,
'total': bid_depth + ask_depth
}
def total_depth(self) -> dict:
"""Total quantity across all price levels."""
bid_total = self.book.total_bid_depth()
ask_total = self.book.total_ask_depth()
return {
'bid_depth': bid_total,
'ask_depth': ask_total,
'total': bid_total + ask_total
}
def order_imbalance(self, levels: int = None) -> Optional[float]:
"""
Order imbalance between bid and ask sides.
If levels is specified, only consider top N levels.
Returns value between -1.0 (all asks) and 1.0 (all bids).
"""
if levels:
bids = self.book.get_bids(levels)
asks = self.book.get_asks(levels)
bid_qty = sum(q for _, q in bids)
ask_qty = sum(q for _, q in asks)
else:
bid_qty = self.book.total_bid_depth()
ask_qty = self.book.total_ask_depth()
total = bid_qty + ask_qty
if total == 0:
return None
return (bid_qty - ask_qty) / total
def vwap(self, n_trades: int = None) -> Optional[float]:
"""
Volume-weighted average price of recent trades.
If n_trades is specified, only consider the last N trades.
"""
trades = self.book.trades
if n_trades:
trades = trades[-n_trades:]
if not trades:
return None
total_value = sum(t.price * t.quantity for t in trades)
total_volume = sum(t.quantity for t in trades)
if total_volume == 0:
return None
return total_value / total_volume
def effective_spread(self, trades: List[Trade] = None) -> Optional[float]:
"""
Effective spread: measures the actual cost of trading,
calculated as 2 * |trade_price - midpoint| averaged over trades.
"""
if trades is None:
trades = self.book.trades
if not trades:
return None
mp = self.book.midpoint()
if mp is None:
return None
spreads = [2 * abs(t.price - mp) for t in trades]
return sum(spreads) / len(spreads)
def price_impact(self, side: Side, quantity: int) -> Optional[float]:
"""
Estimate the price impact of a hypothetical market order.
Returns the volume-weighted average execution price minus
the midpoint.
"""
mp = self.book.midpoint()
if mp is None:
return None
if side == Side.BUY:
levels = self.book.get_asks()
else:
levels = self.book.get_bids()
remaining = quantity
total_cost = 0.0
for price, qty in levels:
fill = min(remaining, qty)
total_cost += price * fill
remaining -= fill
if remaining <= 0:
break
if remaining > 0:
return None # Not enough liquidity
avg_price = total_cost / quantity
if side == Side.BUY:
return avg_price - mp
else:
return mp - avg_price
def summary(self) -> dict:
"""Return a comprehensive summary of all metrics."""
return {
'instrument': self.book.instrument,
'best_bid': self.book.best_bid(),
'best_ask': self.book.best_ask(),
'midpoint': self.book.midpoint(),
'absolute_spread': self.absolute_spread(),
'relative_spread': self.relative_spread(),
'depth_at_best': self.depth_at_best(),
'total_depth': self.total_depth(),
'order_imbalance': self.order_imbalance(),
'top3_imbalance': self.order_imbalance(levels=3),
'vwap': self.vwap(),
'trade_count': len(self.book.trades),
}
def print_summary(self):
"""Print a formatted summary of market quality metrics."""
s = self.summary()
print(f"\n{'=' * 50}")
print(f" Market Quality Report: {s['instrument']}")
print(f"{'=' * 50}")
print(f" Best Bid: ${s['best_bid']:.4f}" if s['best_bid'] else
" Best Bid: N/A")
print(f" Best Ask: ${s['best_ask']:.4f}" if s['best_ask'] else
" Best Ask: N/A")
print(f" Midpoint: ${s['midpoint']:.4f}" if s['midpoint'] else
" Midpoint: N/A")
print(f" Absolute Spread: ${s['absolute_spread']:.4f}" if s['absolute_spread'] else
" Absolute Spread: N/A")
print(f" Relative Spread: {s['relative_spread']:.4%}" if s['relative_spread'] else
" Relative Spread: N/A")
dab = s['depth_at_best']
print(f" Depth at Best: {dab['total']} "
f"(bid: {dab['bid_depth']}, ask: {dab['ask_depth']})")
td = s['total_depth']
print(f" Total Depth: {td['total']} "
f"(bid: {td['bid_depth']}, ask: {td['ask_depth']})")
oi = s['order_imbalance']
print(f" Order Imbalance: {oi:+.4f}" if oi is not None else
" Order Imbalance: N/A")
print(f" VWAP: ${s['vwap']:.4f}" if s['vwap'] else
" VWAP: N/A")
print(f" Trade Count: {s['trade_count']}")
print(f"{'=' * 50}\n")
7.8 Order Book Dynamics and Information
7.8.1 How New Information Affects the Order Book
When new information arrives -- a poll result, a debate performance, a policy announcement -- it does not just change the last traded price. It reshapes the entire order book. Understanding these dynamics gives you a significant edge.
Consider a prediction market on "Will Candidate A win the election?" currently priced around $0.55. A new poll comes out showing Candidate A with a significant lead. Here is what typically happens:
-
Ask-side thinning: Sellers at low prices (those who were willing to sell cheap, effectively betting against Candidate A) rapidly cancel their orders. The ask side becomes thin near the current price.
-
Bid-side loading: Buyers pile in with bids at higher prices. The bid side becomes thick.
-
Spread widening: For a brief period, the spread widens dramatically as the old equilibrium dissolves and a new one has not yet formed.
-
Aggressive crossing: Informed traders send aggressive buy orders that "walk the book," trading at progressively higher prices. These market orders or aggressively priced limit orders consume the remaining ask liquidity.
-
New equilibrium: Market makers re-enter with quotes around the new consensus price (say, $0.65). The spread narrows again as competing quotes tighten it.
This entire process can happen in seconds on electronic markets.
7.8.2 Order Flow Analysis
Order flow -- the sequence of orders arriving at the exchange -- contains information beyond what the order book snapshot shows. Key signals include:
-
Trade direction: Are most trades buyer-initiated (market buys hitting asks) or seller-initiated (market sells hitting bids)? Net buying pressure often precedes price increases.
-
Order size: Large orders (relative to typical size) may indicate informed trading. Institutional traders and insiders tend to trade in larger sizes.
-
Cancel rates: A high rate of order cancellations, especially at the best bid/ask, may indicate algorithmic activity or uncertainty.
-
Order-to-trade ratio: A high ratio of submitted orders to executed trades suggests that many orders are being placed and then cancelled without execution -- a sign of aggressive quoting or potential manipulation.
7.8.3 Quote Stuffing and Spoofing
Quote stuffing is the practice of rapidly submitting and cancelling large numbers of orders to overwhelm the exchange's systems and create latency for other participants. While this is primarily a concern in high-frequency stock trading, some prediction market platforms have encountered similar behavior.
Spoofing (also called layering) involves placing large orders with the intent to cancel them before execution. A spoofer might:
- Place a large buy order at $0.54 (visible to other participants).
- Other traders see the large bid and interpret it as buying pressure, causing them to buy.
- The spoofer sells into the resulting price increase.
- The spoofer cancels the original large buy order.
In prediction markets, spoofing is a particular concern because: - Thinner liquidity means even modest spoofing can move prices significantly. - Regulatory oversight is less established than in traditional securities markets. - Many participants are retail traders who may be more susceptible to order book signals.
Both quote stuffing and spoofing are illegal in US securities markets under Dodd-Frank and similar regulations. The regulatory framework for prediction markets is still evolving.
7.8.4 Order Book Imbalance as a Signal
Research in traditional financial markets has shown that order book imbalance -- the ratio of bid depth to ask depth -- is a statistically significant predictor of short-term price movements:
$$OBI = \frac{Q_{bid}^{best} - Q_{ask}^{best}}{Q_{bid}^{best} + Q_{ask}^{best}}$$
When $OBI > 0$ (more bid depth than ask depth at the best prices), prices tend to rise in the next few seconds. When $OBI < 0$, prices tend to fall. This relationship holds because imbalance reflects the net supply/demand pressure at the margin.
In prediction markets, this signal tends to be noisier due to: - Thinner books where a single order can dramatically shift imbalance - Fewer market makers providing continuous liquidity - Event-driven dynamics that can overwhelm microstructural signals
Nonetheless, sophisticated prediction market traders do monitor order book imbalance as one input to their trading decisions.
def compute_order_book_imbalance_series(snapshots: list) -> list:
"""
Compute order book imbalance for a series of order book snapshots.
Each snapshot is a dict with 'best_bid_qty' and 'best_ask_qty'.
Returns a list of OBI values between -1 and 1.
"""
obi_series = []
for snap in snapshots:
bid_q = snap.get('best_bid_qty', 0)
ask_q = snap.get('best_ask_qty', 0)
total = bid_q + ask_q
if total == 0:
obi_series.append(0.0)
else:
obi_series.append((bid_q - ask_q) / total)
return obi_series
7.9 Level 1 vs Level 2 Data
Market data comes in different levels of granularity, and understanding these levels is essential for anyone working with prediction market data feeds.
7.9.1 Level 1 Data (Top of Book)
Level 1 data, also called top-of-book data, includes:
- Best bid price and quantity: The highest buy order's price and size.
- Best ask price and quantity: The lowest sell order's price and size.
- Last trade price and quantity: The most recent trade.
- Volume: Total contracts traded in the current session.
- High/Low: Session high and low prices.
Level 1 data is what you see on most market summary screens. It is lightweight (a few numbers that change) and sufficient for casual monitoring.
7.9.2 Level 2 Data (Full Depth)
Level 2 data, also called full depth or market depth data, includes the entire order book -- all price levels with their quantities, and in some cases individual order details.
Level 2 data reveals: - The full supply/demand curve - Hidden support and resistance levels - The "thickness" of the book at each price - Whether large orders are lurking deeper in the book
7.9.3 Level 3 Data (Full Order Information)
Some venues provide Level 3 data, which includes individual order IDs, timestamps, and order modifications. This is the most granular data available and allows you to reconstruct the exact sequence of events in the order book.
7.9.4 Comparison
| Feature | Level 1 | Level 2 | Level 3 |
|---|---|---|---|
| Best bid/ask | Yes | Yes | Yes |
| All price levels | No | Yes | Yes |
| Individual orders | No | Sometimes | Yes |
| Order modifications | No | No | Yes |
| Data volume | Low | Medium | High |
| Latency sensitivity | Low | Medium | High |
| Typical use case | Monitoring | Analysis/Trading | Research/HFT |
| Storage (per day) | ~MBs | ~GBs | ~10s of GBs |
7.9.5 Latency Considerations
The speed at which you receive market data matters. In traditional finance, firms spend millions on co-location (placing servers physically close to the exchange) to shave microseconds off their data latency.
In prediction markets, latency is less extreme but still relevant: - Polymarket: Data is available via WebSocket feeds with typical latencies of 100-500 milliseconds. - Kalshi: Provides REST and WebSocket APIs with similar latency profiles. - Betfair: Offers streaming APIs with latencies measured in tens of milliseconds.
For most prediction market strategies, sub-second data is sufficient. High-frequency strategies that depend on microsecond latency are generally not viable in prediction markets due to thinner liquidity and higher per-trade costs.
7.9.6 Storage Requirements
If you are collecting order book data for research, plan your storage carefully:
- Level 1 snapshots every second for one market: ~50 MB/day
- Level 2 snapshots every second for one market: ~500 MB/day
- Full order-by-order data for one market: ~5 GB/day
For prediction markets, volumes are typically lower than stock markets, so these numbers may be 10-100x smaller. Still, over months of data collection, storage adds up.
7.10 Prediction Market Order Books vs Traditional Finance
Prediction market order books share the same fundamental mechanics as stock or futures order books, but there are important differences that affect how you trade and analyze them.
7.10.1 Binary Payoff Structure
The most fundamental difference is the payoff structure. Prediction market contracts pay either $0 or $1 (or equivalent). This means:
- Prices are bounded: Between $0.00 and $1.00. No contract can trade outside this range (rational traders will not pay more than $1.00 for a contract that pays at most $1.00).
- Natural sellers exist at every price: Even at $0.95, there are people willing to sell because they believe the event probability is below 95%. In stock markets, very few people short at current prices.
- Symmetric risk: Buying at $0.60 risks $0.60 to gain $0.40. Selling at $0.60 risks $0.40 to gain $0.60. This symmetry is not present in stocks, where downside is capped at 100% but upside is theoretically unlimited.
7.10.2 Thinner Liquidity
Prediction markets have far less liquidity than major stock markets:
| Metric | Major Stock (e.g., AAPL) | Active Prediction Market |
|---|---|---|
| Daily volume | Millions of shares | Thousands of contracts |
| Depth at best | 10,000+ shares | 50-500 contracts |
| Number of market makers | 10-50 | 1-5 |
| Typical spread | < $0.01 | $0.01 - $0.05 |
This thinner liquidity means: - Larger price impact: A $10,000 trade can move a prediction market price by several cents. In AAPL stock, $10,000 would not move the price at all. - Wider spreads: Market makers demand more compensation for providing liquidity in an illiquid market. - Execution risk: Large orders may face significant slippage.
7.10.3 Event-Driven Dynamics
Stock prices move continuously based on a broad set of factors (earnings, macro data, sentiment). Prediction market prices are driven primarily by specific events: debates, polls, court rulings, official announcements.
This creates distinctive order book patterns: - Pre-event loading: Before a major event (like an election debate), depth often increases as traders position themselves. - Event shock: During the event, the book may temporarily empty as existing orders are cancelled and new information is processed. - Post-event convergence: After the event, the market rapidly converges to a new price reflecting the outcome.
7.10.4 Fewer HFT Participants
High-frequency trading (HFT) firms are less active in prediction markets because: - Lower volumes mean less profit opportunity. - Technology infrastructure is less developed. - Regulatory uncertainty deters institutional participation. - The binary payoff structure limits the value of speed-based strategies.
This means that prediction market order books are often dominated by a few dedicated market makers and a larger number of retail traders. The absence of HFT can be both good (less sophisticated competition) and bad (less liquidity, wider spreads).
7.10.5 Wider Spreads
Combining thinner liquidity, fewer market makers, and event-driven uncertainty, prediction market spreads are typically wider than traditional markets:
- A 1-2 cent spread is considered tight for a prediction market.
- A 3-5 cent spread is typical.
- A 10+ cent spread indicates very low liquidity or high uncertainty.
These wider spreads have implications for trading strategies: you need larger edge per trade to overcome the transaction cost of the spread. Scalping strategies that work in penny-spread stock markets may not be viable in prediction markets.
7.11 Practical Considerations
7.11.1 Latency and Execution
When you submit an order to a prediction market, there is a delay between when you send the order and when it is processed by the matching engine. This latency comes from:
- Network latency: The time for your order to travel from your computer to the exchange's server.
- Processing latency: The time for the matching engine to process your order.
- Blockchain latency (for on-chain markets): If the market settles on a blockchain (like Polymarket on Polygon), there is additional latency for block confirmation.
For most prediction market traders, total latency of 100-500ms is typical. This is an eternity by stock market standards but adequate for most prediction market strategies.
Practical tips: - Do not rely on stale order book data. If your data is more than a few seconds old, the book may have changed. - Use limit orders rather than market orders to control your execution price. - If you are building automated strategies, implement rate limiting to avoid overwhelming the exchange's API.
7.11.2 Data Feed Reliability
Prediction market data feeds can be unreliable: - WebSocket connections may drop and require reconnection. - REST API endpoints may have rate limits or downtime. - Data may have gaps or inconsistencies.
Best practices: - Implement automatic reconnection logic for WebSocket feeds. - Cross-reference data from multiple sources when possible. - Log raw data for later verification and debugging. - Build your systems to handle missing data gracefully.
7.11.3 Order Book Reconstruction from Trade Data
Sometimes you only have trade data (price, quantity, timestamp) rather than full order book snapshots. You can partially reconstruct the order book's state by:
-
Inferring trade direction: Use the Lee-Ready algorithm -- if a trade occurs above the midpoint, it was likely buyer-initiated; below the midpoint, seller-initiated.
-
Estimating depth: Track the cumulative volume at each price level. If multiple trades occur at the same price, the depth at that price was at least the cumulative volume.
-
Approximating the spread: The minimum observed difference between consecutive buyer-initiated and seller-initiated trade prices gives an upper bound on the typical spread.
This reconstruction is imperfect, but it can provide useful approximations when full order book data is unavailable.
def lee_ready_classify(trades: list, midpoints: list) -> list:
"""
Classify trades as buyer- or seller-initiated using Lee-Ready.
Parameters:
trades: list of dicts with 'price' and 'quantity'
midpoints: list of midpoint prices at each trade time
Returns:
list of 'BUY' or 'SELL' classifications
"""
classifications = []
prev_price = None
for trade, midpoint in zip(trades, midpoints):
price = trade['price']
if price > midpoint:
classifications.append('BUY')
elif price < midpoint:
classifications.append('SELL')
else:
# Tick test: compare to previous trade price
if prev_price is not None:
if price > prev_price:
classifications.append('BUY')
elif price < prev_price:
classifications.append('SELL')
else:
classifications.append('UNKNOWN')
else:
classifications.append('UNKNOWN')
prev_price = price
return classifications
7.12 Chapter Summary
This chapter provided a comprehensive exploration of order books and the limit order market mechanism. Let us recap the key concepts:
The order book is the central data structure of any exchange-based market. It organizes all outstanding buy (bid) and sell (ask) orders by price and time, creating a transparent view of supply and demand.
The continuous double auction (CDA) is the mechanism that governs order matching. It operates on price-time priority: the best-priced orders match first, with ties broken by arrival time. Trades execute at the resting order's price.
Order types give traders flexibility: - Limit orders specify a maximum buy price or minimum sell price and may rest in the book. - Market orders execute immediately at the best available price. - Stop orders trigger when a price threshold is reached. - Time-in-force conditions (GTC, IOC, FOK) control how long unmatched orders persist.
Depth charts visualize the cumulative supply and demand at each price level, revealing liquidity concentrations, support/resistance levels, and the overall health of the market.
The matching engine is the core software component that processes orders and generates trades. We built a fully functional implementation supporting limit orders, market orders, stop orders, and multiple time-in-force conditions.
Market quality metrics -- spread, depth, order imbalance, VWAP -- provide quantitative tools for assessing how well a market functions and how costly it is to trade.
Order book dynamics reflect how new information flows through the market. Order flow analysis, imbalance signals, and awareness of manipulation tactics (spoofing, quote stuffing) are essential skills for serious traders.
Prediction market order books differ from traditional finance in important ways: binary payoffs create bounded prices, liquidity is thinner, dynamics are event-driven, and spreads are wider. These differences create both challenges and opportunities for participants who understand them.
What's Next
In Chapter 8: Automated Market Makers (AMMs), we will explore the alternative to order books: algorithmic price-setting mechanisms that use mathematical formulas to provide continuous liquidity. We will study constant-product market makers (like Uniswap), logarithmic scoring rules (like Hanson's LMSR, which we previewed in Chapter 4), and hybrid designs that combine elements of both order books and AMMs. We will build AMM implementations in Python and compare their properties -- capital efficiency, price discovery accuracy, impermanent loss, and slippage -- to the order book model we built in this chapter.
The tension between order books and AMMs is one of the most important design decisions in prediction market architecture, and understanding both mechanisms deeply will give you a complete picture of how prediction markets work at the microstructural level.