title: L1 → L2 → L3: three resolutions of the same market¶
L1 → L2 → L3: three resolutions of the same market¶
Chapter 1 ended with an idea: each finer level of market data summarizes less. This chapter makes it precise. Market data comes in three standard resolutions, and they nest like map zoom levels — city, street, building. Same territory, three levels of detail, and each level is a lossy summary of the next: you can always compute L1 from L2, and L2 from L3, but never the reverse.
One instant, three renderings¶
Here is the toy session's book at t=30s, drawn at all three resolutions at once:
%matplotlib inline
import matplotlib.pyplot as plt
import pandas as pd
from _docs_theme import plot_l1_ticker
from ob_analytics import toy_events, toy_trades
from ob_analytics.analytics import order_book, set_order_types
from ob_analytics.visualization import plot, prepare
events = set_order_types(toy_events(), toy_trades())
t30 = events["timestamp"].iloc[0] + pd.Timedelta(seconds=30)
snap = order_book(events, tp=t30)
fig, (ax1, ax2, ax3) = plt.subplots(1, 3, figsize=(15, 4.4))
plot_l1_ticker(bid=99, ask=101, last=101, ax=ax1)
ax1.set_title("L1 — the quote", fontsize=10)
plot("book_snapshot", level="L2", ax=ax2, **prepare.book_snapshot(snap))
ax2.set_title("L2 — market by price", fontsize=10)
plot("book_snapshot", level="L3", ax=ax3, **prepare.book_snapshot(snap, per_order=True))
ax3.set_title("L3 — market by order", fontsize=10)
fig.tight_layout()

Read it left to right, adding detail as you go:
- L1 is the quote: best bid 99, best ask 101, last 101. Three numbers, nothing else.
- L2 (market by price) unfolds the quote into the full ladder — every price level with its total size: 4 at 99, 4 at 98, not just the best. L1 was this picture's top row.
- L3 (market by order) splits each bar into its owners: at 99 those 4 units are two bids (Alice's 2 and Ivy's 2), at 98 two more. Identity and queue position appear.
Now run it backwards: each panel is a lossy summary of the one to its right. Summing L3's segments gives you L2 exactly; keeping L2's top row gives you L1 exactly. The reverse is impossible — who owns the 4 at 99, and who is first in line, cannot be recovered from the bar's length.
Same L2, different markets¶
How much does the L3 → L2 summary destroy? Here are two ask books that are identical at L2 — four units offered at 101 — drawn at both resolutions:
whale = {
"timestamp": t30,
"bids": pd.DataFrame({"id": [1], "price": [99.0], "volume": [4.0]}).iloc[:0],
"asks": pd.DataFrame({"id": [10], "price": [101.0], "volume": [4.0]}),
}
crowd = {
"timestamp": t30,
"bids": whale["bids"],
"asks": pd.DataFrame(
{"id": [21, 22, 23, 24], "price": [101.0] * 4, "volume": [1.0] * 4}
),
}
fig, axes = plt.subplots(2, 2, figsize=(10, 6), sharex=True, sharey=True)
for col, (name, book) in enumerate([("one whale", whale), ("a crowd", crowd)]):
plot("book_snapshot", level="L2", ax=axes[0][col], **prepare.book_snapshot(book))
axes[0][col].set_title(f"L2 — {name}", fontsize=10)
plot(
"book_snapshot",
level="L3",
ax=axes[1][col],
**prepare.book_snapshot(book, per_order=True),
)
axes[1][col].set_title(f"L3 — {name}", fontsize=10)
for ax in axes.ravel():
ax.set_ylim(100.4, 101.6)
ax.legend().remove()
fig.tight_layout()

The top row is indistinguishable. The bottom row is two different markets: a single 4-lot (one order, which can be cancelled all at once) versus four independent 1-lots. If you trade against it, the difference matters: the whale cancelling removes all the liquidity at once; the crowd thins out one order at a time. L2 cannot tell you which market you're in. That is the information L3 buys you.
Why L3 exists: the queue¶
Chapter 1 showed the 99-bid queue as stacked bars. The package's queue engine turns that into a quantitative face: each resting order's FIFO rank at the touch over time — rank 1 is the front of the line — coloured by its outcome. Every trajectory below is labeled with its owner, and the book ladders underneath show the decisive instants:
from _docs_theme import plot_queue_story
fig = plot_queue_story(events, toy_trades(), at_s=[6, 45, 56, 57])
/home/runner/work/ob-analytics/ob-analytics/ob_analytics/visualization/_matplotlib.py:962: UserWarning: This figure includes Axes that are not compatible with tight_layout, so results might be incorrect.
fig.tight_layout()

Now the walk is literal:
- Ivy (pink) is the clearest case: she joins the 99 queue at t=6 at
rank 2 — the ladder below shows her stacked behind Alice — and
waits there for fifty seconds. When Sam's sweep fills Alice
at t=56 (×), Ivy steps to rank 1... and is promptly half-filled
herself (t=57 ladder: only
Iv 1remains). Still resting at the end: pink. - Bob holds rank 1 on the ask side for 46 seconds and fills in two bites. Frank, Iris, and Sam pass through rank 1 for a single instant each — market orders technically join the queue too, for the moment it takes to match.
- Eve (yellow ○) jumps straight to rank 1 at a brand-new best price of 100 — front of a queue of one. The t=45 ladder catches her mid-flash; 800 ms later she cancels.
- Dana and Chen never appear at all. This face tracks the queue at the touch; their bids at 98 sat one level below it the whole session. A general point about reading any plot: know what it excludes.
Ivy's fifty seconds at rank 2 and Hana's story are two answers to the same question — how do I get to the front? Ivy waited. Hana paid: her order crossed the spread (a taker for 2 units), and the unfilled remainder rested at 101, a fresh best bid — instant rank 1, filled four seconds later (her × at t=52). That trade-off — queue time versus crossing cost — is a central one in market making, and it is invisible below L3.
Which level do you need?¶
| Question | L1 | L2 | L3 |
|---|---|---|---|
| Did the price go up today? | ✅ | ✅ | ✅ |
| What would it cost to buy 500 right now? | ❌ | ✅ | ✅ |
| How much liquidity sits within 10 bps of the mid? | ❌ | ✅ | ✅ |
| Is that liquidity one whale or a crowd? | ❌ | ❌ | ✅ |
| Where is my order in the queue? | ❌ | ❌ | ✅ |
| Who cancelled, and how fast? Flash detection? | ❌ | ❌ | ✅ |
| Full order lifetimes, maker/taker attribution? | ❌ | ❌ | ✅ |
The market-data industry prices along the same ladder: L1 quotes are ubiquitous and cheap, L2 depth costs more, and full L3 — usually sold as market-by-order (MBO) feeds — is the premium product (LOBSTER for academic equity data, Databento and the exchanges' direct feeds commercially). ob-analytics is built for that top rung: its loaders reconstruct full per-order streams, which is why every question in the table is answerable.
The resolution decides what you may ask¶
This isn't just a data-shopping concern — it's encoded in the API. Load the bundled real capture and ask what it can draw:
from ob_analytics import Pipeline, sample_csv_path
from ob_analytics.visualization import available_concepts
result = Pipeline().run(sample_csv_path())
available_concepts(result)
{'trade_tape': ['L2', 'L3'],
'depth_heatmap': ['L2'],
'order_activity': ['L2', 'L3'],
'cancellations': ['L2', 'L3'],
'book_snapshot': ['L2', 'L3'],
'depth_chart': ['L2', 'L3'],
'order_outcome': ['L3'],
'queue_position': ['L3'],
'liquidity_at_touch': ['L2', 'L3'],
'volume_percentiles': ['L2'],
'price_view': ['L2'],
'events_histogram': ['L2'],
'trade_size': ['L2']}
Some concepts exist at both resolutions (trade_tape,
book_snapshot — the comparable pairs you've been looking at);
some only make sense at one (depth_heatmap aggregates by
construction; queue_position and order_outcome need identities).
Here is the queue face again — same code as the toy — on 314,000
real events:
t_start = result.events["timestamp"].min()
payload = prepare.queue_position_l3(
result.events,
start_time=t_start,
end_time=t_start + pd.Timedelta(minutes=10),
)
# Show the front of the queue only: trajectories that ever reached the
# top five ranks. The full plot has hundreds of lanes; the story —
# marching to rank 1 — happens here.
for fate in ("filled", "cancelled", "resting"):
front_ids = payload[fate].loc[payload[fate]["rank"] <= 5, "id"].unique()
payload[fate] = payload[fate][payload[fate]["id"].isin(front_ids)]
payload["max_rank"] = 8
fig = plot("queue_position", level="L3", **payload)

Filtered to the orders that ever reached the top five ranks — the front of the line, where fills happen — this is the same pattern as Ivy's fifty seconds, on real data: trajectories stepping downward as the queue ahead of them fills or cancels, then ending in a fill (×), a cancellation (○), or the window's edge.
Pitfall: not every L3 feed is a matched book
LOBSTER and exchange MBO feeds are matched books: the venue's
own engine guarantees bids never cross asks. The Bitstamp public
feed reconstructed here is a placement/cancellation diff
stream — and it genuinely contains crossed resting orders
(we've verified a bid resting above an ask for ~1.5 minutes,
neither ever filling). order_book() replays such feeds
faithfully rather than silently "fixing" them: a crossed book in
your output is a property of the feed, not a reconstruction bug.
Know which kind of feed you're holding before you trust an
uncrossed-book invariant. The
Data quality explainer shows how to measure
the crossing (validate) and, if you must, uncross it for display.
Next: Loading order data — where these feed differences become practical.
Vocabulary introduced here — Level 1 / Level 2 / Level 3, market-by-order, price–time priority, queue rank, matched book vs diff feed — lives in the Glossary.