A second implementation of an API is only worth having if you can show it agrees with the first. databentoR makes that a test rather than a claim.
The official client is pinned as a git submodule at
dev/python-reference, so every fixture in the repository
describes one exact version of it. Two independent layers check
agreement, and a third watches for the reference moving underneath
us.
Layer 1: the same request
dev/equivalence/wire_capture.py imports the pinned
Python client, replaces its HTTP transport with a stub that records the
call and aborts it, and walks all 28 endpoints of the historical and
reference APIs. No network, no API key, no money.
The recording is committed as
tests/testthat/fixtures/python_wire.json. It contains
endpoint names, parameter names and parameter values from synthetic
arguments, plus the compiled DBN enum value sets. No market data and no
credentials.
test-wire.R then builds the same request in R for every
entry and asserts the HTTP method, the endpoint, and the parameter
names, values and order. For example, the Python
client’s own test suite pins this body for
metadata.get_record_count:
dataset=GLBX.MDP3&symbols=ESH1&schema=mbo&start=2020-12-28T12:00
&end=2020-12-29&stype_in=raw_symbol&limit=1000000
and databentoR produces it field for field, in that order, from
db_get_record_count("GLBX.MDP3", start = "2020-12-28T12:00", end = "2020-12-29",
symbols = "ESH1", schema = "mbo", limit = 1000000)This layer runs on every push, on three operating systems, at no cost. It catches the things that are easy to get wrong and hard to notice:
- Three
metadata.get_*endpoints are POSTs with a form body, not GETs, despite what their docstrings say. -
get_costandget_billable_sizealways pinstype_out=instrument_id;get_record_countsends nostype_outat all. - Form-body booleans go on the wire as
True/False, while the text-encoding flags are lower-casetrue/false. - An omitted
endmust be dropped from the body, not sent empty, because the server forward-fills fromstartwhen it is absent. -
symbols = NULLbecomes the literal stringALL_SYMBOLS.
It also asserts that db_schemas(),
db_encodings() and db_compressions() still
equal the compiled DBN enums, so a schema added upstream fails the test
suite rather than a user’s request.
db_schemas()
#> [1] "mbo" "mbp-1" "mbp-10" "bbo-1s" "bbo-1m"
#> [6] "tbbo" "trades" "ohlcv-1s" "ohlcv-1m" "ohlcv-1h"
#> [11] "ohlcv-1d" "ohlcv-eod" "definition" "statistics" "status"
#> [16] "imbalance" "cmbp-1" "cbbo-1s" "cbbo-1m" "tcbbo"The one deliberate divergence
timeseries.get_range is where the clients differ on
purpose. The Python client hard-codes encoding=dbn with
compression=zstd and decodes the binary format locally.
databentoR asks for encoding=csv and sends the three
text-encoding flags the HTTP API documents for this endpoint, because
the server defaults all three to false:
| Parameter | Python client | databentoR |
|---|---|---|
encoding |
dbn |
csv |
compression |
zstd |
none by default, zstd on request |
pretty_px |
not sent | true |
pretty_ts |
not sent | true |
map_symbols |
not sent | true |
Every other parameter still has to match, and the test asserts the divergence is exactly this and nothing more.
Layer 2: the same data
dev/equivalence/make_reference.py downloads six
deliberately tiny slices through the pinned Python client and freezes
each as parquet in the shape to_df() produces:
price_type="float", pretty_ts,
map_symbols, then reset_index(), because
to_df() makes the leading timestamp the frame’s index while
the CSV encoding keeps it as the first column. The script refuses to
download anything if the quoted total exceeds fifty US cents.
The slices span ohlcv-1d, trades,
tbbo, mbp-1, statistics and
definition, which between them exercise every column kind:
prices, nanosecond timestamps, unsigned counts, single characters and
fixed-width text.
test-equivalence.R pulls the same slices through
databentoR and compares row count, column names, column order, then each
column by kind. Both frames are sorted first, because the server does
not promise a stable order among records that share a timestamp: two
consecutive downloads of the daily-bar slice were measured disagreeing
on 47 of 49 row positions while holding identical data.
-
Prices to a relative tolerance of
1e-12. Both paths end at the IEEE-754 double nearest tofixed / 1e9, one by parsing a nine-decimal string and one by dividing an int64, and they agree bit for bit below2^53. The tolerance is loose enough never to fire on that, and roughly a thousand times tighter than one unit of the 1e-9 grid, so a genuine scaling or rounding bug cannot hide inside it. -
Timestamps as instants, exactly when
ts_type = "integer64". - Counts, flags and sequence numbers exactly.
- Text exactly, after normalising the empty field.
One full run makes 17 billed downloads, about 0.27 US dollars at the
rates measured in September 2026. Databento bills intraday requests in
15-minute chunks, so a one-minute and a ten-minute window of the same
instrument and schema are quoted identically, while a whole day costs
about sixteen times a single chunk. The slices here therefore sit inside
one chunk each, which is the floor for their schema.
dev/equivalence/quote.R prices a run using only the free
preview endpoints.
Layer 2 spends money, so it needs both a key and an explicit opt-in:
DATABENTOR_RUN_LIVE=true Rscript -e 'devtools::test()'
Without fixtures or a key it skips cleanly, which is what keeps the package CRAN-safe.
The differences that remain
Five, all documented rather than papered over. Row order is not among them: it is unstable on both sides, a property of the service rather than of either client.
-
Index versus column.
to_df()returns a frame indexed byts_recv(orts_eventfor OHLCV). A tibble has no index, so databentoR keeps that field as the first column, which is where the CSV encoding puts it anyway. -
Empty text fields are
""in pandas andNAin R. -
Timestamp resolution.
POSIXctis a double count of seconds, so on modern dates it resolves to about a quarter of a microsecond. Usets_type = "integer64"when nanoseconds matter. -
Unsigned 64-bit fields. R has no unsigned integer
type, so fields that are 64-bit on the wire come back as
bit64::integer64. That is what keepsorder_id,raw_instrument_idand an undefined statisticsquantityintact; the last of those is the int64 maximum, which no double can hold. -
pretty_tscollapses two cases. The CSV encoder writes an empty field both for an undefined timestamp and for a literal zero, whileto_df()distinguishes them.ts_type = "integer64"requests raw timestamps and keeps the distinction.
The published attestation
This vignette documents the method. The result is published
separately, because a vignette has to build offline from the package
tarball and a live comparison cannot. Each weekly run writes an
attestation to the workflow’s own summary and commits it to dev/equivalence/LAST-RUN.md,
so the repository carries a dated history of the comparison.
That report states verdicts and shapes and nothing else: rows and
columns compared, columns matched, the largest relative deviation, pass
or fail. It contains no field values at all. No sample rows, no
extracts, and no summary statistics either, since a minimum or mean
price is derived market data rather than a fact about the software.
Column names and types are safe because Databento publishes them itself;
the values under them are not, and never reach the report.
tests/testthat/test-report.R asserts it, and the generator
refuses outright to render a column outside its whitelist.
Layer 3: watching the reference
A weekly workflow compares the pinned submodule with the newest upstream release. When there is a new one it re-captures the wire fixture against that release, diffs it against the committed fixture, and opens an issue naming the parameters, endpoints and enum values that changed, with the changelog delta attached.
So the alert is never “a new version exists”. It is “these are the calls you have to fix”.
