Historical Level 2 data does not arrive as an order book. It arrives as a stream of events: an order was added, an order was cancelled, an order was modified. The book is something you rebuild by replaying those events in order. This post does that in plain Python, from a real download to a verified book, and then shows what the rebuilt book lets you measure.
The code is one file with no third-party dependencies for the self-test and one optional package for reading vendor files: orderbook_reconstruction.py. Every number below comes from running it on 2026-09-17 against Nasdaq data for AAPL from 2026-09-15.
This is the follow-up to Tick and Level-2 Order Book Data Sources, which compares where to get the data. Here we assume you have a sample and want to do something with it.
MBO versus MBP: which one lets you rebuild the book?
Vendors sell three granularities under the "Level 2" label, and only one of them contains enough to rebuild the book yourself.
| Granularity | One record means | Can you rebuild the book? | What it is for |
|---|---|---|---|
| Trades | An execution happened at this price and size | No | Bar building, volume profiles |
| MBP (market by price), e.g. MBP-10 | After this event, the best 10 price levels look like this | No, the vendor already did it | Quick features on depth and imbalance, execution simulation at the level of price levels |
| MBO (market by order) | This specific order was added, cancelled, modified or filled | Yes | Queue position, order-level simulation, and building your own MBP at any depth |
MBP is a derived product. Someone ran exactly the replay you are about to run and kept the top N levels after each event. If you only need the top ten levels, buy MBP-10 and skip this post. If you need queue position (how many shares are ahead of you at a price), the full depth, or the ability to change what "the book" means (for example, exclude a venue or an order type), you need MBO.
The two are also how you check each other, which is what the verification section does.
Get a sample
I used Databento's XNAS.ITCH dataset (the Nasdaq TotalView-ITCH feed), symbol AAPL, 2026-09-15. Two files:
| File | Schema | Time range (UTC) | Records | Compressed size |
|---|---|---|---|---|
aapl_mbo.dbn.zst | MBO | 00:00 to 13:45 (through 09:45 ET) | 441,392 | 8.0 MB |
aapl_mbp10.dbn.zst | MBP-10 | 13:30 to 13:45 (09:30 to 09:45 ET) | 156,953 | 5.1 MB |
The download code, with a cost check before anything is billed:
import databento as db
client = db.Historical() # reads DATABENTO_API_KEY
window = dict(dataset="XNAS.ITCH", symbols=["AAPL"],
start="2026-09-15T00:00:00Z", end="2026-09-15T13:45:00Z")
print(client.metadata.get_cost(schema="mbo", **window)) # dollars, before you commit
client.timeseries.get_range(schema="mbo", path="aapl_mbo.dbn.zst", **window)
# The vendor's own top-10 aggregation for the regular session, used for verification below.
check = dict(window, start="2026-09-15T13:30:00Z")
print(client.metadata.get_cost(schema="mbp-10", **check))
client.timeseries.get_range(schema="mbp-10", path="aapl_mbp10.dbn.zst", **check)
Two things to know before you change the time range:
- Start at UTC midnight. Databento inserts a synthetic snapshot of the full book at the start of each UTC day, flagged
F_SNAPSHOT. Start later and the client warns you that your replay begins from an unknown state. For Nasdaq the book is empty at midnight, so this file contains zero snapshot records and the first record (at 07:04 UTC) is anR, "clear the book". For a venue whose session runs across midnight you will see the snapshot records and must apply them like ordinary adds. - Check the cost first. The
get_costquery returned $0.00 on my account for both files. Yours may differ by plan; the point is to ask before downloading.
If you use another vendor, the field names change but the event model below does not. Nasdaq's own ITCH specification, LOBSTER's academic files, and exchange replay archives all reduce to the same handful of actions.
The event model
Each MBO record carries a timestamp, an action, a side, a price, a size, an order id, a sequence number, and a flags byte. Only four actions change the book:
| Action | Meaning | Book effect |
|---|---|---|
A Add | A new resting order | Insert at the back of the queue at its price |
C Cancel | Fully or partially cancelled; size is the quantity removed | Reduce or delete |
M Modify | Price and/or size changed | Update; queue priority may be lost |
R Clear | Reset the book for this instrument | Delete everything |
T Trade | An aggressing order traded | None |
F Fill | A resting order was filled | None |
The last two rows are the trap. When an incoming buy hits a resting sell, Nasdaq's feed and Databento's normalization of it emit three records with the same sequence number: a T for the trade, an F for the resting order that was filled, and then a C that actually removes the filled quantity from that order. Here is the triplet at sequence 43912147, the first trade after the open:
13:30:00.012023050 T B 330.20 3 order 0
13:30:00.012023050 F A 330.20 3 order 60241693
13:30:00.012023050 C A 330.20 3 order 60241693
If your book reduces the order on F and again on C, every fill is double counted and the book drifts within seconds. The rule is: T and F are information, C does the work. The counts in this file make the split obvious: 15,712 trades, 9,304 fills, and 182,935 cancels, of which 9,304 are the ones that follow fills.
Prices are integers in units of one billionth of a dollar (330.20 arrives as 330200000000). Keep them as integers until you print. Floating point prices are how you end up with two levels at 330.199999 and 330.2.
The F_LAST flag (bit value 128) marks the last record of a venue event for an instrument. Between records of the same event the book is in an intermediate state that nobody should read. Compare, sample, or compute features only at F_LAST.
The data structure
One side of the book is a map from price to an ordered queue of orders. Python's dict keeps insertion order, which is exactly queue order, so a level is {order_id: size} and the side is a dict of levels plus a sorted list of prices for fast "best N levels" lookups:
class BookSide:
def __init__(self, is_bid: bool) -> None:
self.is_bid = is_bid
self.queues: dict[int, dict[int, int]] = {} # price -> {order_id: size}
self._sorted: list[int] = [] # ascending prices
def add(self, order_id: int, price: int, size: int) -> None:
queue = self.queues.get(price)
if queue is None:
queue = self.queues[price] = {}
bisect.insort(self._sorted, price)
queue[order_id] = size
The book keeps a second map from order id to (side, price, size), because cancels and modifies arrive with an order id and you need to find where that order rests. Applying an event is a short branch:
def apply(self, action, side, price, size, order_id):
if action == "A":
self.orders[order_id] = Order(side, price, size)
self._side(side).add(order_id, price, size)
elif action == "C":
order = self.orders[order_id]
remaining = order.size - size
if remaining <= 0:
self._delete(order_id)
else:
order.size = remaining
self._side(order.side).queues[order.price][order_id] = remaining
elif action == "M":
order = self.orders[order_id]
book_side = self._side(order.side)
if price != order.price or size > order.size:
book_side.remove(order_id, order.price) # loses priority: back of the new queue
order.price, order.size = price, size
book_side.add(order_id, price, size)
else:
order.size = size # size decrease keeps its place
book_side.queues[price][order_id] = size
elif action == "R":
self.orders.clear(); self.bids = BookSide(True); self.asks = BookSide(False)
# T, F, N: nothing
The modify rule (price change or size increase loses priority, size decrease keeps it) is Nasdaq's. Other venues differ, and some feeds represent a replace as a cancel plus an add with a new id, which is what this Nasdaq file does: it contains zero M records and 41,805 events of the form "cancel then add" under one sequence number. Read your venue's specification for this one rule; it is the only part of the replay that is not universal.
The full file also counts anomalies (a cancel for an unknown id, a cancel larger than the resting size, a duplicate add) instead of raising, because on real data you want a tally at the end, not a crash at record 200,000.
Replay
pip install databento-dbn zstandard # zstandard decompresses the .zst files
python orderbook_reconstruction.py --mbo aapl_mbo.dbn.zst --mbp10 aapl_mbp10.dbn.zst
loaded 152,507 MBP-10 records for verification
replayed 441,392 MBO records in 2.1s (210,507 rec/s)
actions: {'A': 233440, 'C': 182935, 'F': 9304, 'R': 1, 'T': 15712} snapshot records: 0
resting orders at end: 52,792 anomalies: {}
best bids: [(330.3, 5, 1), (330.28, 93, 4), (330.27, 205, 4), (330.26, 57, 3), (330.25, 150, 3)]
best asks: [(330.33, 40, 1), (330.34, 40, 1), (330.36, 85, 3), (330.37, 62, 3), (330.38, 80, 2)]
verification at F_LAST events: compared 148,402, matched 148,402, mismatched 0
Plain Python, no NumPy, about 210,000 records per second on a laptop. Extrapolating from the rate in this file, a full Nasdaq day for AAPL is on the order of ten million MBO records, so a whole day replays in about a minute. You do not need Rust or C++ to learn from this data; you need them when you replay hundreds of symbol-days per experiment.
At 09:45 ET the rebuilt book holds 52,792 resting orders across 5,403 bid prices and 2,650 ask prices. The best bid is 330.30 for 5 shares, the best ask 330.33 for 40 shares. Most of those levels are far from the touch; that is what "full depth" means, and it is why MBP-10 is enough for most people.
Verify against the vendor's MBP-10
A replay that runs without errors is not a replay that is correct. The check is to compare your book against the vendor's own aggregation, event by event. Databento's MBP-10 records carry the same sequence numbers as the MBO records they were derived from, so the join is exact:
if vendor and record.flags & F_LAST and record.sequence in vendor:
compared += 1
if book.top(10) == vendor[record.sequence]:
matched += 1
Two details make this comparison work, and both are worth understanding because they are properties of the data, not of the code:
Compare only at F_LAST. Inside a trade event the vendor's MBP-10 emits its T record before applying the cancel, so at that instant its top ten still shows the shares that are about to disappear, while a book that processes C has already removed them. My first version compared after every record and reported 13,254 mismatches, all of them mid-event, none at F_LAST. Those were not bugs. They were the vendor's intermediate state versus mine.
MBP-10 has fewer records than MBO. The same 15 minutes contain 378,870 MBO records but only 156,953 MBP-10 records. MBP-10 only emits a record when something in the top ten levels changes, and most adds and cancels in a 5,400-level book happen far below the touch. So the comparison covers the 148,402 events that touched the top ten, and all 148,402 matched.
If you use a vendor that does not provide the MBP counterpart, the self-test in the script (--self-test) is the fallback. It generates a random stream of adds, cancels, modifies, clears and trades, where each trade is the T, F, C triplet against a resting order, maintains a naive reference book by brute force, and checks every level and every queue position after every event. A book that reduces the order on F as well as on C fails the self-test at its first trade (I checked by injecting exactly that bug), and so does a wrong modify priority rule. It cannot catch a misreading of the vendor's format, which is what the MBP comparison is for.
The script also refuses two inputs that would otherwise produce a misleading "success": a file that mixes several instruments (their orders would merge into one book), and an MBO and MBP-10 pair whose time ranges do not overlap (zero comparisons is reported as a failure, not as zero mismatches).
What you can measure now
Once the book is a data structure rather than a file, questions that were impossible with bars become a few lines. Three examples from the same 15 minutes of AAPL, all sampled at F_LAST events.
The spread narrows after the open. Median quoted spread by minute, in cents:
| Minute after 09:30 ET | Events | Median spread | 90th percentile |
|---|---|---|---|
| 0 | 44,026 | 13 | 21 |
| 1 | 29,334 | 11 | 17 |
| 2 | 13,479 | 8 | 14 |
| 3 | 17,575 | 5 | 9 |
| 5 | 18,599 | 7 | 11 |
| 9 | 12,626 | 5 | 8 |
| 14 | 9,836 | 5 | 8 |
In the last pre-market hour the median spread was 16 cents. On this day it took about three minutes of continuous trading to settle near 5 cents. For this symbol, venue and date, a 1-minute-bar backtest with a fixed spread assumption would be off by a factor of two or three during those first three minutes. Whether the pattern holds on other days or other names is something to measure, not assume.
The top of book changes 25 times a second. There were 22,492 distinct best-bid-or-ask changes between 09:30 and 09:45. Any signal computed from "the current quote" is sampling a process that moves every 40 milliseconds; how you sample it (every event, every trade, every 100 ms) changes the answer.
Queue position is a number, not a guess. At 09:45 the bid level at 330.28 held four orders: 18, 30, 5 and 40 shares, in that order. An order joining that level would sit behind 93 shares and four orders. The script exposes this directly:
ahead_orders, ahead_shares = book.bids.queue_position(order_id, price)
That single number is what separates an execution simulation that fills you "when price touches" from one that fills you when the queue ahead of you has actually cleared. The difference is the subject of Why Backtests Pass and Live Trading Fails.
Pitfalls that survive a clean replay
- Starting mid-session without a snapshot. Cancels for orders you never saw show up as
cancel_unknown_idin the anomaly tally. A non-zero tally at the start of a replay usually means the range did not begin at the snapshot. - Gaps. A record with the
F_MAYBE_BAD_BOOKflag means the vendor detected an unrecoverable gap in the channel. Everything after it is suspect until the next snapshot or clear. Count these; do not average through them. - Two clocks.
ts_eventis the exchange's timestamp,ts_recvis when the vendor's capture saw the packet. Latency studies compare the two; feature construction should use one consistently. The first record in this file is flaggedF_BAD_TS_RECV, which is the vendor telling you not to trust its receive time for that message. - Instrument ids, not symbols. Records carry an
instrument_id; the mapping to "AAPL" is point-in-time and lives in the file's metadata. Pulling several symbols into one file and grouping by ticker without that mapping is a quiet way to merge two books. - Licensing. Being able to download a sample does not grant the right to redistribute it or the derived series. The data behind this post is not included with the script for that reason; the script's self-test runs without it. Market Data Licensing Basics covers what to check before a dataset leaves your research box.
Where to go next
- Compare vendors and granularities before buying: Tick and Level-2 Order Book Data Sources and Data Provider Comparison.
- Databento's MBO schema documentation is the reference for the actions, flags and snapshot behavior described here; the field-level definitions are also in the open-source
dbncrate documentation. - Nasdaq publishes the TotalView-ITCH specification that defines the underlying messages, including the priority rules for replace.
- Once the book is rebuilt, the next question is whether a signal computed from it survives costs and queue risk out of sample. The research process for that is in the Dnalyaw quant trading system posts.