Changelog¶
All notable changes to ob-analytics are documented in this file.
The format is based on Keep a Changelog.
[Unreleased]¶
Added¶
- A metric registry, so a user metric runs and plots with no core edit
(#140). A metric is a plain object with a
name, atitle, thelevelsit applies to,compute(result)andprepare(frame)— no base class to inherit, the same structural typing sources and writers use. Register it withregister_metric(metric), or ship it in your own package under theob_analytics.metricsentry-point group andload_metric_plugins()finds it atimport ob_analytics.
A registered metric is a level-less plot concept under its own name, so a
renderer at (name, None, backend) is its face. It then appears in
available_concepts(result), renders through result.plot(name), and gets
its own gallery card with no extra_panels=. Metrics run when asked for, not
during Pipeline.run: result.metric(name) computes one and
result.metrics() computes every metric whose levels include the run's
resolution — so an L3-only metric is skipped on an L2 run instead of failing
on its empty events table, and a metric that raises is logged and its card
dropped, so one broken metric cannot stop the gallery being built. New public
names: Metric,
register_metric, list_metrics, get_metric, load_metric_plugins,
PipelineResult.metric / .metrics. See the "A new metric"
how-to.
ob-analytics audit, a data-quality gate (#108). The oldvalidateverb is nowaudit(the old name still works), it scores the run against named checks, and it exits non-zero when one fails — so a script can stop before trusting a feed. Five checks are new: orphan orders (changed or deleted with nocreatedrow), non-positive prices, negative volumes or fills, and the two clock-order defects — a venue timestamp later than the receive timestamp, and messages that arrived out of venue order.auditalso loads withtrack_sequenceon, so the dropped-message check (#146) reads a venue sequence whenever the feed carries one.
Each check carries a Severity: an error fails the run, a warning
fails it only under --strict, and info never does. A crossed resting
book is scored by feed type, not by size — an error on a matched book, a
faithful replay on a diff feed. --json emits every check plus an ok
verdict; --from-parquet audits a saved process output without re-running
the pipeline. New public names: Severity, QualityCheck, and
DataQualitySummary.ok / .errors / .warnings / .checks. See the
"Check data quality with audit" how-to.
PipelineResult.to_arrow()andPipelineResult.to_polars()(#104). Both return the run's four tables —events,trades,depth,depth_summary— keyed by name, with the same keys on every run: on an L2 runeventsis an empty table, not a missing key. The Arrow tables carry the schema version and tick size in their metadata, the same key-value metadata the Parquet files carry, so a reader handed tables in memory is no worse off than one reading files. Polars is not a dependency and is not installed;to_polars()raisesImportErrorwith an install hint when it is missing, and Polars keeps no schema metadata, so the version and tick size do not survive that conversion.- The frame-type contract is written down in
Frame types: pandas in, pandas out:
public functions take and return pandas, plug-ins are handed pandas, and the
versioned Parquet is how other tools read the output. The reasoning is in
adr/0002-dataframe-library.md. - cryptofeed source for live L2 and L3 capture (
ob-analytics capture cryptofeed --exchange <venue> --pair <symbol>). The per-order complement to the CCXT source: venues publishing an order-by-order book recordorders.csvwith the venue's own ids and replay through the full reconstruction pipeline; the rest recorddepth.csvfor the L2 path. The level is discovered from the venue's declared channels rather than a hardcoded list, and--levelforces it — except L3 on a venue that publishes none, which raises. Ships as the optional[cryptofeed]extra, imported lazily. See the new "Capture cryptofeed venues" how-to. -
sequenceis now written toorders.csv. The capture sink dropped the venue sequence on the L3 path, sodetect_sequence_gapshad nothing to read; L2 already kept it. cryptofeed captures also reportsequence_gaps/sequence_missinginmeta.json. -
L2 (price-level) depth-native ingestion path. Price-level feeds (Binance, Kalshi, Polymarket, most CCXT sources) publish
[price, quantity]levels and diffs with no order IDs; ob-analytics now ingests them as a first-class L2 resolution instead of faking per-order state. A format declares itsresolution(Level.L2/Level.L3, exposed asob_analytics.Level); an L2 format's loader is aDepthSourcethat yields the depth frame directly, andPipeline.runtakes the price-level path — depth metrics / spread and trade-sign classification run, while the per-order stages (set_order_types,order_aggressiveness, queue reconstruction) are skipped.PipelineResultgains aresolutionfield and, on an L2 run, returns an empty (schema-valid)eventsframe. Ships thedepth_csvformat (L2DepthLoader,L2TradeReader,DepthCsvWriter,DepthCsvFormat) for the canonical L2 CSV schema, atoy_l2_depth()/toy_l2_trades()synthetic snapshot+delta fixture, anddata_quality_summary+ the gallery degrade gracefully (L3-only faces skipped, not errored).ob-analytics process|validate --format depth_csvworks from the CLI. Unblocks the aggregated venue connectors. Documented in a new "Process L2 feeds" how-to. - Trade-sign classification (
ob_analytics.trade_sign) for feeds that don't label the aggressor side.tick_rule(last-price-change sign),lee_ready(quote-midpoint test with a tick-rule fallback), andbulk_volume_classification(BVC — the buy fraction of a volume bar via the standardized-price-change normal CDF).classify_trade_sign(trades, method=..., quotes=...)is the per-trade entry point.compute_vpinandorder_flow_imbalancegainsign_method/quotesarguments and now synthesizedirectionautomatically when the trades frame has none — so VPIN and OFI run on L2 / aggregated captures, not just L3. A nativedirectionis still honored unchanged (sign_method=None). On the bundled Bitstamp L3 sample the classifiers agree with the true maker/taker side ~0.83 (tick) / ~0.79 (Lee–Ready) — validated by a test harness. - Feed classification. Every format declares a
FeedType(matched_bookvsdiff_feed) through afeed_typeattribute —BitstampFormat→diff_feed,LobsterFormat→matched_book— so downstream code reasons about crossed books by coordinate, not by format name. Exposed asob_analytics.FeedType. order_book(..., uncross=True)evicts crossed resting orders for display, mirroring the depth engine's crossed-level eviction. The default stays faithful, so a diff feed's genuinely crossed resting orders are replayed as-is. Threaded throughprepare.book_snapshot(..., uncross=True)(also drivesdepth_chart) and available frame-level asanalytics.uncross_book_sides.- Per-run data-quality summary.
data_quality_summary()and the newob-analytics validate <source>CLI verb report the crossed-resting %, unmatched-trades %, duplicate ids, and pre-existing-order count. A new "Data quality: matched book vs diff feed" explanation page and avalidatehow-to document the distinction.
Changed¶
- Sizes are integer lots plus a
lot_size, not floats (issue #226). Breaking: the on-disk schema goes 3.0 → 4.0. Everyvolumeandfillcolumn is now a whole number of lots (int64) instead of adoublein the base asset. The base-asset size islots * lot_size, wherelot_sizeis the instrument's minimum size increment (PipelineConfig.lot_size, default1e-8; LOBSTER sets1, whole shares). This is the size half of the integer-tick decision (issue #155) and it fixes a real defect rather than only re-expressing the data.
A price level is a running sum of adds, cancels and fills. A float sum does
not return to exactly zero when the last order leaves, so a level landed on
residue such as 5.55e-17, stayed live, and was reported as the best bid or
ask ahead of the real one. On the bundled Bitstamp sample that corrupted the
reported best bid on 25,611 of 313,565 rows (8.2%) and the best ask on 30,096
(9.6%) — the spread on about one row in eleven. Integer lots cancel exactly,
so a level empties or it does not, and those counts are now zero.
It was found by the new cross-check against hftbacktest (issue #224), and
that is what confirms the fix: replaying an exported session through
hftbacktest's own L3 reconstruction now agrees with depth_summary on the
best bid and ask for every row across five synthetic seeds, and Nautilus'
book agrees too. Before the fix the two disagreed on up to 78 rows a seed.
The change reaches every size-valued column — depth_summary's per-bin
volumes, placed_vol and filled_vol, the book snapshot's liquidity, and
the queue's ahead_volume and remaining — so their sums are exact as well.
Three float-era workarounds went with it: the Kahan compensation behind
filled_vol, the simulator's _vol_eps exhaustion tolerance, and the
LOBSTER book replay's 1e-12 level cutoff. Loaders convert on the way in;
the plots and the round-trip and export writers convert back, so what a user
sees and what another tool reads are unchanged. lot_size travels in each
Parquet file's key-value metadata under ob_analytics_lot_size, next to
ob_analytics_tick_size, and load_data surfaces it as
df.attrs["lot_size"]. Files written at 1.0–3.0 still read, as the
float-size frames they are. Golden outputs were re-baselined on purpose.
-
The export writers leave out orders that never rested (issue #224). A marketable order is recorded as a transient add on its own side at the touch, then the fill, then a delete;
ob_analytics.depthhas always excluded these from the book, but the hftbacktest and Nautilus writers were sending them. A backtesting engine reads an add as real liquidity, so its book crossed at the touch and dropped the resting level the order traded against — its reconstruction drifted permanently thinner than ours. Both writers now exclude them, which is what makes the two books agree. -
The order-book engine is its own module (issue #136). The rebuild (
order_book), the per-order lifecycles, and the FIFO queue reconstruction moved out ofanalytics.py/queue.pyintoob_analytics/engine/, behind one input and one output: order events in, book states and order lifecycles out, and nothing else. The engine imports no pandas — everything crosses its interface as NumPy arrays, with the shared schema (issue #112) as the input, timestamps as int64 UTC nanoseconds (issue #154) and prices as integer ticks (issue #155). Results carry a row index back into the caller's event arrays instead of copying columns out, so adding a column to the schema does not widen the interface and the engine never learns a vocabulary — order types, venue names — belonging to the layer above.ob_analytics/_engine_frames.pyis the one place pandas and the engine meet;analytics.order_book,analytics.order_lifecycles, and theob_analytics.queuefunctions are now its frame adapters and keep their exact signatures, dtypes, column order, and index behaviour. Output is unchanged byte for byte — the golden-output gates from issue #143 pass on their recorded fingerprints. Two things did move: the display window (max_levels,bps_range) and the queue sampling window are set by the frame adapters rather than the engine, which reconstructs the whole book and replays to the instants it is given. A new import test (tests/test_engine_boundary.py) keeps the engine free of pandas and of every layer above it. This is what lets the inside be replaced with a faster implementation (#138) or fed one event at a time (#139) without touching anything else.Direction,Action, andOutcomeareIntEnumcode vocabularies that derive their schema strings from their own member names, so a code and its label cannot drift apart. Two details of the frame code are reproduced deliberately rather than rewritten: an order's executed total is accumulated with compensated (Kahan) summation, as the pandas aggregation it replaced did, and placement values are taken per column as the first non-null among an order'screatedrows. The lifecycle table is now covered bytests/test_golden_synth.py, which it was not before. -
BitstampTradeReaderno longer requires integer order ids. It keyed its maker/taker lookup onint(order_id), which crashed on a public trade tape carrying no ids (int(NaN)) and on venues publishing UUIDs. Integer ids behave exactly as before; other ids match on their string form, and a missing id resolves toNaNinstead of raising. -
One
Sourceshape for every data source, file or live (issue #137; settled #145 as "optional extras plus entry-point plug-ins"). File loaders and live capturers were two separate designs with two registries; they are now oneSourceprotocol with two capability refinements —OfflineSource(replay stored files: the loader / trade-source / writer / depth factories) andLiveSource(capture a venue:snapshot/stream/shutdown_synthetic_events). A source states itslevel(L2/L3) andfeed_type, carries typedsettings, and registers in the singleSOURCESregistry viaregister_source. A source can be both:BitstampSourcenow covers offline replay and live capture in one descriptor. This is a breaking API change with no back-compat shims:Format→OfflineSource;LiveCapturer→LiveSource;BitstampFormat/LobsterFormat/DepthCsvFormat→BitstampSource/LobsterSource/DepthCsvSource;CcxtCapturer→CcxtSource.Pipeline(format=...)→Pipeline(source=...);Pipeline.from_format→Pipeline.from_source.- The
FORMATS/CAPTURERSregistries and theirregister_format/register_capturer/list_formats/list_capturers/get_capturerhelpers are replaced bySOURCES/register_source/list_sources/get_source(inob_analytics.sources). PipelineResult.resolution→PipelineResult.level(one coordinate name across the codebase;Source.level, matching the visualization layer).CaptureConfig.extras(the untyped settings dict) is removed. Per-source settings are now typedSourceSettingson the source itself, e.g.CcxtSource(settings=CcxtSettings(exchange="binance", depth_limit=100)).- CLI:
process/validatetake--source(was--format), and theformatsverb is nowsources(it also shows each source's capability and required context).
- Third-party sources load through entry points. A source can ship in its
own package and advertise itself under the
ob_analytics.sourcesentry-point group;ob_analytics.sources.load_source_plugins()discovers and registers it at import time, with no edit to ob-analytics. The built-in sources (bitstamp, lobster, depth_csv, ccxt) self-register on import and stay behind today's[live]/[ccxt]extras. - Prices are now integer ticks, not floats (issue #155). Every
pricecolumn — events, trades, depth, depth_summary, book snapshot, and order lifecycles — is a whole number of ticks (int64); the quote-currency price isticks * tick_size, wheretick_sizeis the instrument's minimum price increment (PipelineConfig.tick_size, default0.01). Loaders convert a raw price to ticks on load; the plots and the round-trip writers convert back for display, so figures and CSV output are unchanged. Storing the exact integer removes the float rounding that made small-tick and 0-1 instruments show crossed levels that were not real, and the depth engine now bins and compares levels on exact integers instead of multiplying and rounding each event — LOBSTER'sprice_divisoris now just the raw-feed encoding scale.tick_sizeis written to each Parquet file'sob_analytics_tick_sizekey-value metadata (a JSON map keyed by instrument, ready for per-(venue, symbol)ticks in #147) and surfaced onload_dataframes'attrs. Breaking: the dtype of everypricecolumn changed fromdoubletoint64and the stored numbers changed (prices re-expressed as ticks; price-valued analytics such astrade_impactsVWAP and Kyle's λ are now in tick units — multiply bytick_sizefor the quote currency; scale-free metrics such as bps depth and order-book imbalance are unchanged). The canonical Parquet schema version is now3.0(a1.0/2.0file still reads — Parquet is self-describing — as the float-price frame it stored, whose prices are not directly comparable to a3.0file's ticks; re-save it to move it onto the tick model). Golden-output baselines were re-recorded behind the correctness gate (#143). - Timestamps are now tz-aware UTC nanoseconds (
timestamp[ns, tz=UTC]) on both clocks —timestamp(receive) andexchange_timestamp(matching engine) — across every table, loader, the synthetic generator, and the toy datasets (issue #154). Before, they were tz-naive and in each venue's native clock (millisecond-resolution UTC for Bitstamp, US/Eastern for LOBSTER), and frames from different venues were declared not comparable. Now every frame sits on one UTC clock, so cross-venue frames can be joined or concatenated directly. LOBSTER's seconds-after-midnight are converted to UTC from the session date and a venue time zone (RunContext(session_tz=...), defaultAmerica/New_York); Bitstamp / CCXT keep their wall-clock instants and only gain the zone and the nanosecond unit, so their values do not move. The schema also documents a same-instant total order —timestamp, thensequence, thenevent_id, theningest_seq(ob_analytics.schemas.time_order_keys), which the per-order reconstructions sort by. Breaking: the dtype of every timestamp column changed, so the canonical Parquet schema version is now2.0(a1.0file still reads — Parquet is self-describing — as the tz-naive frame it stored; re-save it to move it onto the UTC clock). Consumers that compared pipeline timestamps against tz-naivepandas.Timestamps must now use tz-aware (UTC) ones.
Fixed¶
- Order lifecycles read every filled order as cancelled when sizes were
floats (#226 regression).
order_lifecyclessummed each order's fills and cast the total toint64. On integer lots that is exact, but the function also accepts base-asset floats, and it is handed them on every gallery run:display_resultconverts a whole result to display units before any face builds. Base-asset sizes are mostly below 1, so a 0.121 BTC fill truncated to0, the order read as never executed, and the three lifecycle-derived L3 faces — Order Activity, Order Outcome and Queue Position — drew a book of nothing but cancellations. On the bundled Bitstamp sample the Order Activity face lost 224 of its 226 filled spans. The sum now keeps the units it was given, integer lots summing exactly and base-asset floats with the compensation that was dropped as part of #226.
LOBSTER was never affected: its lot size is 1, so a truncated size equals the size. Every LOBSTER face is pixel-identical across the change.
- LOBSTER's
fillcolumn wasfloat64, not integer lots (#226). A0.0literal in the expression that built it widened the whole column, so a schema-4.0 LOBSTER run wrote base-asset-looking floats that were really lot counts. Nothing raised; the values only differ from the correct ones once the lot size is not 1.
[0.1.0] - 2026-06-26¶
First public release (PyPI). The Python port of the R obAnalytics package, reworked into a pipeline API with pluggable formats, flow-toxicity metrics, L2/L3 visualization, and Matplotlib/Plotly backends — plus the packaging, documentation, and distribution that make it installable. The sections below also record how the API was deliberately de-bloated and unified during the port (the pipeline's numeric output is unchanged — the regression fingerprints pass; only the shape of the public API moved). See Extending ob-analytics.
Packaging & distribution¶
- The bundled Bitstamp sample ships gzip-compressed (
orders.csv.gz, ~23 MB → ~2.9 MB installed);sample_csv_path()returns the.gzpath, read transparently by pandas. No API change. - Published documentation site (GitHub Pages),
CITATION.cff, an explicit GPL-2.0-or-later license section, and a "Scale envelope" doc. - PyPI release workflow (
release.yml, trusted publishing), package classifiers and project URLs, andob_analytics.__version__. - Fixed quickstart and API-reference documentation drift.
Breaking¶
- Pydantic models removed.
ob_analytics.models(OrderEvent,Trade,DepthLevel,OrderBookSnapshot) deleted; the data contract is now column-list constants +validate_events_df/validate_trades_df/validate_depth_dfinob_analytics.schemas. metrics/package removed.ToxicityMetric,Vpin,Ofi,KyleLambda,register_metric, andlist_metricsare gone. Callcompute_vpin,compute_kyle_lambda, andorder_flow_imbalanceonresult.tradesdirectly.Pipeline(metrics=...)removed. Metrics are no longer a pipeline stage — compute them after the run.PipelineConfig.vpin_bucket_volumeremoved — passbucket_volume=tocompute_vpin.PipelineResultslimmed to exactlyevents,trades,depth,depth_summary, andconfig. Thevpin,ofi,metrics,metadata, andextrasattributes are gone.- The thirteen
plot_*wrappers removed → oneplot(name, *, backend="matplotlib", ax=None, **data)dispatcher keyed by(plot_name, backend); renderers self-register intoRENDERERS. - Global theme state removed.
set_plot_theme/get_plot_theme/_current_themedeleted; passtheme=PlotTheme(...)toplot(). - Exception hierarchy collapsed to
ObAnalyticsError+ConfigError.InvalidDataError,MatchingError,InsufficientDataError, andConfigurationErrorare removed. - Top-level
__all__trimmed to ~22 orchestration names. Low-level helpers now import from their submodules —ob_analytics.bitstamp,ob_analytics.lobster,ob_analytics.analytics,ob_analytics.depth,ob_analytics.data,ob_analytics.visualization,ob_analytics.flow_toxicity. Formatis now atyping.Protocol— there is no base class to inherit; any conforming object is recognised structurally.- Low-level helpers no longer re-exported from the package root (e.g.
depth_metricsis nowfrom ob_analytics.depth import depth_metrics). RunContext.extrasandFormat.collect_extrasremoved. LOBSTER trading halts are read fromLobsterLoader.trading_haltsand composed into the gallery viaextra_panels=.DepthMetricsEngine.update()removed → the public hot-path method isupdate_side(price, volume, side, out).
Added¶
ob_analytics.schemas— the single data contract: column-list constants (EVENT_COLUMNS,TRADE_COLUMNS,DEPTH_COLUMNS) plus thevalidate_*functions, run at the pipeline's Protocol boundaries. Replaces the Pydantic model layer.- One generic
Registry[K, V](ob_analytics._registry) backs the format, writer, capturer, and renderer registries. Register through the public helpersregister_format,register_writer,register_capturer, andRENDERERS.register/register_plot_backend. - Unified
plot()dispatcher +RENDERERSregistry keyed by(plot_name, backend), so new plots and backends plug in without a wrapper function. The HTML gallery composes custom panels viaextra_panels=. ob_analytics.live— optional sub-package for live order-book capture: theLiveCapturerprotocol (with an optionalSupportsDiagnosticscapability),CaptureConfig,CaptureResult,CaptureSink, and a generic asyncio runner. Capture output drops straight into the pipeline (orders.csvschema unchanged). Install withpip install "ob-analytics[live]".ob-analytics capture <venue>CLI verb with a built-inbitstampcapturer (ob_analytics/live/bitstamp.py);--listshows registered capturers.scripts/collect_bitstamp_btcusd.pyis now a thin wrapper around it.TradeSourceprotocol andBitstampTradeReader— read an authoritative companiontrades.csvand join it to events via thefillcolumn.RunContextdataclass (ob_analytics.protocols, re-exported at the top level) for per-run parameters such as LOBSTERtrading_datethat don't belong on long-livedFormatinstances.- Docs —
docs/extending.md(add a data source / writer / plot / metric / capturer). - Tests —
test_bitstamp.py,test_cli.py(subprocess smoke tests for all CLI subcommands),test_exceptions.py,test_data_registry.py, a regression snapshot suite pinning demo Parquet hashes + the Kyle-λ baseline, andob_analytics/__main__.py(python -m ob_analytics).
Changed¶
- Bundled sample —
ob_analytics/_sample_data/now shipsorders.csvandtrades.csvfrom a modern BTC/USD live capture (replaces the legacy 2015 orders-only slice). - Demos consolidated into
ob_analytics._demos;scripts/bitstamp_demo.py,scripts/lobster_demo.py, and thebitstamp-demo/lobster-demoCLI subcommands are now thin argparse wrappers. Behaviour unchanged. - Performance — the LOBSTER book is maintained as a
SortedDict(no per-event re-sort), Bitstamp trade→event resolution is indexed, LOBSTER depth uses a single strategy, the Plotly import is memoised, and depth metrics sum active levels into bps bins. Numeric output is unchanged (pinned by the regression snapshots). compute_kyle_lambdacomputes its OLS vianp.linalg.lstsq(was hand-rolled; agrees with the prior implementation tortol=1e-10).- Internal modules reorganized (renames from the 0.x line): e.g.
event_processing.py→bitstamp.py, validation/time helpers →_utils.py, and the visualization modules split into avisualization/subpackage. - Type checking is Astral's
ty(not mypy); lint and format are Ruff.
Removed¶
pacmanorder type. A legacy artifact of the 2015 Bitstamp HTTP API, where a singleorder_idcould appear at multiple prices over its lifetime. Modern Bitstamp WS v2 and LOBSTER do not produce this pattern (price-modifies become cancel + new id). ThetypeCategorical no longer includes"pacman", the set-subtraction classification path is gone, andLobsterLoaderno longer renumbers hidden-execution ids (raw type 5 now retains the native LOBSTERid=0).- Bitstamp trade inference. A companion
trades.csvnext toorders.csvis now required. Removed: Needleman–Wunsch matching,BitstampMatcher,BitstampTradeInferrer, theMatchingEngine/TradeInferrerprotocols,NeedlemanWunschMatcher, and thematch_cutoff_ms/price_jump_thresholdfields onPipelineConfig. - Zombie detection —
get_zombie_idsand thezombie_offset_seconds/skip_zombie_detectionconfig fields. - LOBSTER
LobsterMatcher— removed;LobsterTradeInferrerrenamed toLobsterTradeReaderwithload(events, source). - Legacy Bitstamp-only wrappers
load_event_data,event_match,match_trades,process_data, andplot_price_levels_faster. - 12 unused runtime dependencies (scikit-learn, scipy, jupyter, bokeh, …) and stale dev dependencies (black, flake8 + plugins, darglint).
Fixed¶
depth_metricsno longer overflows for prices > $9,999.99 — dynamicdict[int, int]state replaces the fixed array.best_bid/best_askare tracked correctly from the first event (were initialised with dataset-wide max/min).datetime_to_epochuses.astype("int64")instead of the deprecated.view("int64").- All
print()replaced withlogurulogging; all bareassertstatements replaced with raised exceptions;plt.show()removed from plot functions (callers control display).