An engine-agnostic WebSocket protocol for streaming normalized two-sided top-of-book (best bid / best ask) market data. It carries the venue and symbol in every message, uses plain JSON, and is independent of any trading framework.
doublezero-edge-connect — the bridge — is the reference implementation of this protocol's
producer side (it ingests the DoubleZero Edge binary multicast feed and re-serves it in this format),
but it is not part of the protocol. Any engine that can open a WebSocket and parse JSON can
consume it by writing a thin (~50-100 line) adapter to its own internal types. The producer's
input (multicast, binary, etc.) is an implementation detail; only the output below is the
contract.
- WebSocket, one JSON object per text frame (no framing/batching). Plain
ws://(no TLS - this service is intended for a trusted/local network; terminate TLS at a reverse proxy if you must expose it). - The server pushes market data. A consumer may optionally subscribe to narrow the feed to specific venues/symbols; with no subscription it receives everything (see Subscriptions).
- Liveness: the server sends periodic WebSocket Pings and closes a client that is silent past an idle timeout; clients may also send an app-level ping (see Heartbeat & liveness).
On each new connection the producer:
- Replays the current instrument definitions - one
instrumentmessage per symbol whose Source ID is known, so the consumer knows precision before the first quote/depth. For a publisher whose reference data carries no Source ID of its own, that means known and priced at least once - a symbol it defines but has not yet priced is not replayed. For a publisher whose reference data carries its own Source ID, every symbol it defines is replayed, priced or not (see A symbol appears only once its Source ID is known). - Replays the latest full-state book per market, if any - the latest
depthper symbol, and abookre-baseline (aclearplus the complete book) for every market that has one, at the market's own granularity. - Streams
quote/trade/midpoint/depth/book/order_bookmessages as they arrive, fanned out to all connected consumers.
connect -> instrument (xN) -> depth (xM, current books) -> quote -> trade -> depth -> ...
A consumer that connects partway through is therefore always able to build instruments first. New or changed instrument definitions may also arrive at any later point in the feed.
Every message is a JSON object tagged by a type field (snake_case):
type |
Meaning |
|---|---|
instrument |
An instrument/precision definition. |
quote |
A top-of-book update. |
trade |
A trade print (last sale). |
midpoint |
A single derived mid price. |
depth |
A full order-book depth snapshot. |
book |
A batch of incremental price-aggregated order-book changes. |
order_book |
The same batch shape, order-level: each change is one order's state, keyed by the venue's order_id. |
status |
A venue-level feed-health transition. |
Consumers must ignore unknown type values and unknown fields (forward compatibility).
{"type":"instrument","venue":"Hyperliquid","source_name":"Hyperliquid","source_id":1,"symbol":"SOL","channel":0,"instrument_id":1,"price_exponent":-2,"qty_exponent":-2,"tick_size":100}| Field | Type | Meaning |
|---|---|---|
type |
string | "instrument". |
venue |
string | Deprecated. Always identical to source_name. See source_name, source_id, and the deprecated venue. |
source_name |
string | The upstream source's registry name (e.g. Hyperliquid, Phoenix). Preferred. |
source_id |
number | The wire Source ID, verbatim. |
symbol |
string | Instrument symbol as the venue names it (e.g. SOL, SOL-PERP). |
channel |
uint8 | The publisher's channel id: the instrument set this feed carries. Filterable. |
instrument_id |
uint32 | Instrument id, unique within channel. |
price_exponent |
int8 | Price fixed-point exponent: prices are quoted to 10^price_exponent (e.g. -2 -> two decimals). Not the tradable tick — see tick_size. |
qty_exponent |
int8 | Size increment exponent: step = 10^qty_exponent. |
tick_size |
int64 | The venue's tradable price increment, in the same fixed point: the real increment is tick_size * 10^price_exponent. 0 means the publisher states none. |
channel and instrument_id are the identity a consumer joins a book to its definition on, rather than the colliding symbol.
price_exponent / qty_exponent give the venue's precision; quote prices/sizes are
already decimal values (below), so the exponents set decimal places, not a rescaling of integers.
Precision is not the tick. 10^price_exponent is how finely prices are expressed; tick_size * 10^price_exponent is how coarsely they may move, and the two differ by orders of magnitude on real venues — BTC on Phoenix is exponent -2 with tick_size 100, so it trades in dollars while the fixed point is cents. A consumer sizing or rounding an order must use the product, never the exponent alone. tick_size == 0 is the publisher declining to state one (Hyperliquid's definitions do today), and the only claim left there is the precision.
{"type":"quote","venue":"Hyperliquid","source_name":"Hyperliquid","source_id":1,"symbol":"SOL",
"bid":184.20,"ask":184.21,"bid_size":12.5,"ask_size":8.0,"bid_n":3,"ask_n":2,
"source_ts_ns":1781019263715344015,"recv_ts_ns":1781019263715501230,
"kernel_rx_ts_ns":1781019263715300010,"ws_send_ts_ns":1781019263715600440}| Field | Type | Meaning |
|---|---|---|
type |
string | "quote". |
venue |
string | Deprecated. Always identical to source_name. |
source_name |
string | The upstream source's registry name. Preferred. |
source_id |
number | The wire Source ID, verbatim. |
symbol |
string | Symbol (matches an instrument's symbol). |
bid |
number | Best bid price (decimal). |
ask |
number | Best ask price (decimal). |
bid_size |
number | Size at best bid (decimal). |
ask_size |
number | Size at best ask (decimal). |
bid_n |
uint16 | Count at best bid (0 if the venue does not report it). Part of the top-of-book identity: a change here is a distinct quote even at an unchanged price/size. |
ask_n |
uint16 | Count at best ask (0 if unavailable). |
source_ts_ns |
uint64 | Venue timestamp, ns since Unix epoch. 0 if unknown. |
recv_ts_ns |
uint64 | Producer user-space receive time (after decode), ns since epoch. |
kernel_rx_ts_ns |
uint64 | Kernel RX timestamp (SO_TIMESTAMPNS, CLOCK_REALTIME) captured in the driver softirq, before user space. 0 if unavailable. |
ws_send_ts_ns |
uint64 | Wall clock sampled the instant this quote is serialized for the consumers. A single value shared by all consumers of this message (the producer serializes once and fans the identical frame out), not a per-connection send time. 0 if not stamped. |
All timestamps are nanoseconds since the Unix epoch (wall clock), and 0 is the sentinel
for "not available." Consumers must treat 0 as missing, not as 1970.
They decompose latency end-to-end and are usable by any engine, not just for backtests:
source_ts_ns --> kernel_rx_ts_ns --> recv_ts_ns --> ws_send_ts_ns --> (consumer recv)
venue book wire-adjacent user-space WS hand-off
arrival (defendable) (post-decode)
kernel_rx_ts_ns - source_ts_ns~ network + venue->host transit (use kernel ts to avoid user-space scheduling jitter).recv_ts_ns - kernel_rx_ts_ns~ decode + queueing inside the producer.ws_send_ts_ns - recv_ts_ns~ fan-out hand-off.consumer_recv - ws_send_ts_ns~ the WebSocket hop to your engine.
{"type":"trade","venue":"Hyperliquid","source_name":"Hyperliquid","source_id":1,"symbol":"SOL",
"channel":0,"instrument_id":1234,
"price":184.20,"size":3.5,"aggressor_side":"buy","trade_id":987654,"cumulative_volume":12500.0,
"source_ts_ns":1781019263715344015,"recv_ts_ns":1781019263715501230,
"kernel_rx_ts_ns":1781019263715300010,"ws_send_ts_ns":1781019263715600440}A trade print (last sale) for a symbol. Prices/sizes are already decimal values (scaled by the
venue precision, same convention as quote).
| Field | Type | Meaning |
|---|---|---|
type |
string | "trade". |
venue |
string | Deprecated. Always identical to source_name. |
source_name |
string | The upstream source's registry name. Preferred. |
source_id |
number | The wire Source ID, verbatim. |
symbol |
string | Symbol (matches an instrument's symbol). |
channel |
uint8 | The publisher's channel id: the instrument set this feed carries. 0 for an upstream source with no channel concept of its own. Filterable. |
instrument_id |
uint32 | Instrument id, unique within channel. |
price |
number | Trade price (decimal). |
size |
number | Trade size (decimal). |
aggressor_side |
string | "buy", "sell", or "unknown" - the aggressor (taker) side. |
trade_id |
uint64 | Venue-assigned trade identifier. |
cumulative_volume |
number | Session cumulative traded volume (decimal); 0 if not provided. |
source_ts_ns |
uint64 | Venue timestamp, ns since epoch. 0 if unknown. |
recv_ts_ns |
uint64 | Producer user-space receive time (after decode), ns since epoch. |
kernel_rx_ts_ns |
uint64 | Kernel RX timestamp (SO_TIMESTAMPNS); 0 if unavailable. |
ws_send_ts_ns |
uint64 | Wall clock the instant this trade is serialized; shared by all consumers of this message (serialized once, not per-connection). 0 if unset. |
The same four timestamps as quote ride every trade (see Why four timestamps). Unlike a quote,
a trade is a point-in-time event, not full state: it is not replayed on connect, and a trade
dropped under backpressure is simply a missed print (it does not leave a stale book). A consumer
that only wants top-of-book may ignore trade per the forward-compatibility rule.
channel/instrument_id are additive (forward-compatible: an older consumer that ignores unknown
fields is unaffected) and carry the same identity instrument/book do — see Identity under
book. They exist so a consumer can join a trade to its market on the identity rather than
symbol alone, which is not unique on a venue whose publisher shards the same instrument set across
more than one channel.
{"type":"midpoint","venue":"MidpointVenue","source_name":"MidpointVenue","source_id":0,"symbol":"SOL","mid":184.205,
"method":0,"quality_flags":0,
"book_ts_ns":1781019263715344015,"compute_ts_ns":1781019263715350000,
"recv_ts_ns":1781019263715501230,"kernel_rx_ts_ns":1781019263715300010,
"ws_send_ts_ns":1781019263715600440}A single derived mid price for a symbol, from the DZ Edge Midpoint feed. Like a
quote it is full state per instrument (the latest mid), so it self-heals on the next message;
a consumer that connects partway through sees the matching instrument (for precision) first.
| Field | Type | Meaning |
|---|---|---|
type |
string | "midpoint". |
venue |
string | Deprecated. Always identical to source_name (a Midpoint feed maps to its own venue). |
source_name |
string | The upstream source's registry name. Preferred. |
source_id |
number | The wire Source ID, verbatim (0 when the feed names no registry row). |
symbol |
string | Symbol (matches an instrument's symbol). |
mid |
number | Mid price (decimal). |
method |
uint8 | How the mid was computed (0 = the instrument's default method). |
quality_flags |
uint8 | Bitfield: bit0 stale, bit1 one-sided, bit2 crossed/locked, bit3 synthetic. |
book_ts_ns |
uint64 | Venue timestamp of the underlying book state; 0 if unknown. |
compute_ts_ns |
uint64 | When the publisher computed the mid; 0 if unknown. |
recv_ts_ns |
uint64 | Producer user-space receive time (after decode), ns since epoch. |
kernel_rx_ts_ns |
uint64 | Kernel RX timestamp (SO_TIMESTAMPNS); 0 if unavailable. |
ws_send_ts_ns |
uint64 | Wall clock the instant this midpoint is serialized; shared by all consumers of this message (serialized once, not per-connection). 0 if unset. |
The Midpoint feed carries no sizes, so its instrument reports qty_exponent: 0 (ignore it
for mids). A consumer that only wants quotes/trades may ignore midpoint per forward-compat.
{"type":"depth","venue":"MboVenue","source_name":"MboVenue","source_id":0,"symbol":"SOL",
"bids":[[184.20,12.5],[184.19,4.0]],"asks":[[184.21,8.0],[184.22,6.5]],
"source_ts_ns":1781019263715344015,"recv_ts_ns":1781019263715501230,
"kernel_rx_ts_ns":1781019263715300010,"ws_send_ts_ns":1781019263715600440}A full order-book depth snapshot (top N levels per side), derived in the producer from the DZ
Edge Market-by-Order feed. bids/asks are arrays of [price, size] decimal pairs, best
first (bids high→low, asks low→high).
| Field | Type | Meaning |
|---|---|---|
type |
string | "depth". |
venue |
string | Deprecated. Always identical to source_name (a Market-by-Order feed maps to its own venue). |
source_name |
string | The upstream source's registry name. Preferred. |
source_id |
number | The wire Source ID, verbatim (0 when the feed names no registry row). |
symbol |
string | Symbol (matches an instrument's symbol). |
bids |
number[][] | [price, size] pairs, highest price first. |
asks |
number[][] | [price, size] pairs, lowest price first. |
source_ts_ns |
uint64 | Timestamp of the latest applied book event; 0 if unknown. |
recv_ts_ns |
uint64 | When the producer built this snapshot, ns since epoch. |
kernel_rx_ts_ns |
uint64 | Kernel RX timestamp (SO_TIMESTAMPNS); 0 if unavailable. |
ws_send_ts_ns |
uint64 | Wall clock the instant this snapshot is serialized; shared by all consumers of this message (serialized once, not per-connection). 0 if unset. |
Each depth message is full state (the complete top N, not a delta), so - like quote - it
self-heals: a consumer that drops one under backpressure recovers on the next snapshot, and a
client that connects partway through is replayed the latest depth per symbol on connect (after the
instrument definitions).
Market-by-Order produces
depthandbook. The producer runs that feed's snapshot+delta recovery internally and derives both products from the same reconstructed L3 book: this full-state top-Ndepth, and the order-levelorder_bookcarrying the venue's ownorder_id. Neither re-serves the upstream's raw add/cancel/execute events — abookchange is the resulting state of one order, so a consumer needs no delta arithmetic, and a recovery still surfaces only as a re-baseline.
{"type":"book","venue":"BookVenue","source_name":"BookVenue","source_id":0,"symbol":"SOL","channel":2,"instrument_id":41,
"changes":[{"action":"update","side":"bid","price":0.6200,"size":150,"order_id":0},
{"action":"delete","side":"ask","price":0.6300,"size":0,"order_id":0}],
"batch_id":364823117,"snapshot":false,"last":true,
"source_ts_ns":1781019263715344015,"recv_ts_ns":1781019263715501230,
"kernel_rx_ts_ns":1781019263715300010,"ws_send_ts_ns":1781019263715600440}A batch of incremental book changes for one instrument, derived in the producer from a DZ Edge order-book feed. Unlike depth, a book message is not full state: apply the changes in order to the book you already hold.
The two granularities are two message types, not one type with a flag. book is price-aggregated: every change is a price level's resulting size and order_id is 0. order_book is order-level: every change is one order's resulting size, keyed by the venue's own order_id, and several orders may rest at one price. The envelope and every field are otherwise identical, so a consumer that handles both shares one parser and routes on type; one that handles only book ignores order_book under the forward-compatibility rule and is unaffected. A market is served under exactly one of the two, never both — a Market-by-Price feed produces book, a Market-by-Order feed order_book.
They are separate types because they cannot be mixed by accident. Applying an order-level change as if it were price-aggregated is silent corruption, not degraded output: two orders resting at one price collapse to the last one's size, and deleting either removes a level the other still occupies. Had order-level changes ridden book with a new order_id field, a consumer written before that field existed — and correctly ignoring it, exactly as this document tells it to — would have corrupted its own book. The type is what the forward-compatibility rule can actually protect.
| Field | Type | Meaning |
|---|---|---|
type |
string | "book" when price-aggregated, "order_book" when order-level. |
venue |
string | Deprecated. Always identical to source_name. |
source_name |
string | The upstream source's registry name. Preferred. |
source_id |
number | The wire Source ID, verbatim. |
symbol |
string | Display label. Not guaranteed unique — see Identity below. |
channel |
uint8 | The publisher's channel id: the instrument set this feed carries. Filterable. |
instrument_id |
uint32 | Instrument id, unique within channel. |
changes |
object[] | Book changes, in order. { "action", "side", "price", "size", "order_id" }. |
changes[].action |
string | "clear", "update", or "delete". |
changes[].side |
string | "bid", "ask", or "both" ("both" only on a clear). |
changes[].price |
number | Price of the level or order (decimal). Ignored for a clear. |
changes[].size |
number | The absolute resulting size (decimal), not a delta — of the level for a price-aggregated change, of the order for an order-level one. 0 on a delete. |
changes[].order_id |
uint64 | The venue's order id for an order-level change, or 0 when the change is price-aggregated and carries no order identity. |
batch_id |
uint32 | The venue's committed slot this book state stands at. Optional — absent, never 0, until the venue has named one. See Same-slot comparison below. |
snapshot |
bool | Advisory: this batch is part of a rebuild. Not what re-baselines you. |
last |
bool | This is the final batch of a logical book event. |
source_ts_ns |
uint64 | Timestamp of the latest applied book event; 0 if unknown. |
recv_ts_ns |
uint64 | When the producer built this batch, ns since epoch. |
kernel_rx_ts_ns |
uint64 | Kernel RX timestamp (SO_TIMESTAMPNS); 0 if unavailable. |
ws_send_ts_ns |
uint64 | Wall clock the instant this batch is serialized; shared by all consumers of this message. 0 if unset. |
Identity: key on (venue, channel, instrument_id), not on symbol. The upstream symbol is a fixed 16-byte field the publisher fills by keeping the ticker's rightmost 16 bytes — silently, with no hash and no length check — so on venues with long tickers distinct markets collide on it, and a consumer keying on symbol merges two books into one. symbol is for display, and for the convenience of venues where it happens to be unique. instrument messages carry channel and instrument_id too, so a consumer joins a book to its definition on the same identity, and learns the mapping from the connect-time replay of the definitions.
Re-baselining is structural: changes[0].action == "clear". Do not key it off snapshot. A rebuild (on connect, after a recovery, or when the producer's authoritative path changes) arrives as a clear followed by the complete level set, with snapshot: true and last: true on the final batch. snapshot exists only so a consumer can tell a rebuild from ordinary activity; a consumer that ignores it stays correct.
Same-slot comparison: batch_id is the venue's own committed slot, truncated to 32 bits by the upstream feed, taken from the most recent batch boundary the producer saw on this market's channel. It is what lets a consumer compare this book against a slot-stamped venue snapshot at the same slot; a comparison over a time window measures sampling skew instead, which alone produces phantom disagreements of several ticks. Two limits: the field is absent — never 0 — until a boundary has been seen for the market, so it is missing for the first batches after a connect, on feeds whose venue publishes no boundary at all (Market-by-Order markets, served as order_book, carry none today), and again after the upstream publisher restarts or ends its session, since the slot is only monotonic within one publisher era and the previous era's number would name a slot the book cannot be at; and a batch that straddles a boundary reports the slot last committed, so the state it carries is at least that slot's, occasionally a little past it. A consumer comparing at a slot boundary should treat a mismatch as inconclusive rather than as a defect.
last is mandatory and must be honored. A consumer that buffers a logical event until its final batch will wait forever if it is dropped — including on a re-baseline whose only change is the clear.
Gap detection is the producer's job. The producer runs the upstream feed's snapshot+delta recovery internally, per publisher, and re-serves only sequences it has verified as contiguous. There are no sequence numbers on the wire and a consumer needs no gap machinery of its own: a recovery surfaces as a re-baseline.
One book per market, whichever upstream publisher wins. Several independent publishers mirror each feed, and a consumer sees one coherent book from them, never two to merge. How that happens depends on whether the changes carry an order_id, but the consumer contract is the same either way and it is never told which publisher it is reading.
- Price-aggregated. The producer elects one authoritative publisher per market and republishes only its feed. A failover surfaces as a re-baseline: that market's next batch is a
clearfollowed by the complete level set as the newly authoritative publisher holds it, or — when that publisher's own book is not yet complete — theclearalone, with the level set rebuilt by the batches that follow. - Order-level. Every publisher stamps the venue's own
order_id, so the producer instead publishes each venue event's first arrival and collapses the rest: a consumer gets each event once, from whichever publisher was fastest for that event. A change for an order the producer has already published as gone is refused, so a lagging publisher cannot resurrect it. A publisher recovering by snapshot republishes its whole book only when no peer is both healthy and currently serving the market, so a recovery cannot wipe a book another publisher is keeping current — while a publisher that stops reaching the producer cannot block the recovery either.
Either way a consumer that honors clear needs nothing else.
The bootstrap matches the market, always. An order-level market is replayed as orders and a price-aggregated one as levels, so a consumer never has to reconcile a bootstrap against a feed of different granularity. There is no way to ask for anything else: an order-level change carries one order's absolute size, so a client bootstrapped with price levels holds no order state to apply the live feed to.
{"type":"status","venue":"Hyperliquid","source_name":"Hyperliquid","source_id":1,"state":"down","stale_ms":30000,"ts_ns":1781019263715344015}A venue-level feed-health transition. The producer emits one when a venue's quote
(market-data) multicast goes silent past the idle watchdog (state:"down"), and again when quotes
recover (state:"ok"). It is emitted only on the edge (not repeatedly while silent), so a
consumer can gray out / restore that upstream source. Unlike quote/instrument it carries no symbol
- it is about the whole venue feed - so the server matches it against a subscription by venue
alone (a
{"venue":"Hyperliquid","symbol":"SOL"}subscriber still receives Hyperliquid status).
A venue is reported down only when every publisher mirroring its quote feed has gone silent
past the idle window; a single wedged publisher does not produce a status transition, because the
remaining publishers still deliver full-state updates for that venue. status stays what it has
always been — the health of the venue's quote feed — so a depth-only (Market-by-Order) publisher
going silent is not reported here, and a healthy one does not suppress a quote outage.
An upstream source whose receivers have revealed no Source ID may never produce a status message at
all. The naming this message carries — like every other message's — depends on a Source ID
observed on the wire (see A symbol appears only once its Source ID is
known); an upstream source that never reveals its
identity this way has no name to emit status under, healthy or not. A consumer should not infer
"up" from the absence of a status message for an upstream source it expects to hear from.
For a publisher whose reference data carries no Source ID of its own, revealing requires a decoded
price, so in practice this is the "no market data decoded" case its name suggests. For a publisher
whose reference data carries its own Source ID, reference data alone reveals it — status can
still be emitted for an upstream source whose receivers have decoded reference data but never a single
quote or trade.
| Field | Type | Meaning |
|---|---|---|
type |
string | "status". |
venue |
string | Deprecated. Always identical to source_name. |
source_name |
string | The upstream source's registry name whose quote feed changed health. Preferred. |
source_id |
number | The wire Source ID, verbatim. |
state |
string | "down" (quote multicast silent) or "ok" (quotes recovered). |
stale_ms |
uint64 | Milliseconds the quote feed had been silent (0 when "ok"). |
ts_ns |
uint64 | Wall clock (ns since epoch) the status was emitted. |
Quote delivery is not gated on status - it is advisory health, and because every quote is
full state the feed self-heals on the next quote regardless. A consumer that ignores status
(per the forward-compatibility rule) simply forgoes the gray-out.
Every message carries three fields naming where the data came from:
| Field | Type | Meaning |
|---|---|---|
source_name |
string | The upstream source's registry name. Preferred. |
source_id |
number | The wire Source ID, verbatim. |
venue |
string | Deprecated. Always identical to source_name. |
This release renames source to source_name, and changes what venue contains. The bare
source key is gone — the edge-feed-spec glossary bans it, since source always takes a qualifier.
source_id is now the Source ID the publisher stamped on the wire, passed through unmodified, and
source_name/venue are both that ID's registry name. Previously the bridge substituted its own
configured label when it did not recognise an ID. It no longer does: a publisher stamping an
incorrect Source ID now produces messages named for the upstream source that ID identifies, because the
Source ID is the contract and a wrong one is a publisher defect to fix at the publisher.
venue remains the compatibility alias and carries the identical value, so a consumer reading
source today either reads venue (no work) or moves to source_name. If you filter or key on
venue, re-check your values against a live feed rather than assuming they are unchanged; new
consumers should read source_name, or source_id for a stable numeric identity that needs no
string matching.
An unregistered Source ID yields a stable synthesized name (SOURCE_<id>) rather than being dropped,
so data always flows and an unrecognised Source ID is visible rather than silent.
Planned for v2: venue names a matching engine, not a venue. The edge-feed-spec glossary is explicit that a Source ID identifies one matching engine and that a venue may hold several IDs, so the field is misnamed as well as redundant. Retiring it — rather than merely deprecating it, which this release does — is a v2 change, and upstream's own sources/spec.md still describes the field the old way.
A message is emitted for an instrument only after that instrument's Source ID has been observed — but when that happens depends on the publisher's reference-data generation.
The original edge feed spec carried a Source ID only on price messages, never on reference data or
book snapshots. A publisher of that generation still works this way: an instrument that has
received reference data but no price yet produces nothing — no instrument, no quote, no
book, no depth — until a price names it. Midpoint stays on this generation permanently: its
reference-data message is a distinct, narrower definition with no Source ID field, so a midpoint
instrument is always deferred until priced, independent of any other feed's generation.
A newer publisher generation adds a Source ID to reference data itself. For a publisher of that
generation, an instrument is named the moment its definition is decoded — instrument reaches the
wire with no price required at all, even for a symbol that never trades.
Consequences for a consumer:
- The connect-time replay contains one
instrumentper symbol whose Source ID is known — every symbol a newer-generation publisher defines, but only the priced ones for a publisher (or Midpoint) still on the original generation. - For a publisher still on the original generation, a
statusmessage may never appear for an upstream source whose receivers have decoded no market data at all (seestatus); a newer-generation publisher's reference data alone is enough to reveal it. - Both generations can be live at once — a host may hold publishers of either kind for different venues, or for different rows of the same venue — so a consumer should not assume the deferral rule observed for one upstream source holds for every one on the feed.
The deferral itself is deliberate, for whichever generation still needs it. The alternative is announcing an instrument under a name the bridge guessed, which is what the previous behaviour did.
A consumer may send control messages (JSON text frames) to filter the feed. Subscriptions are optional: a client that never subscribes receives all venues/symbols (firehose). Once it has >=1 active subscription, it receives only matching messages.
A subscription filter is { "source_name"?: string, "venue"?: string, "symbol"?: string, "channel"?: uint8, "type"?: string } - an omitted field matches any value (so {} = everything, {"symbol":"SOL"} = SOL on every venue, {"type":"book"} = price-aggregated book updates only, {"type":"order_book"} = order-level ones). venue/source_name are matched case-insensitively (PHOENIX selects Phoenix); symbol, channel and type are matched exactly.
source_name and venue are aliases and are matched case-insensitively. The pre-rename source key
is still accepted as a third spelling. All three are ANDed, so supplying any two that disagree
matches nothing; supply one. Sending the same value under two spellings is fine — it is the natural
way to straddle the rename, and no combination is a protocol error. (source is still accepted
because an unknown filter key is ignored: dropping it would have widened a client that narrowed on
source alone to the firehose rather than telling it anything.)
venue, symbol and channel are scope dimensions - which markets - and type is a kind dimension: which messages. The two behave differently on purpose.
A scope dimension never excludes a message that is not about one market. A message type that carries no channel (everything except book and instrument) is excluded by an explicit channel filter, so {"channel":2} selects channel 2's book updates and channel 2's instrument definitions - enough to scale those books - and nothing else. The one carve-out is a venue-level message (status), which carries neither symbol nor channel and is matched on venue and type alone, so a {"venue":"Hyperliquid","symbol":"SOL"} subscriber still receives Hyperliquid status.
A type filter is absolute: it delivers that message type and nothing else, including no instrument and no status. Filters are a union, so a consumer that wants books plus reference data and health subscribes to each - {"type":"book"}, {"type":"order_book"}, {"type":"instrument"}, {"type":"status"} - or omits type and scopes by venue/symbol/channel instead. A client that sets type and never asks for instrument gets the connect-time replay of the definitions that exist then, and no later ones; that is the filter it asked for.
symbol cannot select a single book market where a venue's tickers collide under truncation (see Identity under book); such a subscription delivers every colliding market's book. Filtering book down to one market is not expressible in v1.
Client -> server:
{"method":"subscribe","subscription":{"venue":"Hyperliquid","symbol":"SOL"}}
{"method":"unsubscribe","subscription":{"venue":"Hyperliquid","symbol":"SOL"}}
{"method":"ping"}Server -> client (control/ack frames are tagged by channel, distinct from data's type):
{"channel":"subscription_response","method":"subscribe","subscription":{"venue":"Hyperliquid","symbol":"SOL"}}
{"channel":"pong"}
{"channel":"error","error":"max subscriptions reached"}Unknown/malformed control messages get {"channel":"error","error":"unrecognized message"} and
are otherwise ignored.
Instrument definitions and current book state are replayed on connect (unfiltered, since a client has no subscriptions yet) and again on each subscribe, scoped to the filter just added — so a client that narrows after connecting is bootstrapped for its new scope instead of waiting for the next event. Replay is idempotent full state, so the overlap is harmless.
The granularity of that replay is the market's, not the subscription's. An order-level market is bootstrapped as every resting order with its order_id, under order_book; a price-aggregated one as price levels carrying order_id: 0, under book — see The bootstrap matches the market. The bootstrap therefore carries the same type as the feed it precedes, so a {"type":"book"} subscriber is never bootstrapped with a market it could not then apply.
- The server sends a WebSocket Ping every
WS_HEARTBEAT_SECS(default 20s); a compliant client auto-replies Pong (no app action needed). - A client that sends no frame (data Pong, app ping, or control message) for
WS_IDLE_TIMEOUT_SECS(default 60s) is closed - this reaps dead/stalled consumers. - App-level keepalive is also supported:
{"method":"ping"}->{"channel":"pong"}.
| Limit | Default | Behavior when exceeded |
|---|---|---|
Concurrent clients (WS_MAX_CLIENTS) |
64 | New connection is rejected (closed). |
Subscriptions per client (WS_MAX_SUBS) |
256 | subscribe is refused with an error. |
Inbound control msgs / client / min (WS_MAX_INBOUND_PER_MIN) |
600 | Client is disconnected. |
Broadcast buffer (WS_BROADCAST_CAPACITY) |
4096 | A slow client drops the oldest messages (logged); it is never allowed to stall the feed. |
Because every quote is a full top-of-book snapshot, a consumer that drops messages under
backpressure self-heals on the next quote - no resync handshake is required.
on connect:
for each frame (JSON):
msg = parse(frame)
switch msg.type:
"instrument":
tick_size = 10 ** msg.price_exponent
size_step = 10 ** msg.qty_exponent
register/update instrument(msg.venue, msg.symbol, tick_size, size_step)
"quote":
inst = instrument(msg.venue, msg.symbol) # may not exist yet -> buffer or skip
emit_top_of_book(inst, msg.bid, msg.ask, msg.bid_size, msg.ask_size,
event_time = msg.source_ts_ns or msg.kernel_rx_ts_ns)
"trade":
inst = instrument(msg.venue, msg.symbol)
emit_trade(inst, msg.price, msg.size, msg.aggressor_side,
event_time = msg.source_ts_ns or msg.kernel_rx_ts_ns)
"depth": # full snapshot each message (self-healing)
inst = instrument(msg.venue, msg.symbol)
replace_book(inst, msg.bids, msg.asks) # overwrite, don't merge
"book" | "order_book": # incremental; apply in order
# One handler covers both: the envelope is identical and the type says how to key.
# "book" -> key by c.price. "order_book" -> key by c.order_id (several orders may rest
# at one price, so keying an order_book change by price collapses them). A consumer that
# only implements "book" simply omits the second label and ignores those messages.
book = book_for(msg.venue, msg.channel, msg.instrument_id)
for c in msg.changes: # "clear" re-baselines, not msg.snapshot
apply(book, c.action, c.side, c.price, c.size, c.order_id)
if msg.last: publish(book) # honor `last` or you wedge
_: ignore # unknown type
# ignore unknown fields throughout
reply Pong to Ping; reconnect on close.
- Any engine (Freqtrade, Hummingbot, a custom bot in Rust/Go/Python): implement the loop above against your engine's instrument/quote types. ~100 lines; all the framework coupling lives in the adapter, none on the wire.
- Venue codes and symbols are opaque strings agreed between producer and consumer; the
protocol does not mandate a registry. Match them exactly (
symbolon aquoteequals thesymbolon itsinstrument). - A single feed endpoint may carry multiple venues and symbols; route by
venue+symbol. - One
doublezero-edge-connectprocess ingests several upstream feeds at once and tags each message with that feed's venue, so one WebSocket endpoint is inherently multi-venue.
- This document defines v1, which includes: the
instrument/quote/trade/midpoint/depth/book/order_bookdata messages, the venue-levelstatusfeed-health message, optional subscribe/unsubscribe filtering, app ping/pong + server heartbeat with idle timeout, and connection/subscription/ rate limits with broadcast backpressure. depthis deprecated. It is the full-state top-N product derived from the Market-by-Order feed; the incremental pair supersedes it with the complete book —bookon a Market-by-Price feed,order_bookon a Market-by-Order one. Both are served today, from every feed that has one;depthis removed in v2. New consumers should implement whichever of the two their markets are served under.- Additive in this revision, so still v1:
tick_sizeoninstrument, the venue's tradable price increment — a consumer that derived a tick fromprice_exponentalone was wrong by the tick's own magnitude (100x on BTC), and this document said so; that row is corrected above. Alsobatch_idonbook, the venue's committed slot (see Same-slot comparison) — an optional field a consumer that does not know it ignores. Also theorder_bookmessage type, carrying the Market-by-Order feed's order-level book, andorder_idon a book change. An existing consumer subscribed tobookis served exactly the price-aggregated markets it was served before;order_bookis a type it does not know and ignores. Deliberately a new type rather than a new field onbook— see They are separate types for why a field would have corrupted such a consumer instead. - Breaking within v1:
sourceis nowsource_name, both on every message and as a subscription filter key. The forward-compatibility rule below covers the arrival ofsource_name, not the departure ofsource;venuestill carries the identical value, so the migration is to readvenueor to readsource_name. - Breaking within v1: what
venuecontains changed, and emission is now gated on a Source ID having been observed on the wire. Both are additive to the shape of the protocol (new fields, no removed ones) but change values an existing consumer may depend on — seesource_name,source_id, and the deprecatedvenueand A symbol appears only once its Source ID is known. The forward-compatibility rule below covers unknown fields/types, not this. - There is no
vfield on the wire; the contract is this spec plus the forward-compatibility rule: consumers ignore unknown message types and unknown fields, so additive changes are non-breaking. A future revision may add an explicitvfield. tradegainedchannel/instrument_id. Purely additive - existing fields are unchanged and a consumer ignoring unknown fields is unaffected - carrying the same identityinstrument/bookalready do, so a trade can be joined to its market on the identity rather thansymbolalone.
- TLS /
wss://- intentionally omitted; this service runs on a trusted/local network (use a reverse proxy if exposure is ever needed). - Sequence numbers + gap detection per
(venue, symbol). Not needed for the top-of-book contract (everyquoteis full state and self-heals); would matter only for delta feeds. - Additional message types: funding rate, open interest (the venue-level feed
statusmessage,tradeprints,midpointand order-bookdepthare now part of v1 - see above). - AuthN/AuthZ and a
/health+ metrics endpoint (service/ops concerns, not the wire protocol).