Skip to content

Combo Negotiation Channels

Topics: rfq:{user_id} for the requester, combo_quote:{user_id} for the maker

The channels a combo is negotiated over. A requester asks for a price on a set of legs, makers quote, the requester accepts one, and the trade binds. Each side has its own channel. Every command is a message on your own topic, and every answer is a reply to it or a push to the same topic.

Read Combos first for the model: roles, exact-price accepts, shortfall, maker exposure and settlement. This page is the protocol.

Sign the socket. Connect with the same X-STX-ACCESS-* headers as REST, signing GET and the path /socket/websocket, and send a User-Agent header. See Sign the socket, then join what you need and Authentication. An unsigned socket cannot join these channels.

Use a read_write key. Every command here ends in an order, so both topics take the same key scope as POST /api/v1/orders. A read_only key cannot join them: the join is refused with This API key does not have write access. A read_only key still joins the read-only channels, such as positions and fills. A browser session is not an API key and joins normally.

Join the topic for your side. {user_id} is your user id from GET /api/v1/me.

Side Topic Commands Pushes
Requester rfq:{user_id} request_quote, cash_out, accept, confirm, cancel, snapshot best_quote, bound, outcome_unknown, reconfirm_required, outcome_unresolved
Maker combo_quote:{user_id} quote, withdraw_quote bound, negotiation_closed

geo_location works on both. Every other command exists on one topic only, and sending it to the other answers {"reason":"unknown_command","command":"<name>"}. A requester’s topic carries only their own negotiations, and a maker’s carries only the fate of their own quotes, so neither side ever sees anyone else’s prices. One account can join both topics, to request on one combo and quote on another.

["1","1","rfq:<user_id>","phx_join",{}]
["2","2","combo_quote:<user_id>","phx_join",{}]

A successful join replies with your account’s external_id and the deadline for your next geolocation check, or null when nothing more is asked of this connection:

["1","1","rfq:<user_id>","phx_reply",{"status":"ok","response":{"external_id":"<uuid>","next_geolocation_at":null}}]

external_id is the id a counterparty knows your account by. No command carries it: the socket already says who you are. It travels one way: makers are shown the requester’s, so a maker can keep its own list of takers it will not price. A requester never sees a maker’s, and a maker never sees another maker’s.

Join refused with reason Cause
unauthorized The topic is not your own user id, or the socket is not signed
This API key does not have write access The socket is signed with a read_only key
Invalid user - this action is not allowed. Your user could not be read
This action is not allowed for Test user accounts. The account is a test account
Your account status prohibits this action. The account is suspended, closed or otherwise barred from trading
a geolocation message The geolocation check failed; the message says why

Geolocation applies only where the environment requires it. next_geolocation_at on the join reply tells you: a timestamp is a deadline to meet on this socket, and null means nothing more is asked of the connection, either because the environment does not check or because you are whitelisted. Branch on that field.

Where it applies, send your geolocation packet on the join as geo_location:

["1","1","rfq:<user_id>","phx_join",{"geo_location":"<packet>"}]

If your server’s IP address is whitelisted on your account, send nothing. That is the usual setup for a programmatic client.

The check is made once per connection, not per command, and it expires at next_geolocation_at. Renew it on the open socket rather than reconnecting, because reconnecting cancels your quotes. Either topic takes the renewal, and it covers both:

["1","9","rfq:<user_id>","geo_location",{"geo_location":"<packet>"}]

The reply carries the next deadline. Once a check has lapsed, request_quote, cash_out, quote, accept and confirm are refused with Your geo location check has expired. Reconnect to continue. until you renew it. withdraw_quote, cancel and snapshot still work, so you can always wind a negotiation down. Quotes you already hold are not cancelled by a lapse.

A join or geo_location payload that is not a JSON object is refused with malformed payload, whether or not geolocation applies.

  • A maker’s quotes are cancelled when the connection that placed them closes. Each quote belongs to its connection, not to the account, so a maker on two sockets loses only the quotes from the one that dropped. A quote holds no funds, so this costs the prices and nothing else. Quote again after you reconnect.
  • A requester’s open request is cancelled when their connection closes, as if they had sent cancel. Every maker holding a quote on it is pushed negotiation_closed with reason cancelled. Logging out of a browser session closes its socket and so ends the request too; an API key connection has no session to log out of. The one exception is a request frozen on an unknown outcome, which is left to resolve: read your positions and fills.
  • A quote already inside an acceptance can still bind after its maker disconnects. The maker then misses the bound push. After any disconnect, take your position from the positions and fills channels, not from silence here.

Money and quantities are strings, as on the other channels (see Wire format). Nothing on these channels uses integer cents.

Kind Server to client Client to server
Money At least 4 decimal places, "0.1200" A stake is whole cents at most: "250.00" and "250" both parse, "250.001" is refused. A price is a whole multiple of the 0.0001 tick, unless you send adjust_to_nearest_tick.
Quantity Whole contracts, 2 decimal places, "2083.00" Never sent
Timestamp Integer microseconds since the Unix epoch Never sent. A quote’s lifetime is expires_in_ms, an integer of milliseconds.
Id UUID string UUID string

"Infinity" and "NaN" are refused as malformed_money.

Every command except request_quote and cash_out names the negotiation by both market_id and rfq_id. request_quote and cash_out return both. A negotiation that has ended, for any reason, answers not_found and does not say how it ended.

No command names your account or user. A payload that carries external_id, account_id or user_id anyway is ignored, and the command acts on your own account.

A payload must be a JSON object. Anything else answers malformed_payload. An event name the topic does not know, including a command that belongs to the other topic, answers {"reason":"unknown_command","command":"<name>"}.

outcome_unknown on quote, withdraw_quote, cancel and snapshot

Section titled “outcome_unknown on quote, withdraw_quote, cancel and snapshot”

These four can answer outcome_unknown when the negotiation is too busy to answer within 5 seconds, typically while a bind on the same combo is running. It means only that the answer was lost, not that the command was refused. The command still runs afterwards:

  • quote: your quote may have been recorded. A blind retry can be refused with quote_limit_reached by the quote that landed.
  • withdraw_quote and cancel: retrying is safe. A quote already gone answers unknown_quote, and an ended negotiation answers not_found.
  • snapshot: retry.

On accept and confirm, outcome_unknown means something else: see When the outcome is unknown.

On rfq:{user_id}.

Opens a negotiation to buy a combo.

Field Type Required Notes
legs array yes 2 to 12 legs
legs[].market_id UUID string yes A market that is open right now
legs[].outcome "won" or "lost" yes What the combo requires of that leg
stake money string yes The total to spend, not per contract. Greater than 0, whole cents, at most 100000.00

There is no expiry field. A request lasts 5 minutes.

Reply: {"rfq_id": "<uuid>", "market_id": "<uuid>"}.

The same legs with the same outcomes always return the same market_id, whatever their order.

Refusals, in the order they are checked:

reason Cause What to do
combo_minimum_two_markets_needed legs missing, not an array, or fewer than 2 Send at least two legs
malformed_leg A leg is not an object Fix the payload
malformed_id A leg’s market_id is not a UUID string Fix the payload
unknown_outcome A leg’s outcome is not "won" or "lost" Fix the payload
malformed_money stake missing, not a string, or not a number Fix the payload
non_positive_stake stake is 0 or less Send a positive stake
sub_cent_stake stake is finer than a cent, such as "125.2222" Round to cents
stake_above_max stake is above 100000.00 Stake less
insufficient_assets Your available balance is below what the bind will require, which is at least the stake (see below) Deposit, or stake less
account_unreachable Your balance could not be checked Retry. This says nothing about your balance.
loss_limit24_hours, loss_limit_weekly, loss_limit_monthly A loss limit will not carry this request Wait, or raise the limit
rfq_limit_reached Your account already has 2 negotiations open Finish or cancel one first
combo_too_many_markets More than 12 legs Send fewer
unknown_leg A leg’s market_id is not a market Check the id
duplicate_legs The same market appears twice One leg per market
nested_combo A leg is itself a combo Combos do not nest
leg_not_quotable A leg is not open, including one that is scheduled or not yet trading Every leg must be open when you request
market_not_quotable This combo has itself closed, cancelled, resulted or voided This combo cannot be traded again
persistent_market_unreadable The combo exists but could not be read Retry; contact support if it persists
request_not_started The negotiation could not be started Retry

Only the first failure is reported, so a request wrong in two ways is told about the earlier one in this table. Your account is checked before the legs are resolved, so a request you cannot fund is refused insufficient_assets (and one over the limit rfq_limit_reached) whatever else is wrong with it.

Show insufficient_assets and account_unreachable to a user differently. Only the first is about money.

The balance check asks for what the bind will require. That is the stake plus the request fee, each rounded up to the cent, with the fee taken at the lowest possible price because no maker has quoted yet. Where your fee schedule charges per trade, the figure is more than the stake, so a "250.00" stake can be refused on a balance of exactly 250.00. Where fees are charged at settlement instead, the figure is exactly the stake. A request that passes this check cannot then be refused at the bind for the fee.

A dropped channel is not a refusal. During a deploy, request_quote and cash_out can end with phx_error on the topic and no reply. The request may still have been opened, so do not assume nothing happened. Rejoin; quotes on that connection are gone, and an orphaned request expires after 5 minutes.

An account can have at most 2 negotiations open at once, counting cash-outs.

On rfq:{user_id}.

Opens a negotiation to sell your whole position in a combo you hold. Makers then quote to buy it, and you accept as on any request.

Field Type Required Notes
market_id UUID string yes The combo you hold

Reply: {"rfq_id": "<uuid>", "market_id": "<uuid>", "quantity": "2083.00"}.

You do not name a size. The whole position is sold, and quantity tells you what that is. There is no partial exit.

reason Cause What to do
malformed_id market_id is not a UUID string Fix the payload
nothing_to_sell You hold no long position in that combo Nothing to cash out
insufficient_assets Your available balance cannot cover the fee on selling this position Deposit, then cash out
account_unreachable Your balance could not be checked Retry. This says nothing about your balance.
rfq_limit_reached Your account already has 2 negotiations open Finish or cancel one first
leg_not_quotable A leg of the combo is no longer open There is no exit from here; the position runs to settlement
unknown_combination No combo has that market_id Check the id
exit_already_open You already have a cash-out open on that combo Finish or cancel it first

A cash-out still pays a fee. Selling a position you hold owes no collateral, but the sale is charged the usual fee, so you need enough available balance to cover it. No maker has bid yet, so the check uses the price at which the fee is largest, half the ceiling. A cash-out that passes it cannot be refused for the fee at any price a maker bids.

A cash-out closes when the first leg does. Every leg must still be open, when you cash out and again at the bind. Once any leg results, nobody holding that combo can cash out, and positions run to settlement.

The size is fixed when the cash-out opens. If your position changes before you accept, the accept can fail.

On combo_quote:{user_id}.

Offers a price on an open request or cash-out.

Field Type Required Notes
market_id UUID string yes From the requester, with the rfq_id
rfq_id UUID string yes From the requester
price money string yes Per contract. Greater than 0, less than the combo’s max_price of 1.0000, a whole multiple of 0.0001, and on a request no more than the stake
adjust_to_nearest_tick boolean no true snaps an off-grid price to the nearest tick, halves rounding up, instead of refusing it. Default false.
expires_in_ms positive integer yes How long the quote stands, in milliseconds
client_order_id string or null no Copied onto your order if the quote binds. At most 255 bytes.

Reply: {"quote_id": "<uuid>", "price": "0.1437", "external_id": "<uuid>"}. external_id is the requester’s, the same for every maker on the request; if you will not trade with that taker, withdraw_quote now. price is the price as recorded, which differs from the one you sent when adjust_to_nearest_tick moved it. The snap happens before the bounds are checked, so a price that rounds to 0 or to 1.0000 is refused.

reason Cause What to do
malformed_id market_id or rfq_id is not a UUID string Fix the payload
malformed_money price missing or not a number Fix the payload
malformed_client_order_id Not a string, or longer than 255 bytes Shorten it
expires_at_required expires_in_ms missing, not an integer, or not positive Send a duration
not_found The negotiation has ended Stop quoting it
cannot_quote_own_request The request was opened by your own account Quote someone else’s
quote_limit_reached You already have a live quote on this request withdraw_quote it first, or wait for it to expire
non_positive_price price is 0 or less Send a real price
price_above_max price is at or above 1.0000 Quote below the ceiling
price_above_stake price is more than the whole stake The stake cannot buy one contract at that price
price_off_grid price is not a whole multiple of 0.0001 Round it, or send adjust_to_nearest_tick
account_unreachable Your account could not be checked Retry. This says nothing about your limits.
loss_limit24_hours, loss_limit_weekly, loss_limit_monthly A loss limit will not carry this quote’s exposure Wait, or raise the limit

One live quote per request. There is no amend. To move your price, withdraw_quote and then quote again. A quote that has expired or been withdrawn frees the slot.

A quote holds no funds. Your loss limits are checked when you quote; your balance is not. Instead, a quote your available balance cannot fund at the bind is dropped without notice, the moment it is placed or whenever your balance moves, so a quote_id reply does not prove the quote is still standing. The check uses the same figure as the bind, so a quote that survives it will not be refused at the bind for funds.

You are not told when your quote expires. Treat it as gone once expires_in_ms has passed.

On combo_quote:{user_id}.

Removes one of your own quotes.

Field Type Required
market_id UUID string yes
rfq_id UUID string yes
quote_id UUID string yes

Reply: {"withdrawn": "<quote_id>"}.

reason Cause
malformed_id market_id, rfq_id or quote_id is not a UUID string
not_found The negotiation has ended
unknown_quote You have no quote on this negotiation with that id: it expired, was already withdrawn or bound, or is not yours. These are deliberately one answer.
outcome_unknown No answer in time; see above

On rfq:{user_id}.

Accepts a price and binds the trade.

Field Type Required
market_id UUID string yes
rfq_id UUID string yes
quote_id UUID string yes, the quote you were shown
price money string yes, the price you were shown, from the best_quote push or snapshot you are acting on

The accept fills at exactly price or not at all. If the live best is any other price, higher or lower, nothing is placed and the accept is refused with price_moved. The refusal carries the new best, in the same fields as a best_quote push, so you can show it and accept again:

{"reason": "price_moved", "quote_id": "<uuid>", "price": "0.1200", "expires_at": 1790000140000000}

With nothing live, those three fields are null; wait for a best_quote push. A better price is refused too, so every fill is at a price the requester saw. Expect price_moved in normal running: best_quote is pushed on a cadence, so a quote can take the top while the requester is deciding.

quote_id does not pick the counterparty. Any maker quoting the same price can fill the accept, so a quote that was withdrawn or cancelled still fills if another maker stands at its price. See Accepting names an exact price.

Reply:

{"market_id": "<uuid>", "quantity": "2083.00", "price": "0.1200", "charged": "249.9600", "shortfall": "0.0400"}
Field Meaning
market_id The combo’s market, now a real market
quantity Contracts traded. On a request, the most whole contracts the stake buys at price. On a cash-out, your whole position.
price Always the price you sent
charged quantity × price. What you paid on a request; what you receive on a cash-out.
shortfall stake − charged on a request. Always "0.0000" on a cash-out.

The reply does not say which maker filled. It may not be the one quote_id named.

reason Cause What to do
malformed_id market_id, rfq_id or quote_id is not a UUID string Fix the payload
not_found The negotiation has ended, including one an earlier accept already bound Do not retry
not_the_requester You did not open this negotiation Only the requester accepts
malformed_money price is missing, not a string, or not a number Fix the payload
price_moved The live best is not the price you sent The request stays open and nothing was placed. Show the best the refusal carries and accept again at that price.
no_fundable_quote No maker at your price could fund their side The request stays open. The refusal carries the new best in the same three fields as price_moved; show it and accept that price.
place_refused:<reason> Every maker at your price was dropped, and at least one had their own order refused The request stays open and those makers’ quotes are gone. Nothing about your account was refused. A best_quote push follows if the top moved.
requester_refused:<reason> Your own order was refused, or your balance would not cover the bind The request stays open. No maker is dropped for this, though makers dropped earlier in the same accept are gone. The same accept gets the same answer until you clear <reason>.
leg_not_quotable A leg stopped being open while the request was live The request is cancelled. Those legs can never bind as a combo again.
leg_gate_unavailable The legs’ state could not be checked The request is untouched. Retry.
account_unreachable An account could not be checked The request is untouched. Retry.
outcome_unknown The bind took too long to confirm. It may or may not have traded. Do not retry. See When the outcome is unknown.
confirmation_required An earlier accept did not trade and a fresh quote is waiting Send confirm with the token from reconfirm_required
persist_failed The combo could not be recorded Retry
persist_unknown It is not known whether the combo was recorded The request stays open. Retry once; a record that did land is detected.
manager_unavailable The combo could not be recorded right now The request is untouched. Retry.
combination_state_inconsistent The combo could not be recorded Do not retry; contact support
market_proc:<reason> The combo’s market could not be started Retry

The <reason> after place_refused: and requester_refused: is one of insufficient_assets, account_limits_reached, member_position_limit, total_orders_limit, same_price_orders_limit, loss_limit24_hours, loss_limit_weekly or loss_limit_monthly, or a free-text message such as a geolocation refusal. Split on the first : and treat the rest as opaque text. requester_refused:requester_escrow means your balance failed the bind’s funding check before anything was written: fund the account and accept again.

An order can take longer to confirm than the bind waits, after it has already traded. Neither the server nor you can tell that apart from an order that placed nothing, so the reply is outcome_unknown. Never resend accept after it. What happens next depends on whose order was unknown, and the reply does not say which:

  • Your side. The request freezes: accept and cancel both answer outcome_unknown. You are also pushed outcome_unknown. The server finds out what happened and pushes one of bound (it traded), reconfirm_required (it did not; confirm the quote it carries) or outcome_unresolved (it could not find out; the request stays frozen until it expires, so read your positions and fills).
  • A maker’s side. The negotiation ends at once. Makers are sent negotiation_closed with reason outcome_unknown, no further push reaches you, and later commands answer not_found. Open a new request.

Accepting commits your whole stake. There is no undoing an accept that binds.

On rfq:{user_id}.

Binds the quote a reconfirm_required push presented.

Field Type Required
market_id UUID string yes
rfq_id UUID string yes
confirmation_token UUID string yes, from reconfirm_required or snapshot

Reply: the same object accept replies with. A token names one quote, carries its price, and works once, so confirm sends no price of its own. You fill at exactly that price. If the quote has gone by the time you confirm, nothing binds and the current best is presented again under a new token.

reason Cause What to do
malformed_id confirmation_token, rfq_id or market_id is not a UUID string Fix the payload
not_found The negotiation has ended Nothing to do
not_the_requester You did not open this negotiation Only the requester confirms
no_confirmation_pending Nothing is waiting to be confirmed Use accept
stale_confirmation The token is not the current one Use the token from the latest reconfirm_required, or snapshot
outcome_unknown The request is still frozen Wait for the push
quote_changed The quote the token named has gone A new reconfirm_required has been pushed; confirm that one

confirm runs the same bind as accept, so it can also answer anything accept can, including price_moved when the best has moved off the token’s price.

On rfq:{user_id}.

Ends the negotiation and drops every quote on it. Only the requester who opened it can cancel.

Field Type Required
market_id UUID string yes
rfq_id UUID string yes

Reply: {"cancelled": "<rfq_id>"}. Refusals: malformed_id, not_found, not_the_requester, and outcome_unknown, either while the request is frozen on an unknown outcome or when the answer was lost (see above). Only an accept or confirm you sent can have frozen it.

On rfq:{user_id}.

The negotiation as it stands. Makers have no snapshot, so a maker never sees what others are quoting.

Field Type Required
market_id UUID string yes
rfq_id UUID string yes

Reply:

{
"rfq_id": "<uuid>",
"market_id": "<uuid>",
"status": "open",
"side": "buy",
"stake": "250.0000",
"quantity": null,
"expires_at": 1790000300000000,
"best_quote": {"quote_id": "<uuid>", "price": "0.1200", "expires_at": 1790000140000000},
"confirmation": {"confirmation_token": null, "quote_id": null, "price": null}
}
  • status is "open", "outcome_unknown" (frozen; nothing binds yet) or "awaiting_confirmation" (send confirm with the token under confirmation). A negotiation that has ended answers not_found instead.
  • confirmation has null fields unless status is "awaiting_confirmation". It is how a client that reconnected picks up the token.
  • side is "buy" for a request and "sell" for a cash-out. A request carries stake and a null quantity; a cash-out carries quantity and a null stake.
  • best_quote is the one live quote an accept would fill at: the lowest price on a request, the highest on a cash-out. With no live quote its fields are all null, so the shape never changes. Between equal prices, the earlier quote stays best.
  • snapshot reads the book at the moment you ask and is never held back by the push cadence or the 500 ms opening wait. Use it to be sure of the price before you accept.

Refusals: malformed_id, not_found, not_the_requester, and outcome_unknown when the answer was lost. That refusal is different from the "outcome_unknown" status in a successful reply.

On either topic. Renews your geolocation check on the open socket. See Geolocation.

Field Type Required
geo_location string yes, unless your IP address is whitelisted

Reply: {"next_geolocation_at": "..."}, or a refusal whose reason says why the check failed.

Six events arrive without being asked for. A push has null for both join_ref and ref.

Push Arrives on
best_quote, outcome_unknown, reconfirm_required, outcome_unresolved The requester’s rfq:{user_id}
bound Both: the requester’s and the winning maker’s, with a different payload on each
negotiation_closed The maker’s combo_quote:{user_id}

A maker’s pushes name only that maker’s own quote_id, plus the requester’s external_id.

To the requester, whenever the best quote changes: a new quote takes the top, or the top quote is withdrawn or cancelled because its maker’s balance dropped or its maker disconnected. The push then carries the next best, or nulls if none is left. A quote that does not beat the current best is not pushed. It also fires once after an accept that dropped makers, if that moved the top.

{"rfq_id": "<uuid>", "market_id": "<uuid>", "quote_id": "<uuid>", "price": "0.1200", "expires_at": 1790000140000000}

It is sent on a cadence. At most one best_quote push per negotiation every 250 ms. The first change after a quiet spell goes out at once, later changes in the same window are held, and the state at the end of the window is always sent, so the last push you hold is the price that stands.

An empty book waits. When the last live quote goes, the nulls are sent only at the end of the window, and not at all if a quote arrives first. A maker repricing withdraws and quotes again, so this stops the price blanking for a moment on every reprice.

The first push waits 500 ms. No best_quote arrives until 500 ms after the request opens, however fast the first maker is, so several makers can compete before you see a price. Silence before then is expected; snapshot reads the book through it.

Quotes still land on the book as they arrive; only the pushes are paced. So:

  • Do not count best_quote pushes. One push is not one quote.
  • Do not read silence as “no new quotes”. Quotes may have taken the top and been beaten between two pushes.

The cadence and the opening wait apply only to best_quote. Every other push goes out at once.

It does not fire when the best quote expires. Act on expires_at, and snapshot before accepting if the push you hold may be stale. An accept at an expired quote’s price answers price_moved with whatever stands instead.

To the requester and to the winning maker, when a bind succeeds. The two payloads differ.

To the requester:

{"rfq_id": "<uuid>", "market_id": "<uuid>", "quantity": "2083.00", "price": "0.1200", "charged": "249.9600"}

To the maker, naming the requester’s external_id, which of your quotes traded and the exposure your account now carries:

{"rfq_id": "<uuid>", "market_id": "<uuid>", "external_id": "<uuid>", "quote_id": "<uuid>", "quantity": "2083.00", "price": "0.1200", "liability": "1833.0400"}

shortfall is only on the accept reply.

To each maker holding a quote that did not trade, once per quote, when the negotiation ends.

{"rfq_id": "<uuid>", "market_id": "<uuid>", "external_id": "<uuid>", "quote_id": "<uuid>", "reason": "bound_elsewhere"}

reason is bound_elsewhere, cancelled (including the requester disconnecting), expired, leg_not_quotable (a leg stopped being open) or outcome_unknown (the bind left an order of unknown outcome).

The requester is pushed nothing when their request expires, unless it was frozen; later commands answer not_found.

To the requester, when an accept’s outcome is unknown on your side. The request is frozen until one of the next two pushes, or bound, arrives.

{"rfq_id": "<uuid>", "market_id": "<uuid>", "quote_id": "<uuid>", "price": "0.1200", "quantity": "2083.00"}

To the requester, when that accept turned out not to have traded. It carries the quote live now, which may not be the one you accepted, and the token that binds it with confirm.

{"rfq_id": "<uuid>", "market_id": "<uuid>", "confirmation_token": "<uuid>", "quote_id": "<uuid>", "price": "0.1150", "expires_at": 1790000140000000, "reason": "placement_did_not_trade"}

reason is placement_did_not_trade, or confirmed_quote_gone when you confirmed a quote that had already gone. With nothing live to confirm, confirmation_token, quote_id and price are null and the request is open again: accept the next quote.

To the requester, when the outcome could not be found out, and again if the request expires while frozen. The request stays frozen until it expires. Read your positions and fills.

{"rfq_id": "<uuid>", "market_id": "<uuid>", "quote_id": "<uuid>"}
requester server maker
|-- request_quote -------------->| |
|<------ {rfq_id, market_id} ----| |
| | (rfq_id and market_id |
| | reach the maker out |
| | of band) |
| |<-------------- quote -----------|
| |------- {quote_id, price} ------>|
|<------- push best_quote -------| |
|-- accept {quote_id, price} --->| |
| | funding checked, both sides |
| | combo becomes a real market |
| | maker's sell rests, |
| | requester's buy takes it |
|<-- {market_id, quantity, ...} -| |
|<--------- push bound ----------|---------- push bound ---------->|
| |-- push negotiation_closed ----->| (other makers)
  • The market_id from request_quote is not a market yet. Until a bind it answers no market lookup, has no order book, and takes no orders.
  • A combo that has bound still takes no ordinary orders. An order sent to it through the order endpoints is refused with market_not_found. Combos are traded only through request_quote and cash_out.
  • That id is held only while a negotiation is open on it. Cancel without binding and it is released. It is a stable identity for those legs and outcomes, not a handle to keep.
  • A quote can vanish before you accept it. Expiry is not announced, and a quote is gone at its expires_at, not after it. A withdrawal, a maker’s balance dropping or a maker disconnecting moves best_quote only when it was the top quote.
  • best_quote can be up to 250 ms behind the book. An accept at the last pushed price is refused with price_moved from time to time in normal running.
  • No best_quote arrives in a request’s first 500 ms. Do not time out there.
  • The quote you accept is not necessarily the quote that fills. Another maker at the same price may fill it. The price always matches.
  • A failed accept does not usually end the request. no_fundable_quote, place_refused:*, requester_refused:*, account_unreachable and most infrastructure errors leave it open. leg_not_quotable cancels it, and outcome_unknown either freezes or ends it.
  • not_found does not say how a negotiation ended. The record of a bind is the market, the orders and your positions.
  • Requests are not broadcast to makers. A maker learns an rfq_id and market_id some other way.

Broncos to win the Super Bowl, Avalanche to win the Stanley Cup, Rockies to win the World Series: all three, for $250.

Frames use the Phoenix v2 array form, [join_ref, ref, topic, event, payload]. Replies arrive as phx_reply; pushes have null for both refs. The ids below are examples.

User id Topic
Requester 9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77 rfq:9c2a4e61-…
Maker A 2d8f6b10-4a7c-4e93-b58d-3c1f0e9a7b62 combo_quote:2d8f6b10-…
Maker B f60b3d97-8c25-41ea-9fb4-07d1a6e3c852 combo_quote:f60b3d97-…

1. Everyone joins the topic for their side

Section titled “1. Everyone joins the topic for their side”

All three are whitelisted here, so the joins carry no geolocation packet. The makers join combo_quote: the same way.

["1","1","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_join",{}]
["1","1","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{"external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03","next_geolocation_at":null}}]
["1","2","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","request_quote",{
"legs":[
{"market_id":"b1e4c7a8-3d92-4f05-8a16-2c7b9e0d4f31","outcome":"won"},
{"market_id":"a7d0f3b2-91c6-4e58-b04a-6f2d8c1e5b93","outcome":"won"},
{"market_id":"c4f18b96-5e2d-4073-91ac-8b6e3d0f2a57","outcome":"won"}
],
"stake":"250.00"
}]
["1","2","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64"
}}]

5a1d8e02-… is the combo’s identity: any later request for these three legs, all to win, gets the same one. It is not a market yet. The requester passes the rfq_id and market_id to makers.

Each maker’s quote reply below carries the requester’s external_id, 0b6e2f48-…, which the requester’s join reply also showed.

Maker A, at 0.1250, good for a minute:

["1","2","combo_quote:2d8f6b10-4a7c-4e93-b58d-3c1f0e9a7b62","quote",{
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"price":"0.1250",
"expires_in_ms":60000,
"client_order_id":"mm-a-0417"
}]
["1","2","combo_quote:2d8f6b10-4a7c-4e93-b58d-3c1f0e9a7b62","phx_reply",{"status":"ok","response":{
"quote_id":"7e93c0b5-1f48-4a26-9d07-5b2e8c4f1a90",
"price":"0.1250",
"external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03"
}}]

Maker B, at 0.1200, good for two minutes:

["1","2","combo_quote:f60b3d97-8c25-41ea-9fb4-07d1a6e3c852","quote",{
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"price":"0.1200",
"expires_in_ms":120000
}]
["1","2","combo_quote:f60b3d97-8c25-41ea-9fb4-07d1a6e3c852","phx_reply",{"status":"ok","response":{
"quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6",
"price":"0.1200",
"external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03"
}}]

The requester is pushed both, because each took the top in turn: A as the only quote, then B by undercutting it. Had B quoted 0.1300, only A’s push would have arrived.

[null,null,"rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","best_quote",{
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"quote_id":"7e93c0b5-1f48-4a26-9d07-5b2e8c4f1a90",
"price":"0.1250",
"expires_at":1790000070000000
}]
[null,null,"rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","best_quote",{
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6",
"price":"0.1200",
"expires_at":1790000140000000
}]

4. An accept at a price that moved, then one that did not

Section titled “4. An accept at a price that moved, then one that did not”

The requester accepts before B’s push lands, with A’s 0.1250 still on screen:

["1","3","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","accept",{
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"quote_id":"7e93c0b5-1f48-4a26-9d07-5b2e8c4f1a90",
"price":"0.1250"
}]

The live best is B’s 0.1200, so nothing is placed. The refusal carries B:

["1","3","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"error","response":{
"reason":"price_moved",
"quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6",
"price":"0.1200",
"expires_at":1790000140000000
}}]

0.1200 is cheaper than what the requester sent, and it is refused anyway. The client shows 0.1200 and the requester accepts it:

["1","4","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","accept",{
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6",
"price":"0.1200"
}]

Only quotes at 0.1200 can fill it: B alone. B’s balance is checked for (1 − 0.12) × 2083 = 1833.0400 and covers it. A is never asked, because A is at a different price. At 0.1200 the stake buys floor(250 / 0.12) = 2083 contracts for 249.9600, leaving 0.0400:

["1","4","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"quantity":"2083.00",
"price":"0.1200",
"charged":"249.9600",
"shortfall":"0.0400"
}}]

Had B failed funding with no other maker at 0.1200, the answer would have been no_fundable_quote carrying A’s 0.1250 as the new best, the request would have stayed open, and the requester could accept 0.1250 on its own.

The requester and maker B are each pushed bound on their own topics, and maker A is told on its topic that its quote lost:

[null,null,"rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","bound",{
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"quantity":"2083.00","price":"0.1200","charged":"249.9600"
}]
[null,null,"combo_quote:f60b3d97-8c25-41ea-9fb4-07d1a6e3c852","bound",{
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03",
"quote_id":"8c21e47f-0b93-4d6a-a175-2f9e3c8b40d6",
"quantity":"2083.00","price":"0.1200","liability":"1833.0400"
}]
[null,null,"combo_quote:2d8f6b10-4a7c-4e93-b58d-3c1f0e9a7b62","negotiation_closed",{
"rfq_id":"3f7b9c14-02ad-4e6b-8c51-9d0e7a6b2c38",
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"external_id":"0b6e2f48-1a9c-4d37-8e25-6f4a9c1d7b03",
"quote_id":"7e93c0b5-1f48-4a26-9d07-5b2e8c4f1a90",
"reason":"bound_elsewhere"
}]

Market 5a1d8e02-… is now a real market with two orders and a trade between them. The requester is long 2083 contracts and maker B is short 2083. Any further command on rfq_id 3f7b9c14-… answers not_found. From here the orders, fill, position and settlement appear on the orders, fills, positions and settlements channels like any other market’s. How it settles, including a leg that pushes, is worked through on Combos.

Later, two legs have landed and the third has not started. The requester sells the position:

["1","4","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","cash_out",{
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64"
}]
["1","4","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{
"rfq_id":"8b1f2d05-93c7-4e6a-bb12-5a7c0d3e9f41",
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"quantity":"2083.00"
}}]

Maker B bids 0.4000 with an ordinary quote on the new rfq_id, and the requester is pushed it as best_quote. The requester accepts:

["1","5","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","accept",{
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"rfq_id":"8b1f2d05-93c7-4e6a-bb12-5a7c0d3e9f41",
"quote_id":"c4a7e1b9-2d58-4f03-9e6a-18b7d0c5f3a2",
"price":"0.4000"
}]
["1","5","rfq:9c2a4e61-7d3b-4f18-9a06-1f2c5b8d4e77","phx_reply",{"status":"ok","response":{
"market_id":"5a1d8e02-6c4f-49b7-a3d2-0e8f1b7c9d64",
"quantity":"2083.00",
"price":"0.4000",
"charged":"833.2000",
"shortfall":"0.0000"
}}]

On a cash-out charged is what the seller receives: 2083 × 0.40 = 833.2000. Against the 249.9600 paid to open, that is 583.2400 gross. Maker B’s bound push carries "liability":"833.2000", because a buyer’s exposure is the price itself. Maker B was short 2083 from the first trade and has bought them back, so B is now flat too.

  • Requests are not broadcast to makers. A maker needs the rfq_id and market_id from the requester out of band.
  • bound reports the agreed terms, not a confirmed fill. An order can still be refused after the bind is reported. Confirm the position from the positions and fills channels.
  • No push when a quote expires. The requester’s last best_quote can be stale until something else moves the top; snapshot before accepting.
  • The first accept on a new combo can be refused. Shortly after the first request for a set of legs, an accept can answer requester_refused:Market does not accept such orders. The request stays open; accept again.
  • A maker cannot ask whether a quote still stands. Treat a quote as gone once its expires_in_ms has passed, and do not read a missing negotiation_closed as proof it is live.
v1.5.9Changelogllms.txtllms-full.txt