Process Databento MBO files¶
Databento publishes normalized market data for many
venues in its own binary format, DBN. Its market-by-order (MBO) schema is a
per-order feed: every record carries an order_id, a price, a size and a
side, which is what ob-analytics reconstructs from. A DBN MBO file therefore
replays through the standard pipeline with order lifetimes, queue position and
order classification all available.
databento is an optional dependency:
Databento data needs your own API key and subscription, so none of it ships with the package.
Run a file¶
from ob_analytics import Pipeline
from ob_analytics.databento import DatabentoSource
result = Pipeline(source=DatabentoSource()).run("aapl-20240212.mbo.dbn.zst")
print(result.events.shape, result.trades.shape)
Or by name, through the source registry:
from ob_analytics import Pipeline
result = Pipeline.from_source("databento").run("aapl-20240212.mbo.dbn.zst")
From the command line:
There is no trading date to supply: DBN records carry absolute UTC timestamps.
Set the instrument's tick and lot¶
The defaults suit a US equity — a one-cent tick and whole shares. Another instrument needs its own, or prices are quantised onto the wrong grid:
from ob_analytics import Pipeline, PipelineConfig
from ob_analytics.databento import DatabentoSource
# E-mini S&P 500 future: a quarter-point tick.
config = PipelineConfig(tick_size=0.25)
result = Pipeline(config, source=DatabentoSource()).run("es-20240212.mbo.dbn.zst")
The fixed-point scale of the raw prices is a property of DBN, not of the
instrument, and is already set for you (price_divisor=1_000_000_000).
Pick one instrument out of a file¶
A DBN file can hold several instruments, and a consolidated dataset such as
DBEQ.BASIC can hold several venues quoting the same one. Each
(instrument, publisher) pair is a separate book, so a run covers exactly one.
Name it, or the loader raises and lists what the file holds:
from ob_analytics.databento import DatabentoSettings, DatabentoSource
source = DatabentoSource(settings=DatabentoSettings(raw_symbol="GOOGL"))
# by numeric id instead, for a file with no symbol mapping:
source = DatabentoSource(
settings=DatabentoSettings(instrument_id=1108, publisher_id=41)
)
How the records map¶
Databento's action field says what a record does. Only the first four change
the book.
action |
meaning | canonical event |
|---|---|---|
A add |
insert a new order | created, volume = the new size |
M modify |
change price and/or size | changed, volume = the new size |
C cancel |
remove some or all of an order | changed while size is left, deleted when it empties |
R clear |
remove every resting order | a deleted row for each order still resting |
T trade |
an aggressing order traded | a trade row (see below) |
F fill |
a resting order was filled | the fill on the next book event, and a trade row |
N none |
flags only | nothing |
size means a different thing per action, which is what makes the mapping
work. On A and M it is the order's new total size, which is the canonical
volume directly. On C it is the amount removed, so the loader carries the
outstanding size per order and subtracts from it. On F it is the amount
executed.
A venue reports an execution as a fill and then a separate cancel or modify
that takes the size off the book. The loader charges each F to the next
A/M/C record for the same order, which is what tells a cancel that was
really an execution apart from a cancel the trader asked for. A modify for an
order this window never saw added is treated as an add, the same way
Databento's own reference book builder treats it.
Trades and the aggressor¶
Databento sends two records for a trade: a print (T) for the execution and
a fill (F) for each resting order it hit. DatabentoSettings(trades_from=...)
chooses which one the trades frame is built from.
"fills", the default. Each fill becomes a trade row with amakerand amaker_event_id— one row per resting order a sweep took out. That is what order classification and queue analysis read. A file with no fills at all falls back to its prints."prints". The venue's whole tape, one row per print, but no maker on any row.
The difference matters for trades the publisher sent no fill for: an opening or closing auction, a trade against a non-displayed order, an off-exchange print. With fills they are not in the frame, and the loader warns with the volume left out. On a US equity day the auctions alone can be a large share of the volume, so for a question about the tape — VWAP, bars, flow toxicity, transaction costs — use prints:
from ob_analytics.databento import DatabentoSettings, DatabentoSource
source = DatabentoSource(settings=DatabentoSettings(trades_from="prints"))
The two are not mixed. A print and the fills behind it describe one execution, and nothing in a DBN record ties them together reliably — a fill and the modify it causes can carry different receive times — so joining them would risk counting the same volume twice.
Where the venue states the aggressor, direction is its answer rather than an
estimate.
Databento states no side at all for some trades — an opening or closing auction, a trade against a non-displayed order, an implied order, an off-exchange print. Those reach the pipeline unlabelled and are then classified with Lee-Ready against the reconstructed quotes, so they end up with a direction like any other trade. A side the venue did state is never overwritten: a classifier is an estimate and the venue's answer is not.
The taker's own order is not identified: a DBN trade record does not
reliably carry the aggressing order's id, so taker and taker_event_id are
NA. set_order_types reads those, so on a Databento run it labels executed
resting orders resting-limit and never market or market-limit. Everything
that reads the maker side — order lifetimes, queue position, depth, effective
spread — is unaffected.
The source declares this as trade_attribution = maker_only, so
audit counts a trade as unmatched only when its maker is missing,
not because it has no taker.
Orders that move or grow¶
Databento's M can move an order to another price or make it bigger. Both lose
queue priority, so both are really a new queue entry. The shared schema has no
event for that: it has created, changed and deleted. The loader records
the new price and size on a changed event.
The price-level rebuild follows it. A changed row that reports no execution
and carries a new price moves the order: its volume leaves the old level and
joins the new one. A changed row that reports no execution and a larger size
adds the difference at the level where the order rests. The queue priority the
order loses is not modelled.
One case is still not followed. A row that reports an execution is read as an execution report, and only the fill is taken off, at the level where the order rests. Some venues report an execution at the price it traded at, not the price the order rests at, so the rebuild cannot read that price as a move. If a Databento modify carries a fill and also moves the order, or changes its size by more than the fill, the depth is off by the difference. The loader warns with the number of rows affected. The events, order lifetimes and trades are right in every case.
What the loader refuses, and what it drops¶
A feed the loader does not understand is refused:
- A publisher that only sends top-of-book or price-level data. Databento
normalizes that into MBO records with
F_TOBorF_MBPset and anorder_idthat means nothing, so reconstructing per-order state would invent identity the feed never had. Read those publishers through the L2 path instead. - A file holding a price-level schema (
mbp-1,mbp-10,bbo,tbbo). - A file covering more than one instrument or publisher with no filter saying which book to read.
- An
actionoutside DBN's own alphabet (A M C R T F N). A record whose meaning is unknown has no safe reading, and dropping it would take its liquidity out of the book unannounced. - An
order_idabove 2⁶³−1. DBN order ids are unsigned 64-bit and the shared schema's is signed, so a cast would wrap to a negative id and could merge two distinct orders into one.
A malformed record inside a feed it does understand is dropped and counted in a warning:
- A book action, trade or fill with no price (
UNDEF_PRICE, which isINT64_MAX). Kept, it would sit at about nine billion: read back as the best bid or ask, or as a trade at that price. A clear is exempt, because its price is never read. - A book action with no side. There is no side of the book to put it on, and keeping it would leave the events frame and the depth frame disagreeing.
Refusing a whole session over a handful of malformed records would be worse than saying how many went, which is why these two are a count rather than an error.
The loader also warns when records carry F_MAYBE_BAD_BOOK, which means the
feed reported a gap it could not recover from.
Size a query before you download it¶
MBO volumes are large. One instrument over a session-length window sits inside the in-memory envelope; a whole feed for a whole day does not. Databento bills by record, so it will tell you the size and the cost of a query before anything is downloaded:
import databento as db
client = db.Historical() # reads DATABENTO_API_KEY from the environment
n = client.metadata.get_record_count(
dataset="XNAS.ITCH",
symbols=["AAPL"],
schema="mbo",
start="2024-02-12T14:30",
end="2024-02-12T16:00",
)
print(n) # compare against the ~5M-event envelope
scripts/databento_window.py is this as a runnable tool: it sizes each window,
downloads the ones you ask for, runs them one at a time and concatenates the
results.
export DATABENTO_API_KEY=db-...
uv run --extra databento python scripts/databento_window.py \
--dataset XNAS.ITCH --symbol AAPL \
--window 2024-02-12T14:30 2024-02-12T16:00
Windows are run separately so peak memory is bounded by the largest one. Scale and chunking sets out what concatenates cleanly across a cut and what does not.
Write a file back out¶
DatabentoWriter inverts the loader record for record, so a window you have
worked on can be handed to another DBN reader:
from ob_analytics.databento import DatabentoWriter
DatabentoWriter(result.config).write({"events": result.events}, "out/window.dbn")
The metadata says OB.ANALYTICS rather than claiming to be Databento's own
data, and the fills are written back at the timestamp of the event they were
charged to, so the file is a faithful reconstruction rather than a byte copy of
the original.