vike
DeveloperTesting

The market-data contract

Three L2-book invariants — monotonic sequence, gap triggers a resnapshot, derived L1 matches the book — run against every covered venue's real book normalizer.

crates/vike-bridge-core/tests/market_data_conformance.rs is the market-data twin of the exec contract. Where that harness machine-checks the event contract, this one machine-checks the book seam: the L2 invariants each covered venue's real market_data::route_frame normalizer must uphold. One invariant table, parameterized over the vike_bridge_core::pump_spec roster, so a new venue cannot silently escape classification.

The three invariants

  1. Monotonic sequence — book updates fold in the venue's sequence order. A delta-sync venue folds a contiguous delta and drops a stale or duplicate one, so the book's last_seq never regresses. A snapshot-full venue's frame advances last_seq to the frame's timestamp and replaces the book.
  2. Gap triggers a resnapshot — a sequence gap answers MdEvent::Resync, never a silent fold across the gap. The gapped delta is not applied and the book — best bid, best ask and last_seq — is left exactly as it was, so the pump re-seeds from a fresh snapshot rather than serving a corrupt book. Delta-sync venues only.
  3. Derived L1 consistency — for a venue whose live pump derives top-of-book from the L2 book rather than from a dedicated book-ticker channel, that derived quote must equal the book's real best bid and ask; a one-sided book derives no quote.

Why one table needs two book models

The covered venues do not share one book protocol, and BookModel is the axis that admits it.

DeltaSync — a snapshot seeds the book, then incremental deltas fold under a per-event sequence rule and a gap triggers a resync. Three shapes sit inside that one variant:

  • binance and aster seed lastUpdateId from a REST snapshot, then fold @depth diffs under the U/u span rule; a diff whose U exceeds last_seq + 1 is a gap. L1 rides a separate @bookTicker channel, so invariant 3 is not applicable.
  • bybit takes an orderbook.50 snapshot then strict u += 1 deltas through L2Book::delta_decision(Strict); both a forward jump and a u regression (a venue restart) are gaps. Bybit has no book-ticker channel, so its quote is derived from the L2 top through quote_from_book — it is the one covered venue that exercises invariant 3.
  • deribit opens with a type:"snapshot" frame anchored on change_id, then chains change frames on prev_change_id matching the previous frame's change_id. Its contiguity is an explicit back-pointer, not a numeric step: change_id is venue-global and jumps arbitrarily between frames. Stale is judged first, before the chain check, because a replayed frame's prev_change_id no longer matches the advanced anchor either, and a chain-first reading would report a harmless duplicate as a gap.

SnapshotFull — okx's books5 channel pushes a full top-five snapshot every frame with ts as the sequence, so there are no deltas to gap across. Invariant 2 is recorded as not applicable rather than skipped, and invariant 1 asserts the full-state advance.

Deribit is what keeps the table honest

It is the covered row whose contiguity is a back-pointer rather than an arithmetic step. Without it the shared invariant table would quietly assume numeric sequence contiguity, which three of the four covered venues happen to have.

The bybit row is a retired bug, guarded

The live-versus-backtest divergence ledger once carried "Bybit book fold has no gap arm — binance and okx resync, bybit folds across dropped frames silently until the five-minute reseed." That was resolved when bybit's delta was routed through the shared L2Book::delta_decision(Strict), so bybit now answers MdEvent::Resync on a gap exactly like binance. The harness therefore covers bybit as a passing delta-sync row, which turns a retired ledger item into a permanent regression guard: if bybit's gap arm is ever removed, gap_triggers_resync_all_delta_sync_venues goes red. The task brief that commissioned this harness expected bybit to be deferred as a known divergence; the file's own header records the decision to guard the fix instead of documenting a bug that no longer exists.

Deferrals, and the two kinds of them

Every other roster venue carries a DEFERRED row with a written reason, and the reasons split into two genuinely different categories.

Not on this seam. Aster's market data is a verbatim re-export of binance's family::depth::route_frame, so the shared normalizer and its gap arm are already exercised through the binance row and a dedicated aster row would re-run byte-identical assertions. Hyperliquid has an l2Book feed, but it decodes in its own market-feed module rather than through this route_frame seam. Polymarket's book sits behind its crate feature and uses a feed-local sequence convention the feed derives itself. Each becomes a drop-in row the day its decoder is lifted to this shape.

No book at all, structurally. Oanda's pricing stream serves an unsequenced top-of-book ladder — no sequence id, no delta grammar, every frame a full snapshot of the top — so all three invariants are inexpressible, and its quote path is covered instead by its own decoder fixtures and freshness tests. IG is a dealer venue publishing its own two-sided price: L1 and candles, no ladder. Alpaca, cTrader and IBKR deliver bars, quotes and trades on their own pumps. FXCM has no live market-data lane of any kind — pump_spec classifies it NoPump and its capability row declares live_data: LiveDataCaps::NONE.

The roster gate

md_conformance_roster_is_exhaustive asserts that covered and deferred partition vike_model::VENUES exactly once, that every deferral reason is non-empty, and — the check unique to this harness — that every covered venue is MarketPumpSpec::OnDriver in pump_spec, because a venue must have a live market feed before its book can be conformed. See roster exhaustiveness and the data model.

On this page