How to Read a Polymarket Order Book Like a Sharp Trader

April 21, 2026 · 12 min read

The difference between a trader who captures edge and one who leaks it to the market isn't usually the model — it's how they execute. Two people with identical prediction models can have wildly different P&L, and most of that gap comes from how they read the order book at the moment of entry.

This post explains what an order book actually tells you on Polymarket, which signals indicate fills are safe versus risky, and how professional traders use depth-of-market data to decide when to fire, when to post passively, and when to pass entirely.

What an Order Book Shows You

Every Polymarket contract has a central limit order book (CLOB) with:

A simplified snapshot for a single YES contract:

Ask 0.67 120 shares Ask 0.66 45 shares Ask 0.65 12 shares <-- best ask ─────────────────────────── Bid 0.63 100 shares <-- best bid Bid 0.62 80 shares Bid 0.61 150 shares

Best ask is 0.65, best bid is 0.63, spread is 0.02 (2 cents), mid is 0.64.

Signal 1: The Spread

The spread is the cheapest reading of liquidity. Tight spreads (1-2c) on sports markets mean a market maker is actively quoting both sides and is confident enough to offer close prices. Wide spreads (5c+) mean either low liquidity, uncertainty, or both.

SpreadReadAction
1-2cTight, market maker presentSafe to take. Low slippage risk.
3-4cNormal for sports in-playTrade at small-to-medium size.
5-6cWide. Either pre-game or volatile state.Small size only, or post passive bid.
7c+Very wide. Market maker offline or market confused.Skip or post a passive bid 1c inside.

A trading rule that saves more money than it costs: cap your entry at spread ≤ 6c. Wider spreads mean you're paying the spread as a fixed cost on top of whatever edge you have.

Signal 2: Top-of-Book Size

Size at the best ask tells you how much you can take before moving to the next price level. If the best ask is 12 shares and you want to buy 50, you'll "walk the book" and pay progressively higher prices for 38 of your 50 shares.

For the book above:

That 0.014 difference between 10-share and 100-share fills is pure slippage. If you thought you had a 5c edge at the best ask, buying 100 shares cuts your effective edge to 3.6c. On highly illiquid markets, a 100-share order might consume half your edge.

Practical rule: your order size should consume no more than ~50% of the best level's size. If you want to buy 50 but best ask shows 20, split the order and stagger, or reduce size.

Signal 3: Book Imbalance

Compare total size on the bid side to total size on the ask side (across top 3-5 levels). Heavy imbalance signals market-maker inventory skew.

Heavy bid-side size (buys lined up, few sellers)

The market maker is short the contract and defending higher prices by refusing to quote low asks. Your next market-buy order will push price up. Bad time to take at the ask.

Heavy ask-side size (sells lined up, few buyers)

Market maker is long and trying to offload inventory. Your ask-taking fills cleanly. Good execution environment.

Balanced book

Normal. Size both sides, tight spread. Any fill should work at stated prices.

Compute it as a ratio:

def book_imbalance(book: dict, levels: int = 3) -> float:
    """Returns ratio of bid size to ask size over top N levels.
    > 1 = bid-heavy (harder to buy cheap). < 1 = ask-heavy (easy to buy).
    """
    bid_sz = sum(b["size"] for b in book["bids"][:levels])
    ask_sz = sum(a["size"] for a in book["asks"][:levels])
    if ask_sz == 0:
        return float("inf")
    return bid_sz / ask_sz

# Example
if book_imbalance(book) > 2.0:
    # Bids >> asks. Expect upward price pressure. Skip taker buys.
    skip("book-imbalance")

Signal 4: Level Stickiness

A price level that's been sitting on the book for minutes with real size is a hard level. A level that flickers in and out is a soft level. You want to take liquidity at hard levels — they're less likely to move away when your order hits.

In practice, track the persistence of top-of-book levels across snapshots:

class LevelTracker:
    def __init__(self):
        self.first_seen: dict = {}  # (price, side) -> first_seen_ts

    def observe(self, best_ask_price: float, ts: float):
        key = ("ask", best_ask_price)
        if key not in self.first_seen:
            # New level, possibly soft
            self.first_seen[key] = ts
        # Return how long this level has been visible
        return ts - self.first_seen[key]

# If the level has been > 3 seconds old, it's relatively hard. Safer to take.

Signal 5: Quote Age / Staleness

Between the moment the market maker's quote was posted and the moment you hit it, new information may have arrived. On sports markets, a score change after the quote but before your fill creates two scenarios:

You can't know which direction ahead of time, but you can skip trades where the quote is stale. If the best ask was posted 15+ seconds ago and hasn't updated, and a score just changed, the market maker is about to move that quote. Don't race them.

def is_quote_fresh(book: dict, max_age_s: float = 5.0) -> bool:
    """Is the top-of-book fresh enough to trust?"""
    age = time.time() - book.get("last_update_ts", 0)
    return age < max_age_s

When to Post Instead of Take

If the book is wide but your edge is real, you can post a bid 1-2 cents inside the current best ask and wait for a seller to hit you. This captures the spread instead of paying it — a real edge multiplier for a high-frequency bot.

The tradeoff: you may not fill at all. The decision:

SpreadYour edgeTake or post?
≤ 3c≥ 8cTake. The spread cost is tiny.
4-6c≥ 8cConsider posting at best_ask − 1c.
4-6c5-7cPost passive. Taking eats most of your edge.
7c+AnyPost or skip. Taking is expensive at any edge.

Putting the Signals Together

A practical entry filter combining all five:

def entry_safe(book: dict, order_size: int) -> tuple[bool, str]:
    """Returns (can_enter, reason). Trade only if can_enter."""
    spread = book["asks"][0]["price"] - book["bids"][0]["price"]
    best_ask_size = book["asks"][0]["size"]
    imb = book_imbalance(book)
    age = time.time() - book["last_update_ts"]

    if spread > 0.06:
        return False, f"spread_too_wide({spread:.3f})"
    if order_size > best_ask_size * 0.5:
        return False, f"size_too_big({order_size} vs {best_ask_size})"
    if imb > 2.5:
        return False, f"bid_heavy_book({imb:.1f})"
    if age > 5.0:
        return False, f"stale_quote({age:.1f}s)"
    return True, "ok"

This kind of pre-trade check is what separates a bot that leaks 2-3c to bad fills on every trade from one that captures the edge it calculates.

Get calibrated signals + execution guidance. ZenHodl's API includes pre-trade book quality checks alongside live win probabilities for 11 sports.

See ZenHodl

Further reading: Kelly Criterion for Position Sizing · Risk Management for Bots · Finding Edge in Prediction Markets