# Reconstruct the top of book from L3 events

> Replay one session of order-by-order events into a book, read the best bid and offer each second, and check it against the L2 rows.

Source: https://getmarkethistory.com/docs/guides/l3-top-of-book

[L3 events](https://getmarkethistory.com/docs/l3) are every add, modify, cancel and fill on one venue's book, order by order.
Replay a session from its first event and you hold that book at any instant. This guide builds the
book in plain Python, reads its top every second, and checks the result against the
[L2 rows](https://getmarkethistory.com/docs/l2), which are computed the same way.

**You need** the [Python client](https://getmarkethistory.com/docs/quickstart#4-into-pandas) and a key with bought bars. Ten
events bill one bar: a median ticker's day is about 1,000 events (100 bars), a busy one millions.
Watch `X-Bars-Charged`, or try a quiet ticker first.

## 1. The book

```python
from collections import defaultdict

orders = {}  # order_id -> [side, price, size]
levels = {
    "B": defaultdict(lambda: [0, 0]),
    "A": defaultdict(lambda: [0, 0]),
}  # price -> [shares, orders]


def put(oid, side, price, size):
    orders[oid] = [side, price, size]
    level = levels[side][price]
    level[0] += size
    level[1] += 1


def take(oid):
    order = orders.pop(oid, None)
    if order:
        side, price, size = order
        level = levels[side][price]
        level[0] -= size
        level[1] -= 1
        if level[1] == 0:
            del levels[side][price]
    return order


def top():
    bids, asks = levels["B"], levels["A"]
    bid = (b := max(bids), *bids[b]) if bids else (None, None, None)
    ask = (a := min(asks), *asks[a]) if asks else (None, None, None)
    return bid + ask  # bid_px, bid_sz, bid_ct, ask_px, ask_sz, ask_ct
```

## 2. Apply each event

```python
def apply(e):
    act, oid = e["action"], e["order_id"]
    if act == "A" and e["side"] in ("B", "A"):
        take(oid)  # an id added again replaces the old order
        put(oid, e["side"], e["price"], e["size"])
    elif act == "M":  # new price and size, same side; size 0 removes it
        old = take(oid)
        if old and e["size"] > 0:
            put(oid, old[0], e["price"], e["size"])
    elif act == "C":
        take(oid)
    elif act == "F":  # a resting order traded: it shrinks at its own price
        old = take(oid)
        if old and old[2] > e["size"]:
            put(oid, old[0], old[1], old[2] - e["size"])
    elif act == "R":  # the venue cleared the book
        orders.clear()
        levels["B"].clear()
        levels["A"].clear()
    # T and B are prints and breaks: they rest nothing
```

`F` reduces the resting order named by `order_id`. Its `price` is the print, which can differ
from the order's own price, so the level the shares come off is the stored one.

## 3. Replay a session, one row per second

```python
from markethistory import Client

mh = Client()  # reads MARKET_HISTORY_KEY
day = "2026-10-30"

mine, last, second = {}, (None,) * 6, None
for e in mh.each(mh.mbo, "AAPL", date=day, limit=100_000):
    s = e["timestamp"][:19]  # the event's second, UTC
    if second is not None and s != second:
        t = top()  # the book after the last event of the previous second
        if t != last:
            mine[second] = last = t
    second = s
    apply(e)
if second is not None and top() != last:
    mine[second] = top()
```

Events arrive in `sequence` order, the venue's order of record, and a session starts from an empty
book, so no snapshot is needed. Keying the second on the timestamp text keeps every nanosecond
exact. A second gets a row only when its top differs from the row before, which is what the L2
endpoint serves.

## 4. Check it against L2

```python
served = {
    r["timestamp"][:19]: (
        r["bid_px"],
        r["bid_sz"],
        r["bid_ct"],
        r["ask_px"],
        r["ask_sz"],
        r["ask_ct"],
    )
    for r in mh.each(mh.l2, "AAPL", date=day, limit=100_000)
}
differ = [s for s in served.keys() | mine.keys() if served.get(s) != mine.get(s)]
print(f"{len(served)} seconds served, {len(mine)} rebuilt, {len(differ)} differ")
```

Both come from the same events by the same rules, so the count that differs should be 0. From
here the book is yours: `levels` holds every price level, so depth, imbalance or queue position
at any event are a few lines more.

## Things that bite

- **Order by `sequence`, never by `timestamp`.** Across tickers, timestamps can step back by up to
  milliseconds; within one ticker's pages the order is already right.
- **`order_id` is not a key forever.** An id can come back after a cancel; `take()` before
  `put()` handles it.
- **One venue.** This is that venue's book, not the national best bid and offer.
- **Crossed or locked tops** happen in the data as published. Do not drop them silently.
