Skip to content

Combos

Describes stx-python 0.8.0, the current release.

A combo is one market over two or more legs. Players ask makers for a quote on a set of legs and a stake, makers quote what the player wins, and the player accepts the best quote. The exchange then places one ordinary limit order for each side on the combo’s market. Read Combos for the model and Combo negotiation channels for the wire protocol: every frame, field and refusal reason. This page shows what the SDK gives you.

Both channels are on STXWebSocket, so they are async only, and both need a read_write API key.

Every command returns a typed model from stx.combos, and every push arrives as a ChannelMessage whose data is a typed model chosen by its event. msg.payload is still the raw object. A refused command raises a typed exception (see Errors).

Channel Command Returns
rfq request_quote(legs, amount) QuoteRequest
rfq snapshot(rfq_id) RfqSnapshot (its best_quote is a BestQuote)
rfq accept(rfq_id, quote_id, amount) ComboPlacement
rfq cash_out(market_id) CashOut
rfq cancel(rfq_id) CancelledRequest
combo_quote quote(rfq_id, amount, expires_in_ms) ComboQuote
combo_quote withdraw_quote(rfq_id, quote_id) WithdrawnQuote
Channel msg.event msg.data
rfq best_quote BestQuote
rfq placed ComboPlacement
rfq negotiation_closed NegotiationClosed
combo_quote rfq_opened RfqOpened (its legs are ComboLeg)
combo_quote quote_accepted QuoteAccepted
combo_quote placed MakerPlacement
combo_quote negotiation_closed NegotiationClosed
combo_quote rfq_closed RfqClosed

Money and quantities are strings, never floats. expires_at is integer microseconds since the Unix epoch: 1792557584350270 is 2026-10-21 04:39:44.350270 UTC.

One combo from request to cash out, captured with stx-python 0.7.1 on US demo on 2026-10-21. Every output below is what the SDK returned or delivered, with nothing changed: the field the code prints, or model_dump() of the whole object. A player stakes $5.00 on two legs, BTC up and ETH up over the same 15 minutes. Maker A quotes a $10.00 win and maker B quotes $8.00. Each account has its own AsyncSTX client and socket, opened with async with client.websocket() as ws:; below they are player_ws, maker_ws and maker_b_ws.

rfq = await player_ws.rfq()
quotes = await maker_ws.combo_quote()
print(rfq.join_reply.external_id)
print(quotes.join_reply.external_id)
9c87ec1c-588e-4512-9b91-d71653e41458
3914a397-b553-4510-8b54-d6a6c0a096b0

join_reply is a ComboJoinReply. external_id is the account’s pseudonym: the first line is the player’s, which makers see on every request and quote. The second is maker A’s own, which it would show only as a player; nobody sees a maker’s. The join reply carries more than external_id, and the SDK keeps fields it does not model, so print the field you need rather than the whole reply.

request = await rfq.request_quote(
[
("70e7b102-1dd7-4236-9665-6f645303fcb1", "won"), # BTC 15 min, up
("4e3243fe-b3f5-4264-a586-73d6b512eb86", "won"), # ETH 15 min, up
],
amount="5.00",
)
{"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a"}

The request lives five minutes. Nothing is quoted yet, so a snapshot shows an empty best quote:

await rfq.snapshot(request.rfq_id)
{
"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a",
"market_id": null,
"status": "open",
"side": "buy",
"amount": "5.0000",
"quantity": null,
"expires_at": 1792557584350270,
"best_quote": {"rfq_id": null, "market_id": null, "quote_id": null, "amount": null, "expires_at": null}
}

market_id is None on a buy until it is placed: the combo’s market is found or created at the bind. Inside a snapshot, best_quote never carries rfq_id or market_id; read them from the snapshot itself.

msg = await quotes.next() # msg.event == "rfq_opened", msg.data is an RfqOpened
{
"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a",
"market_id": null,
"external_id": "9c87ec1c-588e-4512-9b91-d71653e41458",
"side": "buy",
"amount": "5.0000",
"quantity": null,
"max_price": "1.0000",
"tick_size": "0.0001000",
"expires_at": 1792557584350270,
"legs": [
{"market_id": "70e7b102-1dd7-4236-9665-6f645303fcb1", "outcome": "won", "short_name": "BTC 15 min"},
{"market_id": "4e3243fe-b3f5-4264-a586-73d6b512eb86", "outcome": "won", "short_name": "ETH 15 min"}
]
}

On buy, amount is the player’s stake and quantity is None. Every maker joined to combo_quote gets the same push, including the player’s own account if it is joined there.

await maker_b_quotes.quote(msg.data.rfq_id, "8.00", 120_000)
quote = await quotes.quote(msg.data.rfq_id, "10.00", 60_000, client_order_id="docs-sdk-m1")
{"quote_id": "73968997-833d-4cfe-bbfb-b56816a51ea7", "amount": "8.0000", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458"}
{"quote_id": "d675be64-f34d-40a6-b454-f004bbb1d272", "amount": "10.0000", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458"}

client_order_id is copied onto the maker’s order if the quote is accepted. A quote cannot be changed: withdraw it and quote again.

q = await quotes.quote(msg.data.rfq_id, "9.00", 60_000)
await quotes.withdraw_quote(msg.data.rfq_id, q.quote_id)
{"quote_id": "a478269d-0789-4d40-8e5d-37a4712873ad", "amount": "9.0000", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458"}
{"withdrawn": "a478269d-0789-4d40-8e5d-37a4712873ad"}
msg = await rfq.next() # msg.event == "best_quote", msg.data is a BestQuote
{"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a", "market_id": null, "quote_id": "d675be64-f34d-40a6-b454-f004bbb1d272", "amount": "10.0000", "expires_at": 1792557344586656}

Only the best live quote is pushed: maker B’s $8.00 is never shown while maker A’s $10.00 stands. expires_at here is the quote’s expiry, 60 seconds out. Every socket the player has joined to rfq gets the push. When the last live quote is withdrawn or expires, best_quote arrives again with quote_id, amount and expires_at set to None.

accept takes the amount you saw as a floor. Above the best, it raises STXComboPriceMovedException and the request stays open:

try:
await rfq.accept(request.rfq_id, msg.data.quote_id, "25.00")
except stx.STXComboPriceMovedException as exc:
print(exc)
print(exc.best_quote)
accept on rfq:c7a6abb5-5e79-489d-951f-c043d4377fac refused: price_moved
{"rfq_id": null, "market_id": null, "quote_id": "d675be64-f34d-40a6-b454-f004bbb1d272", "amount": "10.0000", "expires_at": 1792557344586656}

At the best:

placed = await rfq.accept(request.rfq_id, msg.data.quote_id, msg.data.amount)
{"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a", "market_id": "b66e1763-caa7-49a3-9ec5-2287e81d0610", "order_id": "af1d2b26-0a5a-410f-be98-8607b5774163", "quantity": "14.00", "price": "0.3334"}

The exchange turns the amounts into an order on the combo’s market. The price is max_price × stake / (stake + win) = 5 / 15 = 0.33333, rounded up to the tick: 0.3334. The size is whole contracts, floor(5 / 0.3334) = 14. The player pays $4.67 and collects $14.00 if both legs win. The reply is what was placed, not what filled. accept can take up to 30 seconds and waits 40 by default (timeout=).

The player gets placed on rfq, the same ComboPlacement as the reply. Maker A gets quote_accepted first, then placed with its own order at the same price and size:

{"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a", "market_id": null, "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "quote_id": "d675be64-f34d-40a6-b454-f004bbb1d272"}
{"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a", "market_id": "b66e1763-caa7-49a3-9ec5-2287e81d0610", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "quote_id": "d675be64-f34d-40a6-b454-f004bbb1d272", "order_id": "9a71ba22-0277-49a6-a3d2-66be484036e2", "quantity": "14.00", "price": "0.3334"}

quote_accepted means the bind is being attempted, not that it succeeded: placed or negotiation_closed follows. Maker B, whose quote lost, gets negotiation_closed for its quote:

{"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a", "market_id": "b66e1763-caa7-49a3-9ec5-2287e81d0610", "reason": "placed_elsewhere", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "quote_id": "73968997-833d-4cfe-bbfb-b56816a51ea7"}

and every maker gets rfq_closed:

{"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a", "market_id": "b66e1763-caa7-49a3-9ec5-2287e81d0610", "reason": "placed"}

The request is gone now: snapshot and accept on it raise STXComboException with reason == "not_found". From here b66e1763-... is an ordinary market: fills, positions and settlements arrive on the orders, fills, positions and settlements channels.

out = await rfq.cash_out("b66e1763-caa7-49a3-9ec5-2287e81d0610")
{"rfq_id": "6d2d176a-589a-4cbc-9e72-9feba03f3a89", "market_id": "b66e1763-caa7-49a3-9ec5-2287e81d0610", "quantity": "14.00"}

A cash out is a new request with its own rfq_id, and the exchange sets the size. Right after a bind the position may not be recorded yet, and cash_out raises STXComboException with reason == "nothing_to_sell"; retry after a second. Makers get rfq_opened with side sell:

{
"rfq_id": "6d2d176a-589a-4cbc-9e72-9feba03f3a89",
"market_id": "b66e1763-caa7-49a3-9ec5-2287e81d0610",
"external_id": "9c87ec1c-588e-4512-9b91-d71653e41458",
"side": "sell",
"amount": null,
"quantity": "14.00",
"max_price": "1.0000",
"tick_size": "0.0001000",
"expires_at": 1792557587434511,
"legs": [
{"market_id": "4e3243fe-b3f5-4264-a586-73d6b512eb86", "outcome": "won", "short_name": "ETH 15 min"},
{"market_id": "70e7b102-1dd7-4236-9665-6f645303fcb1", "outcome": "won", "short_name": "BTC 15 min"}
]
}

On sell, quantity is the size, amount is None, and market_id is the combo. The legs come in the combo’s own order, which need not be the order the player sent. Makers quote what they pay for all 14 contracts. A quote worth more than the size can pay ("21.000", $1.50 a contract) is refused with price_out_of_range.

await maker_b_quotes.quote(out.rfq_id, "3.73", 60_000)
await quotes.quote(out.rfq_id, "4.20", 60_000, client_order_id="docs-sdk-m2")

The player’s best_quote pushes now carry market_id, first maker B’s quote, then maker A’s better one:

{"rfq_id": "6d2d176a-589a-4cbc-9e72-9feba03f3a89", "market_id": "b66e1763-caa7-49a3-9ec5-2287e81d0610", "quote_id": "495076df-6cb6-4484-981a-f85598939c07", "amount": "3.7300", "expires_at": 1792557347659883}
{"rfq_id": "6d2d176a-589a-4cbc-9e72-9feba03f3a89", "market_id": "b66e1763-caa7-49a3-9ec5-2287e81d0610", "quote_id": "e5b7980a-9a35-4e70-84b7-21fd51083ba5", "amount": "4.2000", "expires_at": 1792557348010005}

Because the amount is a floor, accepting at the first amount seen ($3.73) takes the best live quote, $4.20:

sold = await rfq.accept(out.rfq_id, best.quote_id, "3.73")
{"rfq_id": "6d2d176a-589a-4cbc-9e72-9feba03f3a89", "market_id": "b66e1763-caa7-49a3-9ec5-2287e81d0610", "order_id": "d4c305e1-820a-4e47-9626-2409b4a7b075", "quantity": "14.00", "price": "0.3000"}

On a cash out the price is amount / quantity = 4.20 / 14 = 0.3000, rounded down to the tick. The pushes are the same as for the buy: placed for the player, quote_accepted and placed for maker A, negotiation_closed with placed_elsewhere for maker B, rfq_closed with placed for every maker. Once the position is sold, another cash_out raises STXComboException with reason == "nothing_to_sell".

negotiation_closed on rfq tells the player a request ended without an order. On combo_quote it is sent once per quote and carries the maker’s quote_id and the player’s external_id. rfq_closed tells every maker the request ended, for any reason. All captured on the same run:

What happened Player gets (rfq) Quoting maker gets (combo_quote) Other makers
Accepted and placed the accept reply, placed quote_accepted, placed, rfq_closed placed negotiation_closed placed_elsewhere if they quoted, rfq_closed placed
rfq.cancel(rfq_id) CancelledRequest, negotiation_closed cancelled negotiation_closed cancelled, rfq_closed cancelled rfq_closed cancelled
The channel that opened it leaves or drops negotiation_closed cancelled on every rfq socket, the one that left included negotiation_closed cancelled, rfq_closed cancelled rfq_closed cancelled
Five minutes pass negotiation_closed expired negotiation_closed expired, rfq_closed expired rfq_closed expired
A leg stops trading before the accept accept raises leg_not_quotable, negotiation_closed leg_not_quotable quote_accepted, negotiation_closed leg_not_quotable, rfq_closed leg_not_quotable rfq_closed leg_not_quotable
The player cannot fund it accept raises player_refused, negotiation_closed player_refused quote_accepted, negotiation_closed player_refused, rfq_closed player_refused rfq_closed player_refused
The maker cannot fund it accept raises STXComboPriceMovedException with an empty best_quote; the request stays open quote_accepted, negotiation_closed maker_refused nothing

A cancel:

await rfq.cancel("0ae284d1-afa8-4084-8289-7e0c76f49be6")
{"cancelled": "0ae284d1-afa8-4084-8289-7e0c76f49be6"}

and the player’s negotiation_closed, where external_id and quote_id are None on the player’s side:

{"rfq_id": "0ae284d1-afa8-4084-8289-7e0c76f49be6", "market_id": null, "reason": "cancelled", "external_id": null, "quote_id": null}

A leg closing: a request on the BTC and ETH markets of 04:30 to 04:45 UTC, opened at 04:42:30 and accepted at 04:45:20, after both legs closed. accept raises and the player is told:

accept on rfq:c7a6abb5-5e79-489d-951f-c043d4377fac refused: leg_not_quotable
{"rfq_id": "7928dea2-b12e-4778-843e-9671629f185a", "market_id": null, "reason": "leg_not_quotable", "external_id": null, "quote_id": null}

Maker A, whose quote was accepted, gets quote_accepted and then:

{"rfq_id": "7928dea2-b12e-4778-843e-9671629f185a", "market_id": null, "reason": "leg_not_quotable", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "quote_id": "8cef87c9-0a0d-4746-bfb4-49a6b31d42d6"}

After the legs close, request_quote on them and cash_out of the combo both raise leg_not_quotable.

A player that cannot fund the accept: the player held 11 contracts and opened two cash outs of them, d91050c4-... and 3f418a9e-..., each for all 11. The first sold. Accepting the second found nothing left to sell:

try:
await rfq.accept("3f418a9e-e76f-4408-87c4-af59e815b1bb", "7a6872f8-d9e0-45b9-8605-0c90a4e37257", "3.30")
except stx.STXComboException as exc:
print(exc)
print(exc.reason, exc.detail, exc.raw_reason)
accept on rfq:c7a6abb5-5e79-489d-951f-c043d4377fac refused: player_refused:nothing_to_sell
player_refused nothing_to_sell player_refused:nothing_to_sell

A maker that cannot fund the accept: maker A quoted a $1,000,000.00 win on a $20.00 stake, which it could not cover. The player’s accept raised STXComboPriceMovedException with every field of best_quote None, because the failed quote was dropped, and the request stayed open with nothing quoted. Maker A got quote_accepted and then:

{"rfq_id": "20013a61-1e35-488e-b804-58010179ace2", "market_id": null, "reason": "maker_refused", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458", "quote_id": "fda28220-562c-4403-91cb-c8f474baac4c"}

A maker’s quote can also end on its own: a quote placed with expires_in_ms=400 gave maker A negotiation_closed with reason expired and its quote_id, while the request stayed open for other quotes.

import asyncio
import stx
from stx import AsyncSTX
async def main():
async with AsyncSTX() as client:
async with client.websocket() as ws:
try:
rfq = await ws.rfq() # as a player
quotes = await ws.combo_quote() # as a maker
except stx.STXCombosUnavailableException:
print("Combos are not available on this environment.")
return
print(rfq.join_reply.external_id)
asyncio.run(main())
9c87ec1c-588e-4512-9b91-d71653e41458

ws.rfq() returns an RfqChannel and ws.combo_quote() a ComboQuoteChannel. One account can join both channels, and can join rfq from several sockets; each socket gets the pushes.

On an exchange without combos the join raises STXCombosUnavailableException, and the socket and your other channels are unaffected. A read-only key is refused with STXComboException, whose reason is the exchange’s sentence:

STXComboException: phx_join on rfq:bc2d4b34-5d21-4a2a-8af6-d1ad3846ac3f refused: This API key does not have write access
import asyncio
import stx
from stx import AsyncSTX
from stx.combos import ComboLeg
async def main():
async with AsyncSTX() as client:
async with client.websocket() as ws:
rfq = await ws.rfq()
request = await rfq.request_quote(
[
ComboLeg(market_id="70e7b102-1dd7-4236-9665-6f645303fcb1", outcome="won"),
ComboLeg(market_id="4e3243fe-b3f5-4264-a586-73d6b512eb86", outcome="won"),
],
amount="5.00",
)
async for msg in rfq:
if getattr(msg.data, "rfq_id", None) != request.rfq_id:
continue
if msg.event == "best_quote" and msg.data.amount is not None:
best = msg.data
print(f"win {best.amount} on a 5.00 stake")
try:
placed = await rfq.accept(request.rfq_id, best.quote_id, best.amount)
except stx.STXComboPriceMovedException as exc:
print("best is now", exc.best_quote.amount)
continue
print(placed.market_id, placed.price, placed.quantity)
break
if msg.event == "negotiation_closed":
print("closed:", msg.data.reason)
break
asyncio.run(main())
win 10.0000 on a 5.00 stake
b66e1763-caa7-49a3-9ec5-2287e81d0610 0.3334 14.00
  • Legs are ComboLeg objects, mappings with market_id and outcome, or (market_id, outcome) pairs. outcome is "won" or "lost". At least two legs, each a different market; too many raise combo_maximum_legs_exceeded.
  • amount is a string or Decimal in whole cents. A float raises TypeError: amount must be a decimal string such as '25.00', got float before anything is sent.
  • best_quote pushes for every open request arrive on the same channel, so filter on rfq_id when you have more than one.
  • After the accept the combo is an ordinary market: follow it on the orders, fills, positions and settlements channels, addressed by placed.market_id.
  • snapshot(rfq_id) reads the request and the live best at once, without waiting for a push, and cancel(rfq_id) ends it.
  • A request belongs to the channel that opened it. Leaving the channel or losing the connection cancels it, and a reconnect does not restore it.

To sell what you hold of a combo, call cash_out(market_id) and accept the same way:

out = await rfq.cash_out("b66e1763-caa7-49a3-9ec5-2287e81d0610")
print(out.rfq_id, out.quantity)
6d2d176a-589a-4cbc-9e72-9feba03f3a89 14.00
import asyncio
from stx import AsyncSTX
async def main():
async with AsyncSTX() as client:
async with client.websocket() as ws:
quotes = await ws.combo_quote()
async for msg in quotes:
if msg.event == "rfq_opened" and msg.data.side == "buy":
request = msg.data
names = ", ".join(leg.short_name for leg in request.legs)
print(f"{names}: stake {request.amount}, tick {request.tick_size}")
quote = await quotes.quote(
request.rfq_id, "10.00", 60_000, client_order_id="docs-sdk-m1"
)
print("quoted", quote.quote_id)
elif msg.event == "placed":
print("placed at", msg.data.price, "x", msg.data.quantity)
elif msg.event == "negotiation_closed":
print("quote", msg.data.quote_id, "ended:", msg.data.reason)
asyncio.run(main())
BTC 15 min, ETH 15 min: stake 5.0000, tick 0.0001000
quoted d675be64-f34d-40a6-b454-f004bbb1d272
placed at 0.3334 x 14.00
  • rfq_opened arrives for every request opened after you join; earlier ones are not replayed. On buy, amount is the player’s stake and your quote is what the player wins. On sell (a cash out), quantity is the size and your quote is what you pay for all of it. Price against the request’s max_price and tick_size.
  • expires_in_ms is an int of milliseconds; anything else raises TypeError: expires_in_ms must be an int of milliseconds. To change a quote, withdraw_quote(rfq_id, quote_id) and quote again.
  • quote_accepted comes before the exchange re-checks the legs and both sides’ funds, so it can be followed by negotiation_closed (leg_not_quotable, player_refused, maker_refused) instead of placed.
  • A quote belongs to the channel that placed it: leaving the channel or losing the connection withdraws it.

A complete maker that quotes every request until Ctrl-C and reports how each one ends is examples/combo_maker.py; Quote combos as a maker walks through it.

A refused command raises STXComboException. str(exc) names the command, the topic and the exchange’s reason, reason is the reason, and command is the refused command. A reason with a detail is split on the first colon: player_refused:nothing_to_sell gives reason == "player_refused" and detail == "nothing_to_sell", and raw_reason keeps the original.

try:
await rfq.accept(request.rfq_id, best.quote_id, best.amount)
except stx.STXComboPriceMovedException as exc:
print("best moved to", exc.best_quote.amount) # the request is still open
except stx.STXComboException as exc:
print(exc.reason, exc.detail)
Exception When
STXCombosUnavailableException joining on an environment without combos; reason == "unmatched topic"
STXComboPriceMovedException accept found the best below your amount; best_quote is the live best, with every field None when nothing is live
STXComboException any other refused join or command

All three extend STXChannelException. A reply that does not arrive in time raises STXChannelException as on other channels; the command can still take effect, so check with snapshot. Joining a channel twice on one socket raises STXChannelException with Already joined rfq:<user_id>.

Every refusal below was raised by stx-python 0.7.1 on US demo, as STXComboException unless noted. For example, str(exc) for the first row is request_quote on rfq:c7a6abb5-5e79-489d-951f-c043d4377fac refused: combo_minimum_two_markets_needed.

Call reason
request_quote with one leg combo_minimum_two_markets_needed
request_quote with six legs combo_maximum_legs_exceeded
request_quote with a leg id that is not a UUID malformed_id
request_quote with an outcome other than won or lost unknown_outcome
request_quote with a market that does not exist, or a combo as a leg unknown_leg
request_quote with both outcomes of one market duplicate_legs
request_quote with amount="0" non_positive_amount
request_quote with amount="5.001" sub_cent_amount
request_quote with amount="five" malformed_money
request_quote for more than the balance insufficient_assets
request_quote on legs that stopped trading leg_not_quotable
snapshot, cancel or accept on a request that ended or never existed not_found
snapshot, cancel or accept on another player’s request not_the_requester
accept above the best price_moved (STXComboPriceMovedException)
accept that the player cannot fund player_refused, with a detail such as nothing_to_sell
accept after a leg stopped trading leg_not_quotable
cash_out of an ordinary market or an unknown id unknown_combination
cash_out of a combo whose legs closed leg_not_quotable or market_not_quotable
cash_out with nothing held nothing_to_sell
quote or withdraw_quote on a request that ended or never existed not_found
quote on your own request cannot_quote_own_request
quote with amount="0" non_positive_amount
quote outside what the size can pay, or "0.0000001" price_out_of_range
quote that buys less than one contract below_one_contract
quote with expires_in_ms=0 expires_at_required
quote with a client_order_id over 255 characters malformed_client_order_id
withdraw_quote of a quote that is gone or not yours unknown_quote
joining with a read-only key This API key does not have write access

Every reason, including the ones the SDK’s typed arguments rule out, is listed in Combo negotiation channels.