> ## Documentation Index
> Fetch the complete documentation index at: https://docs.polyorderbooks.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Working with Polymarket L2 Order Book Data

> How to read Polymarket L2 order book snapshots — ladder structure, complementary outcome pricing, deriving spread and depth, simulating fills, and the carry-forward pitfall.

The `/books` endpoints return the resting bid and ask ladders for each outcome, not just a
last traded price. That is the point of the archive: a last price tells you where the market
was marked, while the ladder tells you what you could actually have traded against.

This page covers how to read those snapshots correctly. For endpoint mechanics see
[Historical overview](/historical/overview); for bucket sizes see
[Resolutions](/historical/resolutions).

## Snapshot anatomy

Each entry in `data.<outcome>` is one snapshot:

```json theme={null}
{
  "t": "2026-08-19T02:00:00Z",
  "bids": [[0.19, 147.34], [0.18, 63.69], [0.17, 277.77]],
  "asks": [[0.2, 13.6], [0.21, 152.17]]
}
```

| Property | Meaning                                                          |
| -------- | ---------------------------------------------------------------- |
| `t`      | Bucket timestamp, UTC ISO-8601                                   |
| `bids`   | `[price, size]` pairs, **best (highest) bid first** — descending |
| `asks`   | `[price, size]` pairs, **best (lowest) ask first** — ascending   |

Three things to note before you write parsing code:

* **Prices are probabilities.** Each is in `[0, 1]` and represents the market-implied chance
  of that outcome resolving true. `0.19` means 19%.
* **Sizes are fractional.** Real values look like `147.34` and `4812.53`, not round share
  counts. Do not assume integers.
* **Depth varies.** A single snapshot might carry 15 bid levels and 26 ask levels. Code that
  assumes a fixed number of levels per side will silently truncate or throw.

<Warning>
  Snapshot lists can be **empty**. A market with no captures in your requested window returns
  `"Yes": []` rather than an error, with `metadata.count` of `0`. Always guard before indexing
  `bids[0]`.
</Warning>

## Outcomes are complementary

A binary market returns both sides, and they are two views of the same book:

```
Yes   bid 0.19   ask 0.20
No    bid 0.80   ask 0.81
```

Buying `No` at `0.81` is economically the same as selling `Yes` at `0.19`, so:

```
Yes_bid + No_ask == 1
Yes_ask + No_bid == 1
```

This holds structurally, which makes it a useful integrity check on your own pipeline — if
those sums drift from `1`, you have mismatched timestamps or mixed up outcome labels.

It also explains why level counts mirror between outcomes: 15 bids and 26 asks on `Yes`
appears as 26 bids and 15 asks on `No`. They are the same orders, reflected.

## Deriving the standard metrics

Everything below comes from the top of each ladder:

```python theme={null}
def snapshot_metrics(point):
    bids, asks = point.get("bids") or [], point.get("asks") or []
    if not bids or not asks:
        return None                      # no book in this bucket

    best_bid, bid_size = bids[0]
    best_ask, ask_size = asks[0]

    return {
        "t": point["t"],
        "best_bid": best_bid,
        "best_ask": best_ask,
        "mid": (best_bid + best_ask) / 2,
        "spread": best_ask - best_bid,
        # size-weighted mid leans toward the side with more resting size
        "weighted_mid": (best_bid * ask_size + best_ask * bid_size) / (bid_size + ask_size),
        "bid_notional_5": sum(p * s for p, s in bids[:5]),
        "ask_notional_5": sum(p * s for p, s in asks[:5]),
    }
```

On the snapshot above that yields a mid of `0.195` and a spread of `0.01` — about 5% of mid.
Spreads that wide are normal in thin prediction markets and are exactly why midpoint-only
data misleads backtests.

## Simulating a fill

To model what an order would actually have cost, walk the ladder consuming size:

```python theme={null}
def simulate_buy(point, target_size):
    """Cost of buying target_size by consuming resting asks."""
    filled = cost = 0.0
    for price, size in point.get("asks") or []:
        take = min(size, target_size - filled)
        cost += take * price
        filled += take
        if filled >= target_size:
            break

    if filled < target_size:
        return {"filled": filled, "cost": cost, "complete": False}

    avg = cost / filled
    return {
        "filled": filled,
        "cost": cost,
        "avg_price": avg,
        "slippage": avg - point["asks"][0][0],
        "complete": True,
    }
```

Run against the snapshot above, buying `100` gives an average fill of `0.2086` versus a quoted
best ask of `0.20` — **4.3% worse than the top of book**, because only `13.6` was available
there and the rest came from `0.21` and beyond. A backtest marking that trade at mid (`0.195`)
would overstate entry by roughly 7%.

`complete: False` matters too. The ladder is finite: on this snapshot the entire ask side
absorbs `124,483` before running out, so anything larger is unfillable at any price in that
bucket. A model assuming unlimited liquidity will report profits that were never available.

## Pitfalls

### A flat series does not mean a quiet market

Buckets are materialised with **last-value carry** — each bucket reports the most recent known
value at or before its timestamp. Six consecutive 1-minute buckets showing an identical
`0.19 / 0.20` book may mean the book genuinely did not move, or that no new capture landed in
those minutes. The response cannot distinguish the two.

This will corrupt any calculation that treats consecutive buckets as independent observations:
realised volatility, counts of book changes, and mean time-between-updates will all be biased.
Where that distinction matters, query at `1s` (Pro and above) to minimise carry, and treat
repeated values as "unchanged or unobserved" rather than "unchanged".

### One book per bucket, not every change

Within each interval the API returns the **last** order book, so a `60s` bucket is a sample,
not a summary. Intra-bucket movement is not recoverable at that resolution — request finer
buckets instead of trying to reconstruct it.

### The window is half-open

`end_ts` is exclusive: `[start_ts, end_ts)`. Requesting a full day means `end_ts` at the
following midnight. Getting this wrong silently drops the final bucket.

### Depth is not a coverage guarantee

Ladder depth reflects what was resting at capture time. A shallow ladder is a fact about the
market, not a gap in the archive.

## Related

<CardGroup cols={3}>
  <Card title="Historical overview" icon="clock-rotate-left" href="/historical/overview">
    Endpoints, parameters, and response envelope.
  </Card>

  <Card title="Resolutions" icon="clock" href="/historical/resolutions">
    Bucket sizes and per-plan resolution floors.
  </Card>

  <Card title="Order book endpoint" icon="layer-group" href="/api-reference/history/get-market-order-book-history">
    Full request and response reference.
  </Card>
</CardGroup>

**Related reading:** [Historical Polymarket order book data for backtesting](https://polyorderbooks.com/blog/historical-polymarket-order-book-data-backtesting)
covers why L2 depth changes backtest results, and the
[free BTC 5-minute L2 sample](https://polyorderbooks.com/datasets/polymarket-btc-5min-orderbook-sample)
lets you run the code above without an API key.
