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.
| Level | Dataset | Row means | Lets you see | Plan |
|---|---|---|---|---|
| L1 | quotes | Best bid and offer changed | Spread, top-of-book size | Researcher |
| L2 | book_l2 | Size at one price level changed | Depth profile, liquidity at distance | Researcher |
| L3 | book_l3 | One order was added, modified, cancelled or filled | Queue position, order lifetimes, cancel behavior | Desk |
| L4 | book_l4 | L3 event plus the account that placed the order | Who provides and takes liquidity | Desk |
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: thesizeis the new absolute size at that price, never an increment. A size of0removes 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
| Snapshot | snapshot_reason | Written | Purpose |
|---|---|---|---|
| File start | file_start | At 00:00 UTC, full depth, opening each daily file. | Each file replays on its own. |
| Hourly | checkpoint | At 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. |
| Venue | venue | When 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.
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:
| Event | Meaning | size after | Book change |
|---|---|---|---|
add | A new order rests on the book. | Resting size | Append to the back of the queue at price. |
modify | Price or size changed, same order_id. | New resting size | If keeps_priority, update in place. Otherwise remove and append at the back of the queue at the new price. |
cancel | The order left the book without trading. | 0 | Remove. |
fill | The resting order traded against an aggressor. | Remaining size | Reduce 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_exchange | event | order_id | price | prev_size | size | fill_size | keeps_priority |
|---|---|---|---|---|---|---|---|
| …600031000000 | add | 141092371988 | 104318 | 0 | 0.5 | ||
| …600127000000 | fill | 141092371988 | 104318 | 0.5 | 0.4579 | 0.0421 | |
| …601884000000 | modify | 141092371988 | 104318 | 0.4579 | 0.3 | true | |
| …604210000000 | cancel | 141092371988 | 104318 | 0.3 | 0 |
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.
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 stateL3 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.
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.
| Venue | Phase | Planned depth | How continuity is checked |
|---|---|---|---|
| Hyperliquid | 1 | L2 (full, aggregated from L3), L3, L4 with addresses | Block height and event index |
| Binance | 2 | L2, L3 | To be confirmed with the venue |
| Bybit | 2 | L2, L3 | To be confirmed with the venue |
| OKX | 2 | L2, L3 | To be confirmed with the venue |
| Coinbase | 3 | L2, L3, L4 | To be confirmed with the venue |
| Kraken | 3 | L2, L3, L4 | To be confirmed with the venue |
| Bitget | 3 | L2, L3 | To 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
gapsrow withreason = checksum_mismatchandresync = true, and the book continues from the new venue snapshot. Venue checksum values stay in the raw tier; the result of each check is inchecksum_okon everybook_l2row (null on venues without a checksum). - Our snapshot checksum is in the
checksumcolumn 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.
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 ofstart(is_snapshot = true,snapshot_reason = "checkpoint"), built from the nearest earlier hourly snapshot plus the deltas in between. Deltas follow from the first message afterstart. - 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 (sameseq, up tois_last), then apply deltas forward. Hourly snapshots bound the work to at most one hour of deltas. - Live.
live=Truewith astartin the past replays from the nearest snapshot, then hands over to the stream onseq, with no gap and no duplicate message.
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 tGaps 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.