DOCS/CONCEPTS

Order book depth

Three depth datasets, one set of rules. This page covers what each row means, how to turn rows into a book you can trust, and where venues limit what can be rebuilt.

LevelDatasetRow meansLets you seePlan
L1quotesBest bid and offer changedSpread, top-of-book sizeResearcher
L2book_l2Size at one price level changedDepth profile, liquidity at distanceResearcher
L3book_l3One order was added, modified, cancelled or filledQueue position, order lifetimes, cancel behaviorDesk
L4book_l4L3 event plus the account that placed the orderWho provides and takes liquidityDesk

L2: snapshots and deltas

book_l2 has two kinds of rows, told apart by is_snapshot.

  • Snapshot rows (is_snapshot = true) together describe the whole book at one moment. When the first row of a snapshot arrives, discard the book you hold and rebuild it from the snapshot rows.
  • Delta rows (is_snapshot = false) set one level: the size is the new absolute size at that price, never an increment. A size of 0 removes the level.

Rows produced from one venue message share a seq, and the last of them has is_last = true. A book is consistent only at message boundaries. If you read it in the middle of a message you can see a crossed or half-updated book that the venue never published.

Where snapshots come from

Snapshotsnapshot_reasonWrittenPurpose
File startfile_startAt 00:00 UTC, full depth, opening each daily file.Each file replays on its own.
HourlycheckpointAt every other UTC hour, full depth. The synthetic snapshot at the start of a REST or SDK response also uses checkpoint.A replay can start within an hour of any time.
VenuevenueWhen the venue sends one: on subscribe, on a venue-side reset, or after we resync following a gap.Ground truth from the venue.

File-start and hourly snapshots are built from our own reconstruction (source = fdc_derived) and carry a checksum (below). They also carry the seq of the last venue message applied, so you can check that a replay you built yourself matches ours at the same point. Venue snapshots have source = fdc_capture. Order books are never backfilled: depth history starts on the day our capture began, November 2025 for Hyperliquid.

On Hyperliquid, book_l2 is aggregated from our full L3 reconstruction, so it covers every price level. The venue's own 20-level L2 snapshots are kept untouched in the raw tier.

L3: order events

book_l3 records every change to every resting order. Four event types:

EventMeaningsize afterBook change
addA new order rests on the book.Resting sizeAppend to the back of the queue at price.
modifyPrice or size changed, same order_id.New resting sizeIf keeps_priority, update in place. Otherwise remove and append at the back of the queue at the new price.
cancelThe order left the book without trading.0Remove.
fillThe resting order traded against an aggressor.Remaining sizeReduce in place; remove when size reaches 0. Priority is kept on partial fills.

A fully filled order produces a fill with size = 0 and no separate cancel. Aggressive orders that trade immediately and never rest do not appear as add events; they appear only through the fill rows of the orders they hit and in trades, linked by trade_id.

One order's life

ts_exchangeeventorder_idpriceprev_sizesizefill_sizekeeps_priority
…600031000000add14109237198810431800.5
…600127000000fill1410923719881043180.50.45790.0421
…601884000000modify1410923719881043180.45790.3true
…604210000000cancel1410923719881043180.30

The fill at …600127000000 is the same execution as the trade shown on Schema reference: its trade_id matches and the trade's maker_order_id is this order.

Snapshots in L3

L3 snapshots are rows with event = add and is_snapshot = true. They list every resting order in queue order, best price first and front of queue first within each price. They are written on the same schedule as L2: every UTC hour and after a resync.

L4: participant attribution

book_l4 has every book_l3 column plus participant_id, counterparty_id (on fills) and entity_id. Attribution comes from the venue itself and is offered only where the venue publishes it. On Hyperliquid the participant is the trader's on-chain address, which the venue makes public for every order.

An address is not an identity. entity_id links the address to an entity resolved by our chain intelligence (L4+), which also connects it to activity on other venues. It is populated only with the chain intelligence add-on, included in Enterprise, and is null otherwise. Methods are documented on Chain intelligence.

Rebuilding a book

L2

A dictionary per side is enough. Clear on the first row of a snapshot, set or delete on every row, read only after is_last.

PYTHON
from decimal import Decimal

def replay_l2(rows):
    book = {"bid": {}, "ask": {}}
    new_message = True
    for r in rows:
        if new_message and r["is_snapshot"]:
            book = {"bid": {}, "ask": {}}
        price, size = Decimal(r["price"]), Decimal(r["size"])
        if size == 0:
            book[r["side"]].pop(price, None)
        else:
            book[r["side"]][price] = size
        new_message = r["is_last"]
        if new_message:
            yield r["ts_capture"], book   # consistent state

L3 with queue position

Keep each price level as an ordered map from order id to size. Insertion order is queue order, so queue position is a walk from the front.

PYTHON
from collections import OrderedDict
from decimal import Decimal

class L3Book:
    def __init__(self):
        self.orders = {}                        # order_id -> (side, price)
        self.levels = {"bid": {}, "ask": {}}   # price -> OrderedDict(order_id -> size)

    def _insert(self, oid, side, price, size):
        self.orders[oid] = (side, price)
        self.levels[side].setdefault(price, OrderedDict())[oid] = size

    def _remove(self, oid):
        side, price = self.orders.pop(oid)
        queue = self.levels[side][price]
        del queue[oid]
        if not queue:
            del self.levels[side][price]

    def apply(self, r):
        oid, side, ev = r["order_id"], r["side"], r["event"]
        price, size = Decimal(r["price"]), Decimal(r["size"])
        if ev == "add":
            self._insert(oid, side, price, size)
        elif ev == "modify" and not r["keeps_priority"]:
            self._remove(oid)
            self._insert(oid, side, price, size)      # back of the queue
        elif size == 0:
            self._remove(oid)                         # cancel or full fill
        else:
            self.levels[side][price][oid] = size      # partial fill or in-place modify

    def size_ahead(self, oid):
        side, price = self.orders[oid]
        ahead = Decimal(0)
        for other, sz in self.levels[side][price].items():
            if other == oid:
                return ahead
            ahead += sz


book, new_message = L3Book(), True
for r in fdc.book(venue="hyperliquid", symbol="BTC-PERP", depth="L3",
                  start="2026-09-15T14:30:00Z", end="2026-09-15T14:35:00Z"):
    if new_message and r["is_snapshot"]:
        book = L3Book()
    book.apply(r)
    new_message = r["is_last"]

The same class works for book_l4; store participant_id next to the size if you need it per order. For a worked study of queue dynamics on this data, see Reconstructing L3 queues on Hyperliquid.

Depth truncation

Some venues publish only a window of the book: the best N levels on each side. Inside that window the book is exact. Outside it, our data says nothing: the region beyond the last level should be read as unknown, even though it looks empty.

  • When a level moves out of a truncated window, some venues send no removal. After each message we trim each side to the venue's window, so a replay matches what the venue's own book would show.
  • Aggregate depth figures (for example, size within 1% of mid) are only meaningful when the window reaches that far. Check the distance from mid to the last level before trusting them.
  • Hyperliquid and other venues that publish order-level data give a full-depth book, with no window.
VenuePhasePlanned depthHow continuity is checked
Hyperliquid1L2 (full, aggregated from L3), L3, L4 with addressesBlock height and event index
Binance2L2, L3To be confirmed with the venue
Bybit2L2, L3To be confirmed with the venue
OKX2L2, L3To be confirmed with the venue
Coinbase3L2, L3, L4To be confirmed with the venue
Kraken3L2, L3, L4To be confirmed with the venue
Bitget3L2, L3To be confirmed with the venue

Every venue other than Hyperliquid is planned, subject to a data agreement with the exchange. Depth windows, checksums and sequence fields for those venues will be confirmed with each venue and published on Venues before capture starts.

Checksums

Two kinds of checksum are involved. Only our own appears as a value in the data; for venue checksums, the data carries the result of the check.

  • Venue checksums, where a venue publishes one, are validated at capture time against the raw message, using the venue's own strings and algorithm. A failed check triggers an immediate resync: we request a fresh venue snapshot, record a gaps row with reason = checksum_mismatch and resync = true, and the book continues from the new venue snapshot. Venue checksum values stay in the raw tier; the result of each check is in checksum_ok on every book_l2 row (null on venues without a checksum).
  • Our snapshot checksum is in the checksum column of every file-start and hourly snapshot row. It lets you confirm that the book you rebuilt matches ours, on every venue, whether or not the venue publishes a checksum.

The snapshot checksum is a CRC32 over the top 25 levels of each side. Build a string by interleaving levels, best first, as bid_price:bid_size:ask_price:ask_size, joined with :, using our normalized decimal strings (no trailing zeros, no exponent). If one side has fewer than 25 levels, the missing entries are skipped. The value is stored unsigned.

PYTHON
import zlib
from decimal import Decimal

def fmt(d: Decimal) -> str:
    return format(d.normalize(), "f")          # 0.10 -> "0.1", 1E+2 -> "100"

def fdc_checksum(book) -> int:
    bids = sorted(book["bid"].items(), reverse=True)[:25]
    asks = sorted(book["ask"].items())[:25]
    parts = []
    for i in range(25):
        if i < len(bids):
            parts += [fmt(bids[i][0]), fmt(bids[i][1])]
        if i < len(asks):
            parts += [fmt(asks[i][0]), fmt(asks[i][1])]
    return zlib.crc32(":".join(parts).encode())

# just before applying the next hourly snapshot, compare your replayed book:
#   assert fdc_checksum(book) == snapshot_row["checksum"]

If your replay disagrees with a snapshot checksum, the usual causes are reading the book in the middle of a message (before is_last), applying rows in ts_capture order instead of seq order, or crossing a gap without resetting.

Replay from any point

Seeing the book at 14:37:12 never requires a replay from midnight.

  • SDK and REST. Pass start. The first rows returned are a snapshot of the book as of start (is_snapshot = true, snapshot_reason = "checkpoint"), built from the nearest earlier hourly snapshot plus the deltas in between. Deltas follow from the first message after start.
  • Files. In a daily Parquet file, find the last snapshot row (is_snapshot = true) at or before your time, take every row of that snapshot (same seq, up to is_last), then apply deltas forward. Hourly snapshots bound the work to at most one hour of deltas.
  • Live. live=True with a start in the past replays from the nearest snapshot, then hands over to the stream on seq, with no gap and no duplicate message.
PYTHON · POLARS · FROM A FILE
import polars as pl

t = 1789483032000000000   # 2026-09-15 14:37:12 UTC
df = pl.read_parquet("book_l2/venue=hyperliquid/date=2026-09-15/BTC-PERP.parquet")

ckpt_seq = (df.filter((pl.col("is_snapshot")) & (pl.col("ts_capture") <= t))
              .select(pl.col("seq").last()).item())
start = df.filter((pl.col("seq") == ckpt_seq) & pl.col("is_snapshot")) \
          .select(pl.col("ts_capture").first()).item()

rows = df.filter((pl.col("ts_capture") >= start) & (pl.col("ts_capture") <= t)).sort("seq", maintain_order=True)
for ts, book in replay_l2(rows.iter_rows(named=True)):
    pass                       # book now holds the state at t

Gaps and the book

If a gap survives dual-region merging, the book you hold is wrong from that point until the next snapshot. The gaps table tells you where; when resync is true, the first book rows after ts_end are a venue snapshot. The SDK raises fdc.GapError by default when a replay crosses a gap, or skips to the following snapshot if you pass on_gap="resync". Merging, gap detection and gap reasons are on Data quality.