Skip to content

Process L2 (price-level) depth feeds

ob-analytics was built around an L3 per-order stream — every event is a created / changed / deleted action keyed by an order id, and the reconstruction stages (order classification, aggressiveness, queue position) all depend on that identity. Many venues — Binance, Kalshi, Polymarket, and most CCXT sources — publish only L2 aggregated data: price-level [price, quantity] snapshots and diffs with no order IDs.

This guide covers the L2 depth-native ingestion path: those feeds produce valid depth / spread / trade analytics without faking per-order state. It is the counterpart to the L3 Bitstamp / LOBSTER paths.

L2 vs L3, in one line

A Format declares its resolution — a Level:

Level Feed What each row is Per-order stages
L3 Market-by-order (Bitstamp, LOBSTER, Databento) one primitive per resting order run
L2 Market-by-price (Binance, Kalshi, Polymarket, CCXT) one price level's aggregate size skipped

On the L2 path the loader yields the depth frame directly — a price-level feed is a depth stream, so there is nothing to reconstruct — and the pipeline skips the per-order stages (set_order_types, order_aggressiveness, queue reconstruction) because there is no order identity to key on.

The L2 CSV schema

One row per price-level update, each carrying the level's new absolute resting size (0 removes the level) — not a signed delta. A book snapshot is simply the opening block of rows.

timestamp,side,price,volume
1700000000000,bid,99.0,5.0     # ─┐ opening snapshot
1700000000000,bid,98.0,8.0     #  │
1700000000000,ask,101.0,4.0    #  │
1700000000000,ask,102.0,7.0    # ─┘
1700000005000,ask,101.0,2.0    # level 101 now holds 2 (was 4)
1700000015000,ask,101.0,0.0    # level 101 removed
column meaning
timestamp receive time — integer epoch (config.timestamp_unit) or any string pandas can parse
side bid / ask (buy / sell and b / a are also accepted)
price the price level (scaled by config.price_divisor, rounded to price_decimals)
volume the level's new absolute resting size (0 = removed)

Column names are flexible: side / direction and volume / size / amount / quantity are all accepted.

Point the pipeline at the file (or a directory containing depth.csv); a companion trades.csv next to it is picked up automatically.

from ob_analytics import Pipeline

result = Pipeline.from_format("depth_csv").run("my_l2_run/")

result.resolution        # Level.L2
result.depth             # the price-level book (timestamp, price, volume, direction)
result.depth_summary     # best bid/ask + BPS-bin depth over time
result.events            # empty — the per-order stages did not run

What you get — and what's skipped

The depth analytics run exactly as they do for L3, because DepthMetricsEngine already consumes an absolute price-level book:

from ob_analytics.depth import get_spread

get_spread(result.depth_summary)      # best bid/ask through time

The per-order stages are skipped with a clear reasonresult.events is an empty (but schema-valid) frame, and result.resolution is Level.L2 records why. Anything that needs order identity — order-type classification, order aggressiveness, order lifecycles, queue position — has nothing to work with on an aggregated feed and is not attempted.

Trade signs

L3 crypto ships the taker side for free; price-level feeds usually don't. When trades.csv has no side column, the pipeline classifies the aggressor with Lee–Ready against the reconstructed BBO (falling back to the tick rule at the mid) — see trade signs. A native side column is honored as-is.

result.trades["direction"]    # buy / sell taker side, native or classified

Because there are no order IDs, the maker / taker (and event-id) columns are <NA> — that is expected for L2, not a data-quality failure.

Visualizing an L2 result

The gallery adapts to the resolution: an L2 result renders the depth/trades faces (the depth heatmap, price view, depth percentiles, trade tape, trade size) and skips every per-order face rather than erroring.

from ob_analytics.visualization import available_concepts

result.plot("depth_heatmap")          # the hero L2 face
sorted(available_concepts(result))    # only the L2-supported concepts

Checking data quality

ob-analytics validate --format depth_csv <source> (and data_quality_summary) work on an L2 result: the crossed-book % is read from the price-level book, and the per-order metrics report zero.

Try it on the synthetic fixture

The bundled toy_l2_depth() / toy_l2_trades() are a tiny, hand-verifiable snapshot + delta stream — the L2 counterpart to toy_events / toy_trades:

from ob_analytics.datasets import toy_l2_depth, toy_l2_trades
from ob_analytics.depth import depth_metrics, get_spread

summary = depth_metrics(toy_l2_depth())
get_spread(summary)

Live L2 capture

This path is for offline L2 files. A live depth-update event kind for the capture runner — so LiveCapturers for L2 venues (Binance, Kalshi, Polymarket) can stream straight into this schema — is a planned follow-up.