Skip to content

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 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", ax=ax1, bid=99, ask=101, last=101, symbol="TOY")
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])

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 never appear: they are market orders, which take liquidity and never rest, so they never join a queue.
  • 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, display_result

result = Pipeline().run(sample_csv_path())
# Canonical prices are integer ticks (issue #155); show quote currency.
result = display_result(result)
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'],
 'book_replay': ['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'],
 'kyle_lambda': [],
 'l1_ticker': [],
 'ofi_horizon': [],
 'order_flow_imbalance': [],
 'vpin': []}

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). Some have no resolution at all: the metrics, such as vpin and l1_ticker, are measured from the finished run, so their level list is empty. 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 (on the bundled sample, most of the crossed time comes from one order that stays in the book long after it has left the venue; the second exercise below finds it). 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.

Exercises

Try each one before you open its solution. Each solution runs when the documentation is built, so the answers are checked against this version of the library.

Exercise 1: where does a new order queue?

A new trader, Jo, posts a bid of 1 lot at 99 at t=10. Before you run anything, answer:

  1. What is Jo's queue rank at 99 when the bid arrives, and how many lots are ahead of it?
  2. Sam sells 3 lots at t=56. How much of Jo's bid fills?
  3. Now move Jo's bid to 100. What changes in Sam's sweep?

Write the session with toy_orders() and match_toy_orders (see Change the script), then check your answers with queue_positions(events, levels="all") from ob_analytics.queue and the trades. queue_positions needs the classified events, as order_book does: classify them with set_order_types first.

Solution
from ob_analytics import ToyOrder, match_toy_orders, toy_orders
from ob_analytics.analytics import set_order_types
from ob_analytics.queue import queue_positions

orders = toy_orders()
orders["Jo"] = ToyOrder(at=10, direction="bid", price=99, volume=1)
jo_events, jo_trades = match_toy_orders(orders)
jo_id = jo_events.loc[jo_events["actor"] == "Jo", "id"].iloc[0]
queue = queue_positions(set_order_types(jo_events, jo_trades), levels="all")
jo_queue = queue[queue["id"] == jo_id]
print(jo_queue[["price", "rank", "ahead_volume"]].to_string(index=False))
print()
sweep = jo_trades[jo_trades["taker_actor"] == "Sam"]
print(sweep[["price", "volume", "maker_actor"]].to_string(index=False))
assert jo_queue["rank"].iloc[0] == 3 and jo_queue["ahead_volume"].iloc[0] == 4
assert "Jo" not in set(sweep["maker_actor"])
 price  rank  ahead_volume
    99     3             4

 price  volume maker_actor
    99       2       Alice
    99       1         Ivy

Jo joins the back of the 99 queue: rank 3, behind Alice and Ivy, with their 4 lots ahead. Sam's 3 lots fill Alice's 2 and 1 of Ivy's, and stop before they reach Jo, so nothing of Jo's bid fills.

orders["Jo"] = ToyOrder(at=10, direction="bid", price=100, volume=1)
jo_events, jo_trades = match_toy_orders(orders)
sweep = jo_trades[jo_trades["taker_actor"] == "Sam"]
print(sweep[["price", "volume", "maker_actor"]].to_string(index=False))
assert list(sweep["maker_actor"]) == ["Jo", "Alice"]
 price  volume maker_actor
   100       1          Jo
    99       2       Alice

At 100 Jo's bid is alone at a better price, so it is first in line from t=10, although Alice and Ivy arrived earlier: price comes before time. Sam fills Jo at 100, then Alice's 2 lots at 99, and Ivy gets nothing. One tick bought Jo the front of the queue, which is the trade-off Hana faced: pay more, or wait.

Exercise 2: find the order that crosses the book

The pitfall above says the Bitstamp sample's book is crossed. Find the order responsible: which side it rests on, at what price, and how long it holds the touch after the venue no longer has it.

Hint: a matching engine fills the better price first, so a trade that prints through a resting order shows that the order has already left the venue. detect_stale_orders from ob_analytics.analytics lists the orders the feed never removed after such a trade, worst first.

Solution
from ob_analytics.analytics import detect_stale_orders

worst = detect_stale_orders(result.events, result.trades)[0]
print(f"side:            {worst.direction}")
print(f"price:           {worst.price:,.2f}")
print(f"disproved at:    {worst.disproved_at:%H:%M:%S}")
print(f"held the touch:  {worst.touch_seconds / 60:.1f} minutes")
assert worst.direction == "ask" and worst.touch_seconds > 20 * 60
side:            ask
price:           78,333.00
disproved at:    02:38:54
held the touch:  27.4 minutes

One ask, at 78,333.00. It came from the opening snapshot of the book. Trades printed above it about two and a half minutes into the capture, so it had already gone from the venue, but the feed never reported it again. It then held the ask touch for about 27 minutes, nearly the whole capture, and that one order is why the faithful book is crossed for most of the session. The Data quality explainer shows the crossed share of the session falling from about 92% to about 1% once it is removed.

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.