Skip to navigation

Trading WebSocket changelog

Consumer-facing history of the trading edge’s WebSocket contract (asyncapi.yaml is the contract itself; 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, headed by the info.version it lands with.

How to get this document. It ships in every release’s contract bundle — immix-contracts-<tag>.tar.gz, attached to the release with its .sha256 — as trading-ws/asyncapi.yaml beside this file, with manifest.json naming the version and a digest per file. info.version and the sections below say what the contract is.

1.1.1 — every boolean constant states its type

The contract’s boolean constants now state their type: success on every acknowledgement and refusal, isSnapshot on every snapshot part, delta and orderRejected, and reduceOnly on submitOrder. 1.1.0 gave their values (const: true, const: false) without type: boolean, and a generator that reads an untyped constant as a string builds a client that expects a string where the frame carries a JSON boolean. Such a client parses no acknowledgement, refusal, snapshot or delta. 1.1.1 states type: boolean beside each value. Nothing else in the document changes.

Both sides of the deploy window. The wire does not change: a member on 1.1.0 and a member on 1.1.1 send byte-identical frames, carrying exactly the values 1.1.0 documented. A client generated from 1.1.1 types these fields as booleans. A client generated from 1.1.0 keeps whatever its generator made of them; if it made strings, regenerate from 1.1.1.

1.1.0 — the door

submitOrder and cancelOrder are served. 1.0.0 published them as prose and answered UNKNOWN_OP; this version publishes their messages and answers them for real.

Both sides of the deploy window. A client generated against 1.0.0 keeps working unchanged: nothing it reads moved, and the six channels’ row schemas are byte-identical. A client that starts sending submitOrder sees UNKNOWN_OP from a member still on 1.0.0 — which is the same answer 1.0.0 documented — and a real answer from one on 1.1.0. There is no frame a 1.0.0 client can receive from a 1.1.0 member that it could not receive before, except on the order channel: see the msgType note below.

What is new

  • submitOrder → submitOrderAck. A success is an offer, not an acceptance: the command reached the stream, and nothing about admission is known yet. There is no orderId on the ack and there cannot be — the owner mints one when it admits the command. Correlate on the clientOrderId you chose.
  • cancelOrder → cancelOrderAck, echoing the orderId this edge resolved. orderId is null when you named a clientOrderId whose order has not reached this member yet; the owner resolves it from its own pending book. A null is not a failure.
  • orderRejected on the order channel — the owner’s refusal, carrying reason, message, the observedValue/limitValue detail pair and the unit that pair is measured in. Read the pair’s own nullness: whether a refusal states one is a property of the check that fired, not of the reason, and the same reason can arrive bare on one refusal and with two numbers on the next (a connection refused hard states nothing; one refused for exceeding its degraded grace states the duration and the grace, in NS). unit is null whenever the pair is null, and also for INVALID_FIELD, whose checks measure a price on one path and a quantity on another — take a null unit beside a stated pair as unlabelled, not as a default unit.

Two things to change in your client

  1. Switch on msgType on the order channel. It now carries two shapes: orderUpdate (the applied image, as before) and orderRejected. Both are event: order / topic: order because they are one subscription. A client that upserts every order frame into its blotter map without reading msgType will insert refusals as if they were orders. msgType was a required const in 1.0.0 for exactly this moment.
  2. Do not key a map on orderRejected. It is never in a snapshot — isSnapshot is pinned false on that message — because a refusal is a fact no fold holds: nothing was minted, so there is no row to retain or restore. Refusals are live-only. A blotter rebuilt on last: true will not get them back, and that is the contract, not a gap.

Money and ids on the way in

  • price and qty are decimal strings, exact at the instrument’s own scale. A value that does not fit that scale exactly is refused, never rounded: submitting a quantity you did not write is worse than refusing the one you did. Trailing zeros are free — "0.0350" and "0.035" are the same number at scale 4.

  • cancelOrder.orderId is a decimal string, the same form this API renders it in. A JSON number is refused. An int64 does not survive a JSON number in a JavaScript client, which is why orderRow.orderId has always been type: string; accepting a number on the way in would have made the wire asymmetric in the one direction where the round-trip matters.

  • Name the account by account or by accountId, and the instrument by instrument or by instrumentId — at least one of each pair, and both stated must agree. Sending both is fine, and is what an interface holding the id it resolved and the name a trader typed will do; only a disagreement refuses (MALFORMED). The HTTP lane states the identical rule for the identical body, for both pairs.

    Prefer the ids where you hold them. A symbol is reference data’s to change and an id is not, so a rename between your read and your submit turns a symbol into UNKNOWN_INSTRUMENT while the id still resolves.

  • Sizing and precision come from reference data, and that API is not published yet. price is exact at the instrument’s priceScale and qty at its qtyScale, and the owner further refuses TICK_SIZE_VIOLATION, QTY_STEP_VIOLATION, QTY_BOUND_BREACH and MIN_NOTIONAL_BREACH against its tick, step, bounds and minimum notional. Those live on the refdata instrument row — and a scale is not a tick: a venue’s tick can be coarser than the scale, so scales alone will not tell you whether a price is placeable. A reference-data API serving that row is planned and not yet available; until it ships you cannot pre-validate sizing, and a miss costs a round trip whose refusal names the observed value against the limit it breached. The contract will name that endpoint once it exists.

  • The schema now states the rules the door enforces, so a generated validator agrees with the server: price and qty carry the decimal-string pattern, cancelOrder.orderId and instrumentId carry theirs, price is required for LIMIT and refused on MARKET as a oneOf, and reduceOnly is const: false because v1 refuses it outright. Nothing the server accepted before is refused now — these describe behaviour 1.1.0 already had.

  • Name the order by orderId or by clientOrderId, exactly one. This one really is either/or: neither is refused because nothing is addressed, and both is refused too, because unlike the account pair there is no lookup that could tell you they agree.

Your idempotency key, and how to recover an outcome you never saw

  • clientOrderId deduplicates against (organization, clientOrderId) for the platform’s whole retention window — not merely while the order is live. Re-sending a submit under a key you already used restates the existing order’s outcome and never places a second order.
  • CLIENT_ORDER_ID_CONFLICT does not mean “you reused your own key.” It means another user of your organization holds that key. (1.1.0’s first cut said “already in use by a live order” — that text was wrong on both counts and is corrected.)
  • So the recovery is: re-send the identical submit under the same clientOrderId. If the socket drops after an ack and the order is absent from the order image — which reads the same for “refused” and “not folded here yet” — re-sending is safe and is the procedure. Minting a new key instead is what risks two orders.
  • A refusal answer to a write guarantees nothing reached the stream, so re-sending after one is always safe.
  • Correlate on command, not on clientOrderId alone. A refused cancel arrives as orderRejected carrying the order’s clientOrderId — the same key a refused submit carries — so matching on the key alone shows a cancel’s refusal as though the order was never placed. A cancel can also fail a second way: the venue rejects it, which arrives as an orderUpdate with transition: CANCEL_REJECTED, not as an orderRejected.

What a subscription delivers, and what it is needed for

  • order carries the ORGANIZATION’s orders — every principal’s, and the HTTP lane’s as well as this one. Filter to what you sent before you show a refusal to a trader.
  • Subscribe to order before you write. A session that writes without holding it is acked and then never told what happened; the ack says the command reached the stream and nothing more.
  • A write’s ack precedes its outcome, so you never see an outcome for a command you have not seen acknowledged.

The refusal codes the door needs

INVALID_FIELD, INVALID_PRICE, INVALID_QTY, INVALID_CLIENT_ORDER_ID, UNKNOWN_INSTRUMENT, UNKNOWN_ACCOUNT, UNKNOWN_ORDER, NOT_SERVING, OWNER_ABSENT and BACKPRESSURE join refusalCode in this version, beside the ops that write. They are the only additions to that enum, and a client generated against 1.0.0 meets them the first time it sends a write — which is why the enum has always said to tolerate a value it does not know and to read retryable instead.

One of the ten is reserved: OWNER_ABSENT is published but not emitted by 1.1.0. When the orders owner is not established, a write refuses NOT_SERVING in this version. Handle OWNER_ABSENT as you would any other retryable refusal so that nothing has to change when a later version starts sending it, but do not expect to observe it yet.

A NOT_SERVING or BACKPRESSURE refusal is retryable: true and is a statement about this member, never about your capabilities: retry, or reconnect to another member.

1.0.0 — the read edge

The first version. One WebSocket session on which an organization’s principals watch their trading state: authenticate, subscribe to any of six channels, receive a complete image and then every change to it.

This version is read-only, and says so. There is no write op in the document and none is served: a frame naming one is answered UNKNOWN_OP, like any other op this build does not have. An earlier draft of this document claimed submitOrder and cancelOrder were “documented here but not yet served” while carrying neither — a sentence a client could act on and nothing could deliver. Order entry lands in 1.1.0, with its envelopes published before it is served.

What this version states that a client must read

The behaviour below was always the behaviour; none of it changed. It was in the platform’s plans rather than in the contract, which meant a consumer had to derive it — and a derived store model is the class of bug this document exists to prevent.

  • Per channel: what the image holds, and how a row leaves it. Each of the six messages now states its snapshot scope, its retention, and the signal that a row is gone. The answers differ and the differences matter: order serves every live order whatever its age plus terminal orders inside a served window (24 h by default); execution has no window and follows its orders; accountBalance, account and credential never remove a row — an emptied pool is reported as zero, and a retired account or a revoked credential is served with its status. Where a row can leave (order, execution, by the platform’s own retention), the release rides no wire: there is no tombstone, and a resync is how a client learns.
  • Ordering. A subscribe ack precedes the first part of every topic it named; a topic’s parts are contiguous, with no delta of that topic between them; every delta that follows carries a seq at or past the part’s. Topics named in one subscribe are imaged in the order named. Across topics nothing is guaranteed and nothing needs to be.
  • The inbound budget and the heartbeat. 50 frames per second refuses, 500 closes with 4004, both over a tumbling one-second window per session; a refused frame is applied to nothing and answers on its own op. The heartbeat is every 5 seconds by default, and streamAgeMs is how long ago this member applied a platform fact — pick a dead-socket timeout from the interval, never from that number.
  • additionalProperties: false is about the producer, not you. It is how this gateway’s tests refuse to serve an undocumented field. Ignore properties you do not know: fields are added in minors and a client that refuses one will break on the first.

orderRow gains message — the platform’s words for its reason

reason is a stable enum name, and for the reasons the platform decides — OWNER_TIMEOUT, INSTRUCTION_FRESHNESS_EXPIRED, RECONCILE_PROOF — no venue ever said anything, so venueText was null and a client had no sentence to show a human. The only route to one was a code-to-text table of its own: the hand-held table v2.0.0 removed, arriving back through the side door.

message is the platform’s voice; venueText is the venue’s. They are not alternatives, and a client shows both:

the row saysmessagevenueText
reason: OWNER_TIMEOUT”the platform gave up waiting for the venue to answer”null — no venue spoke
reason: VENUE_REJECTED”the venue rejected this order""insufficient margin”
no reason (the ordinary path)nullnull

Render message, then venueText after it when present. A rule that rendered one instead of the other would hide the informative half exactly where it mattered most — on VENUE_REJECTED, where the venue’s own words are the whole point.

Nothing on the platform’s wire moved: message is derived at this edge from the reason the row already carries, from a table that is exhaustive by construction — a reason appended to the platform’s schema fails our build until someone writes what it says. It is display only: branch on reason, never on this string, which may be reworded in any release.

Three vocabularies are published instead of promised

  • exchange is one $ref shared by accountRow, credentialRow and venueCapabilityRow, so the whole-string joins an order form makes are sound by construction. It is deliberately not a closed enum — the platform lists a venue by carrying a reference-data row, not by cutting a release, so a closed enum would make every new listing a breaking change. Today’s members are published as examples. The market-type split is part of the name: binance_spot and binance_usd-m are two venues, not one with an attribute.
  • capabilities on the auth ack is the HTTP lane’s seventeen scope strings, published as an enum and held equal to the platform’s own by a lockstep test. Append-only: treat an unknown scope as a grant you cannot use, never as an error.
  • instrumentId is one $ref across orderRow and executionRow, and the description answers the cutover question outright: it is the platform’s own reference-data key, an int64 as a decimal string, and it does not equal an immix-api v2 Instrument.id. Join through instrument, the platform symbol, once.

A snapshot part and a delta are two named shapes

Each of the six topic messages is a oneOf of <channel>SnapshotPart and <channel>Delta, discriminated by a const on isSnapshot. The part requires snapshotId, part and last; the delta does not carry them at all — not as optional fields, not at all.

A generated client gets two types to switch on instead of one with three optionals to null-check. Named variants rather than a JSON-Schema conditional deliberately: if/then is stripped before generation by the toolchain the consumer generates with, so a conditional would reach their types as exactly the loose optionals it was meant to remove. submitOrder.params is built the same way, for the same reason.

No frame changed by this.

What a client does

  1. op: auth with a bearer token. The answer carries the grant, not the grantee: capabilities and expiresAtNs, with no identifier in it. Read your own identity from the HTTP lane’s GET /me.
  2. op: subscribe with topic names from the closed set. The ack arrives first, then a complete image as numbered snapshot parts.
  3. Upsert by key into a map that part: 1 cleared. Snapshot parts and deltas are the same shape and are applied the same way; the image is complete at last: true.

A repeat subscribe for a held topic re-pushes the image — that is the resync, and there is no separate op for it.

The v2 → v3 map

For immix-control-centre, which vendors the v2 spec at 5.0.0. The things it isolates in one place (channel names, topic shape, store keys) change freely; the things it depends on structurally (the {op, reqId, params} envelope, the auth flow, string numerics, account and instrument display names on rows) are kept; the things it works around (snapshot chunking, payload sniffing, flat frames, uncorrelated errors) are fixed so the workaround is deleted.

v2v3What it costs the client
{op, params:{token}, reqId} authunchanged; the answer adds session{capabilities, expiresAtNs}none — identity comes from GET /me, never from the ack
topics as {channel, account} objects or dotted stringsbare channel-name strings from a closed set; no account or bookbuildApiTopic returns a string; the per-account subscription managers collapse to one subscribe per channel
op-less errorEvent (+ 429/503 on the op)every failure is an op-response with code + retryable; op: "error" only for a frame whose op could not be readthe errorEvent bridge is deleted; add retryable-driven backoff, which is the 429 handling that was missing
integer code (open) + messagestring RefusalCode (closed, tolerated-unknown) + message + retryablekey on BACKPRESSURE rather than 503
order: orderUpdate / orderCancelReject / orderAmendReject, msgType sniffedorder: orderUpdate / orderRejected, msgType required on every messagedelete the payload heuristics. v2’s single orderCancelReject splits in two, because v2 conflated two events: the platform refusing the cancel command (unknown or already-terminal order) arrives as orderRejected command: CANCEL, correlated by orderId or by the clientOrderId the cancel named; the venue refusing a cancel already instructed arrives as an orderUpdate with cancelRequested: false and transition: CANCEL_REJECTED, folded into the row it describes, so applying a status stays a plain upsert
fillsexecution, rows keyed executionIdrename the channel; row key fillId → executionId; the fill-echo fields on the order row are gone — join by orderId
externalBalance (locked)accountBalance (held)rename; column locked → held
accountStatus (accounts + connection status in one)account + credential, two channelsthe directory reads account, health reads credential, joined by credentialId
venueCapability nested per classvenueCapability flat rows, timeInForce as the accepted setnone — the order form can finally read it
gatewayTimeNs, keyIdseq, seqTsnone (declared, never read)
snapshot: one frame, silently split past a capisSnapshot + part + lastdelete the two defect docblocks; accumulate until last
PENDING_NEW / NEW / PENDING_CANCELED / …the platform’s status lattice plus live, terminal, cancelRequestedTERMINAL_STATUSES becomes terminal; the PENDING_* docblocks go
rejectReason (100+ values) + rejectSource + rejectMessage on the order rowadmission refusals are orderRejected; venue refusals are orderUpdate{reason, venueCode, venueText}read message/venueText; no client-side reject registry
unsolicited: true ordersexecution with orderId: nullthe “unknown order” rendering goes; a discovered-execution row instead
internalBalance, accountRisk, bookRisk, externalPositionreserved or absentno v3 target in this version — see Reserved names below
executionOrder, strategy, bookName, TWAP/PEG/ICE paramsabsentno v3 target until the algo platform; the flat-frame router branch is deleted

Money is served at the scale each value states

Every money value is a decimal string rendered from the scale the value itself carries, not from reference data. Parse it as a decimal and compare numerically.

Do not read meaning into the digit count. The same amount recorded at different precisions serves different strings — "2.5" and "2.500000" are the same number — because each states the precision its own fact carried. The string depends on the value alone, so it does not change as reference data catches up, and the HTTP lane serves the identical string for the same value.

Reserved names

position, ledgerBalance, transfer and reconciliation are real names whose domains have not landed. Subscribing to one answers TOPIC_NOT_ACTIVE, which is deliberately different from UNKNOWN_TOPIC: the first means wait for a release, the second means fix your spelling. They are activated additively, with a minor version bump and a section here.

Known gap in this version

execution.feeUsd is always null. The fee’s USD conversion needs signed wide-integer rendering that is not in place yet; null is inside the field’s contract — it never means the un-converted amount — so a client that treats null as “no value” is already correct and will keep working when the number arrives. notionalUsd and fxSource are populated.

Session lifetime

A session is closed when its credential lapses, when the register withdraws the principal’s membership, or when it floods the inbound budget. A sessionClosing frame naming the reason precedes the close, and sessionExpiring warns one lead time before a lapse so a client can re-authenticate over the open socket and keep its subscriptions.

The reason arrives twice, on purpose. The sessionClosing frame carries it with the retryable flag; the WebSocket close frame that follows carries the same number as its close code (RFC 6455’s private-use range — 4001 SESSION_EXPIRED, 4002 AUTH_FAILED, 4003 PRINCIPAL_REVOKED, 4004 RATE_LIMITED). Read whichever suits your client. A browser reads event.code off the close event whether or not it processed the last message, so a client that only ever sees the close still knows to mint a fresh credential rather than retry the same one — which is the difference between recovering and reconnect-looping.

The identity is immutable for the life of the socket: re-authenticating with a credential for a different principal is refused, and the session keeps the one it was opened with.