> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.immix.xyz/changelog/market-data/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.immix.xyz/_mcp/server. # Market Data WebSocket changelog Consumer-facing history of the client wire ([asyncapi.yaml](/api-reference/market-data/overview) is the contract; this file says what changed, for the people who parse it). Every client-visible change has its section here, newest first, each stating what each side of the deploy window sees. ## The composite channels — Binance spot rejoins: its connector states each value's minimal scale **What changed** * **Nothing in the gateway, its schema or its frames.** What moved is upstream, in binance-md-connector, which no longer states the eight places Binance renders on every surface as the instrument's precision. Every price and size is now read at its *minimal* scale — the trailing zeros stripped, `64887.68000000` read as `64887.68` at 2 — and stated, as every moved connector states a value, at its channel's working scale, which therefore settles at the instrument's grid instead of at eight: a BTCUSDT book is stated at one pair, 2/5 (its 0.01 tick and 0.00001 step), instead of 8/8, inside the record's 4/7, and an ETHUSDT book at 2/4 inside its 4/6. The rule is the venue's, chosen because Binance's width carries no information about the instrument: a venue that pads *to* its grid (Kraken, Bybit) is unchanged. * **Every Binance spot book enters the merge.** A composite holding a Binance spot source beside others stops naming it `precision` and merges its levels, and one built on Binance spot alone serves. The rejoin is the one the re-cut's section below (*a `precision` exclusion reason*) defines: once the source's next snapshot is stated within the topic's output scales it is named in a `recovered` frame — whether it had left the merge or, as every Binance spot source since its move, had never entered it — and appears in `src` from the next full image. A topic on Binance spot alone that acked `syncing` and never served says `recovered`, then serves. * **`precision` no longer stands for padding.** A source is told `precision` because its book is stated finer than the topic's output scales, and the two ways that happens are the ones already in this file: the venue outgrew its reference record (the *`precision` is reachable* section), or — after a record *narrows* — a running connector keeps stating the finer scale it had learned until it restarts (the transient cause the section directly below names). Never any more because a venue renders more digits than it trades. * **The per-instrument channels are unchanged.** They render shortest form and always did, so `64887.68000000` read as `64887.68` before this release and reads the same after; no digit a client saw changes. What a client can notice: `agg_book` levels attributed to a Binance spot source appear where before the source was absent; and on `book`, raw or conflated, a Binance spot book's restatements now come from real precision moves rather than never — over a connector's first frames after a restart a *book* channel can widen as it climbs to the grid, and each book-channel widen is one more `snapshot: true` frame, the form the contract has always used. Expect none in the common case: the venue's capture widened twice, both on a trade channel, which emits no `book` frame. * **`rescaled` is unchanged by this**, and no reference record moves: the alternative — widening the record to the venue's rendering once a record's pair can move — was not taken, so no composite holding a Binance spot source is rescaled to eight places. **Deploy window**: nothing in the gateway deploys for this section; binance-md-connector does, and its restart is the window. A gateway beside the connector **before** its deploy is the *`precision` is reachable* section's world for this venue: every Binance spot book out of every composite as `precision`, a composite on Binance spot alone `syncing` and never serving. **After**: the connector's next snapshot of each book is stated at the minimal pair, and every composite holding that source says `recovered` — or serves for the first time — and merges it at its next tick. Nothing a client must do: one that already treats `precision` as a standing word and parses decimal strings without assuming a digit count sees a source appear, and that is all. The gateway has no "before" of its own here, for the reason the *`precision` is reachable* section gives. ## The composite channels — `rescaled` becomes ordinary: a source's scales can now change **What changed** * **Nothing in the gateway, its schema or its frames** — the re-cut's section below taught the two words and they are unchanged. What changes is upstream, and it changes *when* a client hears one of them. Until now the reference-data owner froze each instrument's scale pair at first listing and refused any catalog row that needed another, so a composite's output scales — derived from its sources' records — could only move in an incident. From this release the owner derives the pair from the catalog row at every poll and publishes a changed pair as the record's next version. **A topic's output `priceScale` / `qtyScale` can therefore move under a live subscription in normal operation, and `status: "rescaled"` is how the client is told** — one frame per affected topic per change, before the first frame rendered at the new pair, exactly as the re-cut's section below specifies. Never per tick. * **Both directions happen.** The scales **widen** when a source venue starts quoting finer than the catalog had registered and the catalog catches up. They can **narrow** when a venue coarsens its price or size grid, or when a first registration that was too fine is corrected. A client must not assume a `rescaled` pair is at or above the one it replaces. * **A narrowing can leave a `vol_book` ladder behind — and a re-subscribe can then answer `bad_ladder`.** `bucketSizes` are held at the topic's quantity scale, so a rescale restates them exactly or not at all. A widening always can. A narrowing cannot when a bucket size uses a decimal the narrower scale drops (`"2.55"` at a quantity scale going from 2 to 1): that topic keeps the pair it advertised — still exact, and no `rescaled` is sent for it — but the same config sent again later (a reconnect re-subscribes with the full config) is judged against the *current* scale and refused `bad_ladder`, naming the scale. Send bucket sizes no finer than the instrument's size increment and neither can happen. * **Expect a burst once, then rarity.** Be ready for `rescaled` on many topics at the first catalog poll after this release deploys, and for it to recur, rarely, over a subscription's life — it is not a once-in-a-listing event. The burst: every instrument whose pair had been held at a stale first sighting corrects at once, and each affected topic — one whose output pair moves with it — says `rescaled` then; a source's pair can change under an output pair another source still sets, and then nothing is said. The rarity: after that a topic's scales move only when a source's catalog row stops fitting the scales its record holds — a venue making its grid finer than the record's headroom absorbs, or a registration being corrected — while a one-decimal tick move is absorbed and says nothing (a week of published catalog history, 2026-09-20, held no other kind of move). * **`reason: "precision"` is live already, and gains one transient cause.** The re-cut's section below said `precision` could not be sent until a later release let a connector state a venue's own digits; the section directly below is that release, and the connectors moved to it venue by venue — every market-data connector states the venue's own digits now, and that section names them. For a moved connector `precision` means what the re-cut's section says: the source's book is stated finer than the topic's output scales, which derive from the reference records. Two things clear it. The record widens — which, from this release, the owner does at the next catalog poll that shows the finer precision, nobody acting — or the venue's rendering changes. (A venue that padded its rendering past its own grid — Binance spot, eight places on every value above its record — stayed `precision` on either side of this until its connector stripped the padding: the section above, which lands in the same release.) **A narrowing can put a source out until its connector restarts.** A stated scale never narrows within a connector's process — the rule the section below states — so after a record **narrows**, a connector whose channel had learned digits above the new pair keeps stating them, every value still exact. If that leaves the source above a topic's output scales, it is announced out of the merge by an ordinary `degraded` frame with `reason: "precision"`, and rejoins through an ordinary `recovered` frame when the connector restarts and its channels learn the venue's digits afresh. Nothing is mis-rendered in between: a level is never rounded into a merge. It takes a venue coarsening by more than the record's headroom, so expect it rarely. **Deploy window**: the gateway and the reference-data owner deploy independently, and only the owner changes here. A gateway carrying the re-cut's section below, beside an owner **before** this release: `rescaled` only in an incident, as that section says; `precision` already, from the connectors that state a venue's own digits — and *standing* wherever a venue has outgrown a record the owner still refuses to widen. The same gateway beside an owner **at** this release: `rescaled` on each affected topic whenever a source's pair change moves its output pair; `precision` clearing within a poll of the catalog showing the finer precision; and the transient cause above. There is no third combination — a gateway older than the re-cut's section below cannot read the re-cut market-data schema this owner's estate runs on. What a client must do is what that section already asked: parse decimal strings rather than assuming a digit count; if it formats to the ack's `priceScale` / `qtyScale`, take the new pair from the `rescaled` frame — in either direction; treat an unknown `status` as ignorable and an unknown `reason` as `stale`. ## The composite channels — `precision` is reachable: the connectors state the venue's own digits **What changed** * **Nothing in the gateway, its schema or its frames.** No field, enum member or frame shape moves, and this section adds no word the section below did not teach. What moved is upstream, in the connectors, and it makes one sentence of that section true sooner than it said: `reason: "precision"` "cannot be sent until a later release lets a connector state a venue's own digits". This is that release — it carries the section below's re-cut and the connectors' move together, so a client meets both at once. * **Which sources can now be announced `precision`, and why.** A moved connector states every price and size at the digits the venue renders on that channel — a working scale learned from the wire — where before it stated the instrument's reference-record pair on every value. A composite's output scales are still derived from its sources' records (the `priceScale` / `qtyScale` the ack echoed), and each source is judged on the pair its book was last stated at. A record's pair is derived with headroom — at least two places past the instrument's tick and past its step — so a venue that renders inside that headroom enters the merge as it always did, and one that renders **past** it is out as `precision`. One kind of venue does: one that outgrew its record — it re-gridded an instrument finer than the record's headroom absorbs. (A venue that padded its rendering past its own grid, Binance spot, did too between its move and the section at the top of this file, which strips the padding at the connector.) The sources this can be said of are the moved venues' books — every market-data venue's, since this release moves them all: **Kraken, Coinbase, Gate.io, Bybit, Binance futures, Binance spot, OKX, KuCoin futures and MEXC**. KuCoin spot moved too but publishes no book yet, so none of its instruments is a composite source. * **How it clears.** As the section below says, the source rejoins through an ordinary `recovered` frame once its book is within the topic's scales again, and two things get it there. **The record widens**: the catalog restates the instrument's pair, the gateway rebinds every composite holding that source, says `rescaled` with the new pair wherever the output scales moved, and the source rejoins. Today that widening is an operator's act — the reference-data owner refuses a changed pair in normal operation, so a record moves only when the instrument is re-listed, the incident shape the section below names — and a `precision` source waits on it. The release that makes the pair a versioned attribute turns the wait into a poll: the owner derives the pair from the catalog row at every poll and publishes the wider pair as the record's next version, so the source rejoins within a poll of the catalog showing the finer precision, nobody acting — that release's own section says what else it changes. **The venue's rendering changes**: a stated scale never narrows within a connector's process — a venue that coarsens its digits, or stops padding, keeps being stated at the finer pair, exactly, with trailing zeros — so a coarser rendering reaches the gateway at that connector's next restart, when its channels learn the venue's digits afresh and the source's next snapshot states the coarser pair. * **Binance spot stood excluded from its move until the section at the top of this file.** Binance spot renders eight places on every surface whatever the instrument's grid — BTCUSDT trades on a 0.01 tick and a 0.00001 step, and every price and size still reads `64887.68000000` and `0.00200000` — and its moved connector stated 8/8 on every channel while the record derives 4/7, so every Binance spot book was out of every composite as `precision` from its first tick, and a composite on Binance spot alone never served. The fix is at the connector, in the same release as this section — the section at the top of this file: the connector states each value's minimal scale, its books settle at the grid (BTCUSDT 2/5) and enter the merge with the topic's scales where they are, so no client meets the exclusion. * **The per-instrument channels are unchanged in shape and, nearly everywhere, in content.** `trade`, `tob`, `book`, `mark`, `oi`, `candlestick` and `ticker` advertise no scale and render shortest form, so a value the venue pads reads as it did — Binance spot's `64887.68000000` was `64887.68` before the move and is after — and a level's `src` attribution on `agg_book`, rendered from the venue's stated pair, is the same string at any stated scale. Two things a client can notice. A value the record's grid could not hold was quarantined by the unmoved connector — never published — and a moved connector publishes it exactly, so on a venue that outgrew its record a client sees prices or sizes at more decimals than the record's pair where before it saw nothing. And when a moved connector's book channel learns a finer digit than it had seen, it restates the held book at the finer pair before deltas resume: on `book`, raw or conflated, that is one more `snapshot: true` frame — the form the contract has always used for a restatement — carrying the same digits. On a shortest-form venue (Coinbase, Gate.io) expect those over a connector's first frames after a restart, then rarely. * **`rescaled` is unchanged by this**: still only the reference-data incident the section below names, until the release that lets a record's pair move. **Deploy window**: nothing in the gateway deploys for this section; the connectors do, one venue at a time, and each is its own window. A gateway carrying the section below beside a connector **before** its move is that section's world for the venue: the connector states the record's pair, so the venue's sources sit at or below the output scales by construction, `precision` is unreachable in normal operation, and a value off the record's grid does not reach the wire. The same gateway beside a connector **after** its move: that venue's books, where it publishes one (KuCoin spot does not yet), can be told `precision` under the rule above, and the per-instrument channels carry every value the venue renders, exactly. The gateway has no "before" of its own here: a gateway older than the section below cannot read the re-cut market-data schema the moved connectors publish on (a clean break under a schema floor, taken with an environment reset), so it never runs beside one. What a client must do is what the section below already asked — treat an unknown `reason` as `stale`, and parse decimal strings rather than assuming a digit count — plus one thing new with this section: expect `precision` to be a standing word, not a transient one. A source told `precision` waits on the catalog or on the venue, not on a restate interval. ## The composite channels — a `precision` exclusion reason, a `rescaled` status, and one rule for `ts` **What changed** * **Two new enum members on `agg_book` and `vol_book` status frames.** The exclusion `reason` vocabulary gains **`precision`**, and the `status` vocabulary gains **`rescaled`**. Both enums were documented closed ("complete so consumers bind once"), so a client that generated an exhaustive enum from the spec must regenerate — or, better, treat an unknown `reason` as it treats `stale` and ignore an unknown `status`, which was always the safe reading. * **`reason: "precision"`** rides an ordinary `degraded` frame: the named source's book is stated at more decimal places than the topic's output scales (the `priceScale` / `qtyScale` the subscribe ack echoed) hold, so its levels cannot enter the merge without dropping digits, and a level is never rounded into a merge. It is judged on the scale, not on the digits the book happens to hold, so a source does not flap in and out with its content. The source rejoins through an ordinary `recovered` frame once its book is within the topic's scales again: the topic was rescaled wide enough, or the venue's next snapshot states a coarser scale. A rescale that widens only part of the way leaves it out. * **`precision` is the one exclusion announced for a source that never entered the merge.** A source that is merely not synced yet is covered by the ack's `syncing` promise and joins silently, as it always has. A `precision` source is different — its book is servable, and it stays out until the catalog or the venue moves — so its `degraded` frame is sent once whether the source left the merge or was never in it. A composite whose only source is born that way answers `syncing` and then, at its first tick: ```json {"event":"agg_book","topic":"agg_book.BTC-X.100ms","status":"degraded", "seq":"4242","seqTs":"1700000000000000000", "excluded":[{"s":"OKX@BTC/USDT","reason":"precision"}]} ``` No `ts` — nothing is included to timestamp — and no `stale` marker after it, because the topic has not served yet and `syncing` still stands. A topic that *was* serving and loses its last source to `precision` gets the same frame first (naming every source that left with it) and then the `stale` marker, so the silence that follows has a stated cause. * **`precision` is also the one reason said over another.** A source already announced `stale` or `fx_expired` whose exclusion *turns into* `precision` — its book comes back from a resync, or its rate returns, and the book is finer than the topic — is named again in a `degraded` frame carrying the new reason. The first word promised a heal within a restate interval or a rate tick; this one waits on the catalog, and a client should not be left waiting on the wrong one. One `recovered` answers both. No other change of reason is re-announced: a `precision` source whose chain breaks and heals stays told `precision`. * **`status: "rescaled"`** is a new status frame with two new integer fields, `priceScale` and `qtyScale` — the ack's own field names, carrying the topic's **new** output scales: ```json {"event":"agg_book","topic":"agg_book.BTC-X.100ms","status":"rescaled", "seq":"4242","seqTs":"1700000000000000000","priceScale":3,"qtyScale":2} ``` The output scales are derived from the sources' reference records. When a source's record restates its scale pair — a venue starts quoting finer than the catalog had registered, and the catalog catches up — the gateway rebinds every composite holding that source, and because the client sent no request for an ack to answer, the frame says so. It is emitted once, at the tier's tick, **before** the first frame rendered at the new pair (so before any `recovered` frame the rebind causes, and before the image). It carries no `ts`: it is a fact about the subscription, not about any source's book. A rebind that leaves the output scales where they were says nothing, and so do two that land back on the pair the client was last told. * **`ts` is written by one rule on every composite frame.** It is the newest included source's exchange timestamp: present when an included source carries one, **absent** otherwise — never `"0"`. Images and the `stale` marker already behaved this way; `degraded` and `recovered` wrote `"ts":"0"` when no included source carried a timestamp, and now omit the field like the other forms. The schema never required `ts`. * **Nothing else moves.** Image frames, the ack, `degraded` / `recovered` / `stale`, every field name and type are unchanged, and every number on the topic is still a canonical shortest-form decimal string: a client that parses decimals — rather than assuming a digit count — sees the same values before and after a rescale. For `vol_book`, the ladder's `bucketSizes` are restated at the new quantity scale, so the buckets a client subscribed to are the buckets it keeps receiving. An identical re-subscribe after a rescale is answered with the new pair. **The per-instrument channels are untouched**: the release that carries this section re-cuts the sequenced market-data schema underneath the gateway (every price and quantity now states its own scale), but `trade`, `tob`, `book`, `mark`, `oi`, `candlestick` and `ticker` advertise no scale and render shortest form, so every string on them is byte-identical before and after. **Deploy window**: a gateway before the upgrade never sends either word, so a client built to this section meets nothing new against it and must simply not *wait* for the words. A gateway after the upgrade can send them to a client that has not learned them, but only when the data gives it cause, and through this release the data does not: every connector still states its reference record's scale pair, which sits at or below every composite's output scales by construction, so **`precision` cannot be sent until a later release lets a connector state a venue's own digits** finer than its record; and **`rescaled` needs a reference record whose pair changed**, which the catalog refuses in normal operation until the release that makes a pair an attribute — the *`rescaled` becomes ordinary* section of this file, which also gives `precision` its one transient cause; the connectors that now state a venue's own digits are named in the section directly above. The one way either happens earlier is an incident — a reference-data rebuild that re-lists an instrument at other scales — and there the frames are the better outcome: before this release the composite stayed bound to the pair the catalog had moved past and said nothing. That is the window to teach a client the two words. A client that has not learned them by then sees, at worst, a `degraded` frame whose `reason` it does not recognise (treat as `stale`: the source is out of the merge, and the image's `src` says which sources are in) and a status frame whose `status` it does not recognise (safe to ignore if it parses decimal strings; a client that fixed its formatting to the ack's `priceScale` / `qtyScale` must take the new pair from this frame). The `ts` correction is visible on both sides at once: an old gateway may send `"ts":"0"` on a `degraded` or `recovered` frame, a new one omits the field — treat `ts` as optional on every composite frame, which is what the schema has always said. ## The ticker channel — `turnover*` is the contract-aware quote notional **What changed** * **`turnover1h` / `turnover24h` now carry the quote notional, not the bare `price × quantity` sum.** For spot instruments nothing moves: the sum IS the quote notional. For contract-sized instruments the numbers change visibly: a linear contract (`FUTURES`, `PERPETUAL_SWAP`, `QUANTO_*`) is the sum times the instrument's `contractMultiplier` (rounded half-even at `priceScale + qtyScale`, so the string keeps its scale — a 0.01-multiplier perpetual's turnover is a hundredth of what it read before, which is what it was worth), and an inverse contract is `volume × contractValue`. * **Null now also means "not valued"**, beside the warming window it already meant: an instrument whose shape the rule cannot value — a linear contract with no stated multiplier, an inverse contract with no stated contract value (every inverse row today: reference data does not publish the field yet), an option, an index — renders `null`, never `0` and never the raw sum. The AsyncAPI description states the rule. * No field, type or frame-shape change: both fields stay `[string, "null"]` at the same scale; every other `ticker` field is untouched. **Deploy window**: before the upgrade a contract-sized instrument's `turnover*` read the raw contract-count sum (a mismatched unit — the correction this section lands); after, it reads the quote notional or `null`. A client that already treated `null` as "no value" needs no change; one that summed turnover across instruments of mixed shapes was summing mismatched units before and gets comparable quote amounts after. The `candlestick` channel carries no turnover field and is unaffected. ## The ticker channel — trailing stats that decay on the stream frontier **What's new** * **The `ticker` channel is served**: `ticker.SYMBOL[.tier]` — last price plus derived trailing 1h/24h statistics off the rolling fold, re-rendered on trades (conflated per tier) **and** once per elapsed stream minute — the decay edge for quiet instruments; an actively trading topic simply receives one extra image at the minute edge. `asOf` (the consumed event-time frontier at render) advances on decay re-renders while `seq`/`seqTs` (fact provenance, the last trade folded) stay unchanged: `asOf` moving alone IS the decay tell. * **Per-window cold-start completeness rides nullability**: a window's derived fields are null until that window's history exists post-cold-start (between +1h and +24h the 1h numbers stand while the 24h ones are null); `windowComplete` is the 24h window's flag; null never renders as 0, and a warm window with no trades is flat (zero sums, ratio `"0"`, extremes carried), not null. `pctChange*` are ratios (not percent) at 4 decimals; `turnover*` are exact decimal strings at `priceScale + qtyScale` (sums never pass through floating point). **Deploy window**: before this deploy, `ticker.*` topics answer per-topic errors — probe-and-fall-back works as for the candlestick channel's section below. ## The candlestick channel — closes on the stream frontier, and the object topic form **What's new** * **The `candlestick` channel is served**: `candlestick.SYMBOL.INTERVAL[.tier]`, the interval segment mandatory from `components.schemas.candleInterval` (the 8-interval set shared with the candle-history surface). Subscribing answers `live` and pushes one `snapshot: true` frame — the last two closed bars plus the forming bar, oldest to newest; a never-traded instrument answers `bars: []` with `seq`/`seqTs` omitted, so "no data exists" is distinguishable from "snapshot lost". The raw lane streams a forming-bar partial per folded trade; tiers serve the latest forming bar per tick. **Closes never conflate**: every elapsed interval closes — zero-trade intervals as a carried close (OHLC = previous close, volume `"0"`, count 0, provenance inherited) — released once the stream's consumed event-time frontier passes the bucket boundary, never wall clock: under catch-up closes arrive late, never wrong. After `closed: true` for a bucket no further update frame for that bucket arrives, and the close precedes any frame of the next bucket. Around the subscribe itself, snapshot and topic broadcasts may overlap — re-applying a final keyed on the bucket timestamp is a no-op, never desync. `seq`/`seqTs` are fact provenance (repeats legal, gaps meaningless): apply keyed on the bucket timestamp. * **Subscribe requests accept the object topic form** (`components.schemas.topicSelector`): `{channel, instrument, interval, throttle}`, exactly those keys, string-valued, normalized and echoed as the dotted string — one ack shape with the string form. An unknown key or a non-string value (e.g. a numeric throttle) answers op `error`/`malformed` rather than silently arming different semantics. The required top-level `instrument` key is what distinguishes a selector from a composite entry (`aggBookEntry`/`volBookEntry` carry `name`/`tier`/`sources` and never a top-level `instrument` — an object with one always parses as a selector). **Deploy window**: the old gateway answers every `candlestick.*` topic with a per-topic error, and mis-collects an object entry's inner strings as individual topic strings, answering the subscribe ack with per-topic errors echoing those fragments (never malformed). A probing client should treat an ack whose per-topic errors echo its object's field names/values as the old deploy and fall back to dotted strings. Bare literals (numbers, `true`, `null`) in a topics array are skipped without an answer on both sides of the window, unchanged. ## Composite sources — a contract multiplier of exactly 1 is no longer a unit mismatch **What changed** * **`agg_book`/`vol_book` sources whose `contractMultiplier` is exactly `1` are now accepted.** The rule was always about *units*: a source is refused when its quantities do not speak the base asset. A multiplier of one says one contract IS one unit of the base asset, so those quantities already are base units and nothing is being mixed. The check previously refused every stated multiplier, one included. * **Everything else still answers `unit_mismatch`**, unchanged: any multiplier other than one — `0.01`, `100`, whatever the venue states — is a genuine unit change, and conversion through the multiplier remains deferred. * No code, field, or frame shape changes: `unit_mismatch` stays in the error enum with the same meaning and the same `message` text, and an admitted source serves exactly like any other (scales echo, margins and MID normalisation apply, images render identically). **Who this unblocks**: reference data states `1` on rows other venues leave unstated — every OKX spot instrument carries it, as do linear perpetuals on several venues. Those instruments could not be used as a composite source at all; now they can. **Deploy window**: before the upgrade, a source with a stated multiplier of `1` answers `unit_mismatch` per topic and nothing arms; after, it validates and serves. Sources with a multiplier of one are the only behaviour that moves — `0` (unstated) sources and genuinely contract-sized sources behave identically on both sides, so a client whose subscriptions were succeeding before sees no change. A client that retried on `unit_mismatch` should expect those topics to start serving; one that treats `unit_mismatch` as permanent needs no change beyond re-subscribing to pick the instrument up. ## Composite books, phase D — `vol_book` volume ladders **What's new** * **The `vol_book` channel is served.** Subscribe with an object entry (`channel: "vol_book"`) carrying the same source vocabulary as `agg_book` (margins and MID normalisation included) plus the ladder: `pricingModel` (`VWAP` | `BEST` | `WORST`), `bucketSizes` (decimal strings at the resolved output `qtyScale` — positive, distinct by value, **any order**: the server sorts, the response ascends, and the optional positional `bidAdjBps`/`askAdjBps` pair with the sizes **as submitted**). Identity, `replace`, budgets, status frames, and reconnect behave exactly as `agg_book`; the session composite budget counts both channels together. * **Frames are full ladder images** (`event: "vol_book"`): ascending buckets, each present side an object of `cum` (the achieved cumulative quantity — the threshold when filled, the side's whole depth when it exhausts, with `filled: false` and every deeper bucket sharing that tail), `px` (the model price), `adjPx` (with the bucket's directional margin — bid down, ask up), and **`notional` — the exact swept cost at `priceScale + qtyScale`. It can exceed a 64-bit integer: parse it as the arbitrary-precision decimal string it is.** A side with no levels is absent (one-sided markets publish); `spreadBps` rides every bucket both sides price — partial fills included and the adjusted mid nonzero (a maximal bid margin can floor a sub-tick-scale price to an adjusted zero; the field is omitted rather than divided by zero) — over the **adjusted mid** at a fixed two-decimal scale, negative when independent venues cross. VWAP rounds to nearest with ties away from zero (an estimate, not a quote); `BEST`/`WORST` quote actual levels. * **`bad_ladder` is answered.** Present-but-wrong ladder values (an unknown model, a non-positive/duplicate/finer-than-scale size, more than `composites.max.buckets` buckets, an adj array that does not pair) answer `bad_ladder` per topic; an absent required field (`pricingModel`, `bucketSizes`) answers `malformed`, as does `maxDepth` on a `vol_book` entry (it is `agg_book` vocabulary — ladders walk as deep as their buckets need) or a ladder field on an `agg_book` entry. **Deploy window**: before the upgrade, any `vol_book` entry answers `unknown_channel` per topic (nothing arms); after, it validates and serves. `bad_ladder` was already in the documented code enum (bind-once). One `agg_book` edge tightens: a ladder field attached to an `agg_book` entry was silently ignored before (the fields did not exist) and now answers `malformed` — a schema-valid frame is unaffected, and everything else behaves identically on both sides. ## Composite books, phase C — MID normalisation **What's new** * **`normalisationMode: "MID"` is served.** A MID source (with the now-meaningful `normalisedAsset`) has its prices normalised through the internal index: the current (source-quote → target) rate multiplies each price **before margins**, rounding directionally (bids down, asks up — an adjustment never flatters a price). Rates are read fresh at every render, and a rate refresh itself marks the composite dirty exactly like a source-book tick — a standing image re-renders on the next tier tick when its rate moves, even while every source book is quiet. * **`fx_expired` is emitted.** A MID source whose pair has no current rate (absent, or older than the index's 30 s consumer window) leaves the merge and the topic says so — `status: "degraded"` with `reason: "fx_expired"`; a returning rate announces `recovered`. Expiry is watched on the gateway's health cadence (1 s by default), so the announcement lands within about a second even while every source book is quiet. * **`live` now also requires FX**: a composite acks `live` only when every enabled source book is synced *and* every required pair is current; otherwise `syncing`, kept by the first computable tick. * Validation: `normalisedAsset` is required with MID and must resolve to a known asset that differs from the composite's base asset and from the source's own quote asset — each violation answers `bad_normalisation`. The touch modes stay reserved and answer the same code. * Normalisation never changes a topic's scale identity: output `priceScale`/`qtyScale` stay the max over the configured sources, exactly as phase B advertised them. **Deploy window**: before the upgrade, any `normalisationMode: "MID"` entry answers `bad_normalisation` per topic (nothing arms); after, it validates and serves. Nothing else changes shape: `fx_expired` was already in the documented reason enum (bind-once), and NONE-mode composites behave identically on both sides. ## Composite books, phase B — `agg_book` **What's new** * **Object-form subscribe entries.** `params.topics` now accepts objects beside strings. An object entry subscribes a client-configured composite: `channel` (a required const — `agg_book`), `name` (your handle, session-scoped, 1–64 of `[A-Za-z0-9._-]`), `tier` (required — composites are always conflated), optional `maxDepth`, and 1–16 `sources` (canonical symbols, optional per-side `bidMarginBps`/`askMarginBps`, optional `disabled`). All sources must share one base asset; contract-sized derivatives are rejected (`unit_mismatch`) in v1. * **The composite ack row.** `live`/`syncing` rows echo the resolved output `priceScale`/`qtyScale` — they bound every number the topic can carry (bind your parser's precision once; a `replace` that only toggles `disabled` never changes them). Error rows carry a **closed `code`** (see the AsyncAPI enum) plus a human `message` you should not parse. `live` means every enabled source book is synced and the full image pushes immediately; `syncing` promises the first image at the first tick the composite becomes computable. * **`agg_book` data frames** are full images at each tier tick: idempotent, no chain semantics, both sides always present (`[]` when empty — one-sided markets publish). Levels are **objects** — `px`/`qty` at the output scales plus `src` per-venue attribution (venue-native raw prices at each venue's own scales). `ts` is the newest included source's `exchangeTsNs`; `seq`/`seqTs` are stream-position provenance, not a per-topic chain. * **Degradation is explicit.** A source whose book cannot be served leaves the merge and a status frame says so (`status: "degraded"`, `excluded: [{s, reason}]`); recovery announces itself (`status: "recovered"`). All enabled sources out → one `status: "stale"` marker, then silence, then the healing image. The `reason` enum is complete in the contract (`stale`, `fx_expired`, `delisted`, `disabled`) so you bind once; this release serves `stale` — `fx_expired` arrives with FX normalisation. * **Reconfigure with `"replace": true`** on a live `(name, tier)`: the swap is atomic (no unsubscribe, no gap, no `syncing` flash while sources stay synced), a failing replace answers its error code and leaves the old config serving, and an identical re-subscribe (your reconnect replay) is an idempotent re-ack. A different config *without* the flag answers `name_in_use` — reconfiguration is never an accident. The tier is topic identity: changing it is unsubscribe + resubscribe. * **Unsubscribe by the composed topic string alone** (`agg_book..`, byte-identical to the echo in acks and frames) — the config is never re-sent. * Budgets: at most 16 composites per session, 16 sources per composite, depth ≤ 50 (deployment-configurable). Over-budget subscribes answer `budget_exceeded` per topic. * Reserved, not yet served: `normalisationMode: "MID"` (+ `normalisedAsset`) answers `bad_normalisation` until MID normalisation is served (phase C, above); `vol_book` entries answer `unknown_channel` until volume ladders are (phase D). **Deploy window** (a not-yet-upgraded gateway vs an upgraded one): * **Do not mix object entries into frames an old gateway might serve.** The old parser reads the topics array as loose quoted strings with no nesting awareness: an object entry comes back as a flood of spurious per-string `error` rows (its keys and values each read as a "topic", so the ack can carry MORE rows than entries you sent, breaking order-correlation for that frame), and collection **stops at the first `]` the parser meets — the entry's own `sources` array's** — so every entry after the object in the same frame, plain string topics included, is silently dropped: never subscribed, never answered. A client-chosen `name` that happens to look like a servable topic string could even be *subscribed* by the old parser. During the window: send composites in their own subscribe frame, after your string subscriptions, and treat "no ack row matching my composed `agg_book..` topic" as "old gateway — retry later", never as an error. * On the upgraded gateway, string-topic behaviour is unchanged, including acks, all existing channels, and unsubscribe. The ack row's new fields (`priceScale`/`qtyScale`/`code`/`message`) appear only on composite rows, so existing parsers are unaffected. * A bare-string subscribe of a composite topic (`"agg_book.X.250ms"` as a string entry) answers `error` on both sides of the window: the config is the subscription.