33 min read

> "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."

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:

  1. 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.

  2. 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.

  3. Transparency: The full order book reveals the market's supply and demand landscape, giving participants valuable information about market sentiment.

  4. 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.

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

  1. Order arrival: A new order arrives at the exchange (buy or sell, with a specified price and quantity).
  2. Matching check: The matching engine checks whether the new order can be immediately matched against existing orders on the opposite side of the book.
  3. If matchable: The order is executed (partially or fully) against the resting orders, generating one or more trades.
  4. 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:

  1. 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.
  2. 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 SortedDict class is from the sortedcontainers library. Install it with pip install sortedcontainers. If you prefer to avoid external dependencies, you can replace SortedDict with 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:

  1. 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.

  2. Bid-side loading: Buyers pile in with bids at higher prices. The bid side becomes thick.

  3. Spread widening: For a brief period, the spread widens dramatically as the old equilibrium dissolves and a new one has not yet formed.

  4. 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.

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

  1. Place a large buy order at $0.54 (visible to other participants).
  2. Other traders see the large bid and interpret it as buying pressure, causing them to buy.
  3. The spoofer sells into the resulting price increase.
  4. 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:

  1. Network latency: The time for your order to travel from your computer to the exchange's server.
  2. Processing latency: The time for the matching engine to process your order.
  3. 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:

  1. 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.

  2. 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.

  3. 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.