# Combos

> Request, quote, accept and cash out combos over the rfq and combo_quote channels.

Source: https://docs.stxapp.io/sdks/python/combos/

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](/concepts/combos/) for the model and [Combo negotiation channels](/websockets/channels/combo-negotiation/) 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.

## What you get back

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](#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.

## Walkthrough

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`.

### 1. Join

```python
rfq = await player_ws.rfq()
quotes = await maker_ws.combo_quote()
print(rfq.join_reply.external_id)
print(quotes.join_reply.external_id)
```

```text
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.

### 2. Player requests a quote

```python
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",
)
```

```json
{"rfq_id": "3c9a69c5-6a48-4655-9a20-25fd5ddd157a"}
```

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

```python
await rfq.snapshot(request.rfq_id)
```

```json
{
  "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.

### 3. Makers receive the request

```python
msg = await quotes.next()  # msg.event == "rfq_opened", msg.data is an RfqOpened
```

```json
{
  "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.

### 4. Makers quote what the player wins

```python
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")
```

```json
{"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.

```python
q = await quotes.quote(msg.data.rfq_id, "9.00", 60_000)
await quotes.withdraw_quote(msg.data.rfq_id, q.quote_id)
```

```json
{"quote_id": "a478269d-0789-4d40-8e5d-37a4712873ad", "amount": "9.0000", "external_id": "9c87ec1c-588e-4512-9b91-d71653e41458"}
{"withdrawn": "a478269d-0789-4d40-8e5d-37a4712873ad"}
```

### 5. Player sees the best quote

```python
msg = await rfq.next()  # msg.event == "best_quote", msg.data is a BestQuote
```

```json
{"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`.

### 6. Player accepts

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

```python
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:

```python
placed = await rfq.accept(request.rfq_id, msg.data.quote_id, msg.data.amount)
```

```json
{"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=`).

### 7. Both sides are told

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:

```json
{"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:

```json
{"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`:

```json
{"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.

### 8. Player cashes out

```python
out = await rfq.cash_out("b66e1763-caa7-49a3-9ec5-2287e81d0610")
```

```json
{"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`:

```json
{
  "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`.

```python
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:

```json
{"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:

```python
sold = await rfq.accept(out.rfq_id, best.quote_id, "3.73")
```

```json
{"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"`.

## How a request ends

`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:

```python
await rfq.cancel("0ae284d1-afa8-4084-8289-7e0c76f49be6")
```

```json
{"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:

```json
{"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
```

```json
{"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:

```json
{"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:

```python
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:

```json
{"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.

## Join

```python
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
```

## Request a quote and accept it

```python
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:

```python
out = await rfq.cash_out("b66e1763-caa7-49a3-9ec5-2287e81d0610")
print(out.rfq_id, out.quantity)
```

```
6d2d176a-589a-4cbc-9e72-9feba03f3a89 14.00
```

## Quote as a maker

```python
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`](https://github.com/stxapp/stx-python-demo/blob/main/examples/combo_maker.py); [Quote combos as a maker](/websockets/combo-maker/) walks through it.

## Errors

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.

```python
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](/websockets/channels/combo-negotiation/).
