Skip to content

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()

png

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()

png

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()

png

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 1 remains). 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)

png

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.