Skip to content

Quote combos as a maker

A player asks for quotes on a combo: a set of legs and a stake to buy, or a combo they hold to cash out. Every maker joined to its combo_quote:{user_id} channel receives the request, and the player takes the best quote. This page is a complete maker: it quotes every request at an amount and expiry you choose, and reports how each one ends. Combo negotiation channels documents every frame, field and refusal it relies on.

Quoting needs an API key with read_write scope: a read_only key is refused when it joins. Environments lists the hosts and where to get a key. Each SDK picks the exchange from its usual configuration, for example region = ontario and env = demo in ~/.stx/credentials, or STX_REGION=ontario STX_ENV=demo.

Arrives Means The example prints
rfq_opened A player opened a request. Every maker gets every request opened after it joined, your own account’s included [request], then [quoted] with your quote_id
quote_accepted, then placed The player took your quote and your order was placed on the combo’s market, at the same price and size as the player’s [accepted], then [won]
negotiation_closed placed_elsewhere Another maker’s quote was taken [lost]
negotiation_closed expired Your quote ran out and a later quote on the request replaced it [expired]
negotiation_closed, any other reason The request ended for another reason, such as cancelled [ended]
rfq_closed The request ended, whether you quoted or not [closed]

quote_accepted alone is not a trade: the exchange still checks both sides, and negotiation_closed can follow instead of placed.

On a buy you quote what the player wins, here the stake times a multiple. On a cash out you quote what you pay for everything the player sells, here a price a contract. Price and size come from those amounts, rounded to the request’s tick_size in your favor, as How price and size are derived explains. The exchange refuses a quote whose price is not above 0 and below max_price (price_out_of_range), or a buy whose stake buys no whole contract (below_one_contract), so each example checks that first.

Each tab is the whole program, from the example repositories. Clone the repository to run it: the setup there installs the SDK at the version shown, and the README walks through the options.

examples/combo_maker.py in stx-python-demo, on stx-python 0.8.0. python examples/combo_maker.py quotes until Ctrl-C; WIN_MULTIPLE, CASH_OUT_PRICE and QUOTE_SECONDS set the quote.

"""Quote every combo request as a maker, and report how each one ends.
Players ask for quotes on a combo: a set of legs and a stake to buy, or a
combo they hold to cash out. Every maker joined to its combo_quote channel
receives each request. This script quotes all of them and prints what
happens next: your quote placed (quote_accepted, then placed), another
maker's placed instead (placed_elsewhere), your quote expiring, and the
request closing. It needs a read_write API key.
On a buy you quote what the player wins: the stake times WIN_MULTIPLE. On a
cash out you quote what you pay for everything the player sells:
CASH_OUT_PRICE a contract. Each quote stands for QUOTE_SECONDS.
Run: python examples/combo_maker.py (quote until Ctrl-C)
python examples/combo_maker.py 120 (stop after 120 s)
WIN_MULTIPLE=12 CASH_OUT_PRICE=0.10 QUOTE_SECONDS=60 python examples/combo_maker.py
"""
import asyncio
import os
import sys
import time
from stx import AsyncSTX, STXComboException, STXCombosUnavailableException
from stx.combos import ComboQuoteChannel, RfqOpened
from helpers import maker_quote_amount
run_seconds = float(sys.argv[1]) if len(sys.argv) > 1 else None
win_multiple = os.environ.get("WIN_MULTIPLE", "9")
cash_out_price = os.environ.get("CASH_OUT_PRICE", "0.08")
quote_ms = int(float(os.environ.get("QUOTE_SECONDS", "30")) * 1000)
def short(uuid: str) -> str:
return uuid[:8]
def describe(request: RfqOpened) -> str:
if request.side == "buy":
legs = ", ".join(f"{leg.short_name or short(leg.market_id)} {leg.outcome}" for leg in request.legs)
return f"buy, stake {request.amount}, {len(request.legs)} legs: {legs}"
return f"cash out of {request.quantity} on combo {short(request.market_id)}"
async def quote(quotes: ComboQuoteChannel, request: RfqOpened) -> None:
amount = maker_quote_amount(request, win_multiple, cash_out_price)
if amount is None:
print(f"[skip] {short(request.rfq_id)} no amount prices inside 0 to {request.max_price}")
return
try:
q = await quotes.quote(request.rfq_id, amount, quote_ms, client_order_id=f"maker-{short(request.rfq_id)}")
except STXComboException as exc:
# e.g. cannot_quote_own_request, or not_found when the request ended first
print(f"[refused] {short(request.rfq_id)} {exc.reason}")
return
what = f"win {q.amount}" if request.side == "buy" else f"pay {q.amount} for {request.quantity}"
print(f"[quoted] {short(request.rfq_id)} {what} for {quote_ms // 1000} s quote {q.quote_id}")
async def main() -> int:
async with AsyncSTX() as client:
# The SDK signs the socket, sends heartbeats and reconnects. Quotes belong to the
# connection that placed them: a drop withdraws them, so there is nothing to clean up.
async with client.websocket(
on_reconnect=lambda: print("[reconnected] quotes from before the drop were withdrawn")
) as ws:
try:
quotes = await ws.combo_quote()
except STXCombosUnavailableException:
print(f"Combos are not available on {client.base_url}.")
return 1
print(f"Quoting combos on {client.base_url} as a maker.")
print(f"Buys: win {win_multiple}x the stake. Cash outs: {cash_out_price} a contract.", end=" ")
print(f"Each quote stands {quote_ms // 1000} s. Ctrl-C to stop.\n")
deadline = time.monotonic() + run_seconds if run_seconds else None
while deadline is None or time.monotonic() < deadline:
try:
msg = await quotes.next(timeout=deadline - time.monotonic() if deadline else None)
except asyncio.TimeoutError:
break
d = msg.data
rfq = short(d.rfq_id) if d is not None else "-"
if msg.event == "rfq_opened":
# Every request opened after the join, from every player, your own included.
print(f"[request] {rfq} {describe(d)}")
await quote(quotes, d)
elif msg.event == "quote_accepted":
# The player took your quote; the exchange is placing both orders now.
print(f"[accepted] {rfq} quote {d.quote_id}")
elif msg.event == "placed":
print(f"[won] {rfq} order {d.order_id} {d.quantity} @ {d.price} combo {d.market_id}")
elif msg.event == "negotiation_closed":
# One of your quotes stopped standing, once per quote, with the reason.
label = {"placed_elsewhere": "[lost] ", "expired": "[expired]"}.get(d.reason, "[ended] ")
print(f"{label} {rfq} quote {d.quote_id} {d.reason}")
elif msg.event == "rfq_closed":
print(f"[closed] {rfq} {d.reason}")
return 0
try:
sys.exit(asyncio.run(main()))
except KeyboardInterrupt:
print("\nStopped. Quotes still standing were withdrawn with the connection.")

The amount check, from examples/helpers.py:

from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
from typing import Optional
from stx.combos import RfqOpened
def maker_quote_amount(request: RfqOpened, win_multiple: str, cash_out_price: str) -> Optional[str]:
"""The amount a maker quotes on `request`, in whole cents, or None when it would be refused.
On a buy the amount is what the player wins: the stake times `win_multiple`.
On a cash out it is what you pay for the whole size: `cash_out_price` a
contract. The exchange derives a price from the amount, rounded to the
request's `tick_size` in your favour, and refuses a quote whose price is
not above 0 and below `max_price` (`price_out_of_range`), or, on a buy,
whose stake buys no whole contract (`below_one_contract`). This checks the
same rules first, so the example does not send a quote it knows will fail.
"""
max_price, tick = Decimal(request.max_price), Decimal(request.tick_size)
if request.side == "buy":
stake = Decimal(request.amount)
amount = (stake * Decimal(win_multiple)).quantize(Decimal("0.01"), rounding=ROUND_FLOOR)
if amount <= 0:
return None
# max_price x stake / (stake + amount), rounded up to the tick.
price = (max_price * stake / (stake + amount) / tick).to_integral_value(rounding=ROUND_CEILING) * tick
if not 0 < price < max_price or (stake / price).to_integral_value(rounding=ROUND_FLOOR) < 1:
return None
else:
quantity = Decimal(request.quantity)
amount = (quantity * Decimal(cash_out_price)).quantize(Decimal("0.01"), rounding=ROUND_FLOOR)
# amount / quantity, rounded down to the tick.
price = (amount / quantity / tick).to_integral_value(rounding=ROUND_FLOOR) * tick
if not 0 < price < max_price:
return None
return f"{amount:.2f}"

The Python example on Ontario demo with WIN_MULTIPLE=15 CASH_OUT_PRICE=0.12, while a player bought a combo and then cashed it out. Both quotes were taken:

Quoting combos on https://demo.stxapp.ca as a maker.
Buys: win 15x the stake. Cash outs: 0.12 a contract. Each quote stands 30 s. Ctrl-C to stop.
[request] 354cc116 buy, stake 2.0000, 2 legs: WSH @ CHA won, LA @ NYR won
[quoted] 354cc116 win 30.0000 for 30 s quote d0a4b3bd-e53b-4d26-b901-51af04c6f313
[accepted] 354cc116 quote d0a4b3bd-e53b-4d26-b901-51af04c6f313
[won] 354cc116 order 5c020a30-1cfc-4d57-8f0c-7f51a0d0913f 32.00 @ 0.0625 combo df0489de-8f36-4fde-818e-adeaf038bde3
[closed] 354cc116 placed
[request] 79b805cc cash out of 32.00 on combo df0489de
[quoted] 79b805cc pay 3.8400 for 32.00 for 30 s quote d55d0637-0189-460f-9978-19356747ac48
[accepted] 79b805cc quote d55d0637-0189-460f-9978-19356747ac48
[won] 79b805cc order 09159e2a-5545-4996-82d4-d9ad8e81896c 32.00 @ 0.1200 combo df0489de-8f36-4fde-818e-adeaf038bde3
[closed] 79b805cc placed

A 2.00 stake against a 30.00 win prices at 1 x 2 / (2 + 30) = 0.0625, which buys 32 whole contracts. The cash out paid 3.84 for all 32, 0.12 a contract. The same run at WIN_MULTIPLE=3 CASH_OUT_PRICE=0.02, with another maker quoting more:

[request] 7fb32026 buy, stake 2.0000, 2 legs: WSH @ CHA won, LA @ NYR won
[quoted] 7fb32026 win 6.0000 for 30 s quote bc38ca50-6d36-40fe-8d27-000afd0c04c6
[lost] 7fb32026 quote bc38ca50-6d36-40fe-8d27-000afd0c04c6 placed_elsewhere
[closed] 7fb32026 placed
[request] a55f671b cash out of 32.00 on combo df0489de
[quoted] a55f671b pay 0.6400 for 32.00 for 30 s quote a0dc4ae3-9651-4386-b69e-b3441b0883e4
[lost] a55f671b quote a0dc4ae3-9651-4386-b69e-b3441b0883e4 placed_elsewhere
[closed] a55f671b placed

The SDKs do all of this for you. On a plain socket, each point below is something the exchange enforces:

  • Send a User-Agent. A handshake without one is refused by the edge with a 403 from CloudFront, an HTML body rather than the exchange’s JSON, even when it is correctly signed.
  • Sign the handshake. Sign GET and /socket/websocket with the same X-STX-ACCESS-* headers as REST, as Sign the socket and Authentication describe. The timestamp is good for 30 seconds, so sign again on every reconnect.
  • Heartbeat. Send [null, ref, "phoenix", "heartbeat", {}] every 30 seconds; the socket closes after 60 seconds without one. See Keep the connection alive.
  • Reuse the topic’s join_ref. The ref of your phx_join is the channel’s join_ref, and every quote and withdraw_quote to the topic must carry it. A frame with an old join_ref is dropped without an answer. rfq_opened and rfq_closed arrive with that join_ref too; see Wire format.
  • On reconnect, rejoin with a fresh join_ref. Quotes belong to the connection that placed them: a drop withdraws them, and nothing is restored. Rejoin, then quote again as new requests arrive. Requests opened while you were away are not replayed. After a reconnect has the rest.
  • Money is a string. "amount":"30.00", never a JSON number, which is refused with malformed_money.

The official Phoenix JavaScript client, the phoenix npm package, also works on Node. Give it a ws transport that signs in its constructor, so each reconnect is signed afresh, as watch.mjs in stx-api-examples does. It manages join_ref, heartbeats and rejoins for you.

  • Get a demo read_write key. A read_only key is refused at the join with This API key does not have write access.
  • Open requests from a second account. Quoting a request your own account opened is refused with cannot_quote_own_request. Run a player from another account, for example the combos example in the same repository (examples/combos.py, npm run combos), which buys a small combo, cashes it out and takes the best quote each time.
  • Pick legs that start more than a day out. A combo with a leg starting 12 to 24 hours out can refuse accepts with wrong_market_state for a while.
  • Other makers quote too. placed_elsewhere means another maker’s quote was better. To see a win, quote more than the player is already offered.
  • Run one maker per account. Every socket joined as one account gets the pushes about all of that account’s quotes, including quotes placed from its other sockets.