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.
Before you start
Section titled “Before you start”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.
What the maker does
Section titled “What the maker does”| 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.
The code
Section titled “The code”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 acombo they hold to cash out. Every maker joined to its combo_quote channelreceives each request. This script quotes all of them and prints whathappens next: your quote placed (quote_accepted, then placed), anothermaker's placed instead (placed_elsewhere), your quote expiring, and therequest closing. It needs a read_write API key.
On a buy you quote what the player wins: the stake times WIN_MULTIPLE. On acash 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 asyncioimport osimport sysimport time
from stx import AsyncSTX, STXComboException, STXCombosUnavailableExceptionfrom stx.combos import ComboQuoteChannel, RfqOpened
from helpers import maker_quote_amount
run_seconds = float(sys.argv[1]) if len(sys.argv) > 1 else Nonewin_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, Decimalfrom 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}"examples/combo_maker.ts in stx-typescript-demo, on @stxapp/stx-typescript 0.9.0. npm run combo-maker 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 topic// 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: npm run combo-maker (quote until Ctrl-C)// npm run combo-maker -- 120 (stop after 120 s)// WIN_MULTIPLE=12 CASH_OUT_PRICE=0.10 QUOTE_SECONDS=60 npm run combo-maker
import { STX, STXComboRefusedException, STXCombosUnavailableException, STXTimeoutException, type ComboMaker, type ComboMakerEvent, type ComboRfqOpenedEvent,} from "@stxapp/stx-typescript";import { makerQuoteAmount } from "../src/helpers.ts";
const runSeconds = process.argv[2] ? Number(process.argv[2]) : null;const winMultiple = process.env.WIN_MULTIPLE ?? "9";const cashOutPrice = process.env.CASH_OUT_PRICE ?? "0.08";const quoteMs = Math.round(Number(process.env.QUOTE_SECONDS ?? 30) * 1000);const client = new STX();
// 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.const ws = await client .websocket({ onReconnect: () => console.log("[reconnected] quotes from before the drop were withdrawn") }) .connect();process.on("SIGINT", () => { console.log("\nStopped. Quotes still standing were withdrawn with the connection."); void ws.close().finally(() => process.exit(0));});try { process.exitCode = await quoteEverything();} finally { await ws.close();}
async function quoteEverything(): Promise<number> { let maker: ComboMaker; try { maker = await ws.comboMaker(); } catch (err) { if (!(err instanceof STXCombosUnavailableException)) throw err; console.log(`Combos are not available on ${client.baseUrl}.`); return 1; } console.log(`Quoting combos on ${client.baseUrl} as a maker.`); console.log( `Buys: win ${winMultiple}x the stake. Cash outs: ${cashOutPrice} a contract. Each quote stands ${quoteMs / 1000} s. Ctrl-C to stop.\n`, );
const deadline = runSeconds ? Date.now() + runSeconds * 1000 : null; while (deadline === null || Date.now() < deadline) { let event: ComboMakerEvent; try { event = await maker.next(deadline === null ? null : Math.max(1, deadline - Date.now())); } catch (err) { if (err instanceof STXTimeoutException) break; throw err; } const rfq = event.payload.rfq_id.slice(0, 8); switch (event.event) { case "rfq_opened": // Every request opened after the join, from every player, your own included. console.log(`[request] ${rfq} ${describe(event.payload)}`); await quote(maker, event.payload); break; case "quote_accepted": // The player took your quote; the exchange is placing both orders now. console.log(`[accepted] ${rfq} quote ${event.payload.quote_id}`); break; case "placed": { const p = event.payload; console.log(`[won] ${rfq} order ${p.order_id} ${p.quantity} @ ${p.price} combo ${p.market_id}`); break; } case "negotiation_closed": { // One of your quotes stopped standing, once per quote, with the reason. const p = event.payload; const label = p.reason === "placed_elsewhere" ? "[lost] " : p.reason === "expired" ? "[expired]" : "[ended] "; console.log(`${label} ${rfq} quote ${p.quote_id} ${p.reason}`); break; } case "rfq_closed": console.log(`[closed] ${rfq} ${event.payload.reason}`); break; } } return 0;}
function describe(request: ComboRfqOpenedEvent): string { if (request.side === "buy") { const legs = request.legs.map((leg) => `${leg.short_name ?? leg.market_id.slice(0, 8)} ${leg.outcome}`).join(", "); return `buy, stake ${request.amount}, ${request.legs.length} legs: ${legs}`; } return `cash out of ${request.quantity} on combo ${request.market_id?.slice(0, 8)}`;}
async function quote(maker: ComboMaker, request: ComboRfqOpenedEvent): Promise<void> { const rfq = request.rfq_id.slice(0, 8); const amount = makerQuoteAmount(request, winMultiple, cashOutPrice); if (amount === null) { console.log(`[skip] ${rfq} no amount prices inside 0 to ${request.max_price}`); return; } try { const q = await maker.quote(request.rfq_id, amount, quoteMs, { clientOrderId: `maker-${rfq}` }); const what = request.side === "buy" ? `win ${q.amount}` : `pay ${q.amount} for ${request.quantity}`; console.log(`[quoted] ${rfq} ${what} for ${quoteMs / 1000} s quote ${q.quote_id}`); } catch (err) { // e.g. cannot_quote_own_request, or not_found when the request ended first if (!(err instanceof STXComboRefusedException)) throw err; console.log(`[refused] ${rfq} ${err.code}`); }}The amount check, from src/helpers.ts:
import type { ComboRfqOpenedEvent } from "@stxapp/stx-typescript";
// Exact decimal arithmetic for the maker's quote check: amounts and ticks as// integers of 10^-8 dollars, so no price is rounded through a float.const SCALE = 10n ** 8n;
function toUnits(value: string): bigint { const match = /^(\d+)(?:\.(\d+))?$/.exec(value.trim()); if (!match) throw new Error(`not a decimal string: ${JSON.stringify(value)}`); const decimals = (match[2] ?? "").padEnd(8, "0").slice(0, 8); return BigInt(match[1]!) * SCALE + BigInt(decimals);}
/** Integer division rounded down (`up` false) or up (`up` true), for non-negative values. */function divide(a: bigint, b: bigint, up: boolean): bigint { return up ? (a + b - 1n) / b : a / b;}
/** Whole cents of `units`, rounded down, as a dollar string: 333250000n becomes "3.33". */function centsString(units: bigint): string { const cents = units / (SCALE / 100n); return `${cents / 100n}.${String(cents % 100n).padStart(2, "0")}`;}
/** * The amount a maker quotes on `request`, in whole cents, or null when the * exchange would refuse it. * * On a buy the amount is what the player wins: the stake times `winMultiple`. * On a cash out it is what you pay for the whole size: `cashOutPrice` 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. */export function makerQuoteAmount( request: Pick<ComboRfqOpenedEvent, "side" | "amount" | "quantity" | "max_price" | "tick_size">, winMultiple: string, cashOutPrice: string,): string | null { const maxPrice = toUnits(request.max_price); const tick = toUnits(request.tick_size); const cent = SCALE / 100n; if (request.side === "buy") { const stake = toUnits(request.amount ?? "0"); const amount = ((stake * toUnits(winMultiple)) / SCALE / cent) * cent; if (amount <= 0n) return null; // max_price x stake / (stake + amount), rounded up to the tick. const price = divide(maxPrice * stake, (stake + amount) * tick, true) * tick; if (price <= 0n || price >= maxPrice || stake / price < 1n) return null; return centsString(amount); } const quantity = toUnits(request.quantity ?? "0"); const amount = ((quantity * toUnits(cashOutPrice)) / SCALE / cent) * cent; if (quantity <= 0n) return null; // amount / quantity, rounded down to the tick. const price = divide(amount * SCALE, quantity * tick, false) * tick; if (price <= 0n || price >= maxPrice) return null; return centsString(amount);}combo-maker/ in stx-csharp-demo, on STX.Sdk 2.0.0 and .NET 10. dotnet run 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 program 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.//// dotnet run # quote until Ctrl+C// dotnet run -- --seconds 120 # stop after 120 s// dotnet run -- --win-multiple 12 --cash-out-price 0.10 --quote-seconds 60// dotnet run -- --profile my-demo # a named profile from ~/.stx/credentials//// On a buy you quote what the player wins: the stake times --win-multiple (default 9). On a cash// out you quote what you pay for everything the player sells: --cash-out-price a contract (default// 0.08). Each quote stands for --quote-seconds (default 30).
using System.Globalization;using STX.Sdk;using STX.Sdk.Combos;
var winMultiple = decimal.Parse(Arg("--win-multiple") ?? "9", CultureInfo.InvariantCulture);var cashOutPrice = decimal.Parse(Arg("--cash-out-price") ?? "0.08", CultureInfo.InvariantCulture);var quoteMs = (int)(double.Parse(Arg("--quote-seconds") ?? "30", CultureInfo.InvariantCulture) * 1000);var runSeconds = Arg("--seconds") is { } s ? double.Parse(s, CultureInfo.InvariantCulture) : (double?)null;
// Ctrl+C, or --seconds running out, ends the run.using var stop = runSeconds is { } secs ? new CancellationTokenSource(TimeSpan.FromSeconds(secs)) : new CancellationTokenSource();Console.CancelKeyPress += (_, e) => { e.Cancel = true; stop.Cancel(); };
using var client = new STXClient(new STXClientOptions { Profile = Arg("--profile") });
// 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.await using var ws = client.CreateWebSocket(new STXWebSocketOptions{ OnReconnect = () => { Console.WriteLine("[reconnected] quotes from before the drop were withdrawn"); return Task.CompletedTask; },});
STXComboQuoteChannel maker;try{ maker = await ws.ComboQuoteAsync();}catch (STXCombosUnavailableException){ Console.WriteLine($"Combos are not available on {client.Host}."); return 1;}
Console.WriteLine($"Quoting combos on {client.Host} as a maker.");Console.WriteLine($"Buys: win {winMultiple}x the stake. Cash outs: {cashOutPrice} a contract. Each quote stands {quoteMs / 1000} s. Ctrl+C to stop.");Console.WriteLine();
try{ await foreach (var msg in maker.ReadAllAsync(stop.Token)) { switch (msg.Data) { case RfqOpened request: // Every request opened after the join, from every player, your own included. Console.WriteLine($"[request] {Short(request.RfqId)} {Describe(request)}"); await QuoteAsync(request); break; case QuoteAccepted accepted: // The player took your quote; the exchange is placing both orders now. Console.WriteLine($"[accepted] {Short(accepted.RfqId)} quote {accepted.QuoteId}"); break; case MakerPlacement placed: Console.WriteLine($"[won] {Short(placed.RfqId)} order {placed.OrderId} {placed.Quantity} @ {placed.Price} combo {placed.MarketId}"); break; case NegotiationClosed closed: // One of your quotes stopped standing, once per quote, with the reason. var label = closed.Reason switch { "placed_elsewhere" => "[lost] ", "expired" => "[expired]", _ => "[ended] " }; Console.WriteLine($"{label} {Short(closed.RfqId)} quote {closed.QuoteId} {closed.Reason}"); break; case RfqClosed ended: Console.WriteLine($"[closed] {Short(ended.RfqId)} {ended.Reason}"); break; } }}catch (OperationCanceledException){}
Console.WriteLine();Console.WriteLine("Stopped. Quotes still standing were withdrawn with the connection.");return 0;
async Task QuoteAsync(RfqOpened request){ var amount = MakerQuote.Amount(request, winMultiple, cashOutPrice); if (amount is null) { Console.WriteLine($"[skip] {Short(request.RfqId)} no amount prices inside 0 to {request.MaxPrice}"); return; } try { var quote = await maker.QuoteAsync(request.RfqId!, amount, quoteMs, clientOrderId: $"maker-{Short(request.RfqId)}", ct: stop.Token); var what = request.Side == "buy" ? $"win {quote.Amount}" : $"pay {quote.Amount} for {request.Quantity}"; Console.WriteLine($"[quoted] {Short(request.RfqId)} {what} for {quoteMs / 1000} s quote {quote.QuoteId}"); } catch (STXComboException ex) { // e.g. cannot_quote_own_request, or not_found when the request ended first Console.WriteLine($"[refused] {Short(request.RfqId)} {ex.ComboReason}"); }}
static string Describe(RfqOpened request){ if (request.Side == "buy") { var legs = string.Join(", ", (request.Legs ?? []).Select(leg => $"{leg.ShortName ?? Short(leg.MarketId)} {leg.Outcome}")); return $"buy, stake {request.Amount}, {request.Legs?.Count} legs: {legs}"; } return $"cash out of {request.Quantity} on combo {Short(request.MarketId)}";}
static string Short(string? id) => id is null ? "-" : id[..Math.Min(8, id.Length)];
string? Arg(string name){ var i = Array.IndexOf(args, name); return i >= 0 && i + 1 < args.Length ? args[i + 1] : null;}MakerQuote.cs:
using System.Globalization;using STX.Sdk.Combos;
/// <summary>The amount this maker quotes on a request.</summary>static class MakerQuote{ /// <summary> /// The amount to quote on <paramref name="request"/>, in whole cents, or null when the exchange /// would refuse it. On a buy it is what the player wins: the stake times /// <paramref name="winMultiple"/>. On a cash out it is what you pay for the whole size: /// <paramref name="cashOutPrice"/> a contract. The exchange derives a price from the amount, /// rounded to the request's tick 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. /// </summary> public static string? Amount(RfqOpened request, decimal winMultiple, decimal cashOutPrice) { var maxPrice = Dec(request.MaxPrice); var tick = Dec(request.TickSize); decimal amount, price; if (request.Side == "buy") { var stake = Dec(request.Amount); amount = Math.Floor(stake * winMultiple * 100) / 100; if (amount <= 0) return null; // max_price x stake / (stake + amount), rounded up to the tick. price = Math.Ceiling(maxPrice * stake / (stake + amount) / tick) * tick; if (price <= 0 || price >= maxPrice || Math.Floor(stake / price) < 1) return null; } else { var quantity = Dec(request.Quantity); amount = Math.Floor(quantity * cashOutPrice * 100) / 100; if (quantity <= 0) return null; // amount / quantity, rounded down to the tick. price = Math.Floor(amount / quantity / tick) * tick; if (price <= 0 || price >= maxPrice) return null; } return amount.ToString("F2", CultureInfo.InvariantCulture); }
private static decimal Dec(string? value) => decimal.Parse(value ?? "0", CultureInfo.InvariantCulture);}python/websockets/combo_maker.py in stx-api-examples: no SDK, only websockets and cryptography, with the signing in that repository’s python/stx.py. The same maker in JavaScript is javascript/websockets/combo_maker.mjs. --frames prints every frame sent and received; the repository’s CHANNELS.md shows them.
#!/usr/bin/env python3"""Quote every combo request as a maker, over a plain Phoenix socket.
python python/websockets/combo_maker.py python python/websockets/combo_maker.py --win-multiple 12 --cash-out-price 0.10 python python/websockets/combo_maker.py --frames # also print every frame
Players ask for quotes on a combo: a set of legs and a stake to buy, or a combothey hold to cash out. Every maker joined to its `combo_quote:<user_id>` topicreceives each request as `rfq_opened`. This script quotes all of them andprints what happens next, until Ctrl-C:
[won] quote_accepted, then placed: your quote was taken [lost] negotiation_closed placed_elsewhere: another maker's was [expired] negotiation_closed expired: your quote ran out [closed] rfq_closed: the request ended, for any reason
On a buy you quote what the player wins: the stake times --win-multiple. On acash out you quote what you pay for everything the player sells:--cash-out-price a contract. Each quote stands for --quote-seconds.
Combos need a read_write key: a read_only key is refused at the join.CHANNELS.md has every frame this sends and receives.
Requires the packages in python/requirements.txt; ``./install.sh`` puts them inpython/.venv."""
import argparseimport asyncioimport itertoolsimport jsonimport osimport sysimport timefrom decimal import ROUND_CEILING, ROUND_FLOOR, Decimal
try: import requests import websocketsexcept ModuleNotFoundError as error: raise SystemExit( f"{error.name} is not installed. Activate the virtualenv ./install.sh made:\n" f" source python/.venv/bin/activate" ) from None
# stx.py sits in python/. Appended so this directory cannot shadow `websockets`.sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))import stx # noqa: E402
# The socket closes after 60 s without a heartbeat, so send one every 30.HEARTBEAT_SECONDS = 30
# Seconds to wait before reconnecting, doubling up to the last value.BACKOFF = [1, 2, 5, 10, 30]
CENT = Decimal("0.01")
def stamp(): return time.strftime("%H:%M:%S", time.gmtime())
def short(uuid): return (uuid or "-")[:8]
def quote_amount(request, win_multiple, cash_out_price): """The amount to quote on an rfq_opened payload, or None when it would be refused.
The exchange derives a price from the amount, rounded to `tick_size` in the maker's 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). Money stays in Decimal, never float. """ max_price = Decimal(request["max_price"]) tick = Decimal(request["tick_size"]) if request["side"] == "buy": # A buy: what the player wins on their stake. stake = Decimal(request["amount"]) amount = (stake * win_multiple).quantize(CENT, rounding=ROUND_FLOOR) if amount <= 0: return None price = (max_price * stake / (stake + amount) / tick).to_integral_value(ROUND_CEILING) * tick if not 0 < price < max_price or (stake / price).to_integral_value(ROUND_FLOOR) < 1: return None else: # A cash out: what you pay for the whole quantity. quantity = Decimal(request["quantity"]) amount = (quantity * cash_out_price).quantize(CENT, rounding=ROUND_FLOOR) price = (amount / quantity / tick).to_integral_value(ROUND_FLOOR) * tick if not 0 < price < max_price: return None return f"{amount:.2f}"
def describe(request): if request["side"] == "buy": legs = ", ".join(f"{leg.get('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'])}"
class Maker: def __init__(self, config, private_key, user_id, args): self.config, self.private_key, self.args = config, private_key, args self.topic = f"combo_quote:{user_id}" self.refs = itertools.count(1) # What each command we sent was, by its ref, so its phx_reply can be explained. self.pending = {} self.joins = 0
async def send(self, ws, frame): text = json.dumps(frame, separators=(",", ":")) if self.args.frames: print(f"{stamp()} -> {text}") await ws.send(text)
async def heartbeat(self, ws): # On the `phoenix` topic, with a null join_ref: it belongs to no channel. while True: await asyncio.sleep(HEARTBEAT_SECONDS) await self.send(ws, [None, str(next(self.refs)), "phoenix", "heartbeat", {}])
async def session(self): """One connection: sign, connect, join, then quote until the socket drops.""" # A fresh signature on every connect: the timestamp is good for 30 s. headers = stx.signed_headers(self.private_key, self.config["key_id"], "GET", stx.SOCKET_PATH) async with websockets.connect(f"{self.config['socket_url']}?vsn=2.0.0", additional_headers=headers) as ws: # The join's ref becomes the channel's join_ref. Every later frame to the topic # must carry it: the exchange silently drops a frame with an old one. join_ref = str(next(self.refs)) self.pending = {join_ref: ("join", None)} await self.send(ws, [join_ref, join_ref, self.topic, "phx_join", {}]) keepalive = asyncio.create_task(self.heartbeat(ws)) try: async for raw in ws: if self.args.frames: print(f"{stamp()} <- {raw}") frame_join_ref, ref, topic, event, payload = json.loads(raw) if topic == "phoenix": continue # heartbeat replies if event == "phx_reply": self.reply(ref, payload) elif event == "rfq_opened": # Every request opened after the join, from every player, your own included. print(f"[request] {short(payload['rfq_id'])} {describe(payload)}") await self.quote(ws, join_ref, payload) elif event == "quote_accepted": # The player took your quote; the exchange is placing both orders now. print(f"[accepted] {short(payload['rfq_id'])} quote {payload['quote_id']}") elif event == "placed": print(f"[won] {short(payload['rfq_id'])} order {payload['order_id']} " f"{payload['quantity']} @ {payload['price']} combo {payload['market_id']}") elif event == "negotiation_closed": # One of your quotes stopped standing, once per quote, with the reason. label = {"placed_elsewhere": "[lost] ", "expired": "[expired]"}.get(payload["reason"], "[ended] ") print(f"{label} {short(payload['rfq_id'])} quote {payload['quote_id']} " f"{payload['reason']}") elif event == "rfq_closed": print(f"[closed] {short(payload['rfq_id'])} {payload['reason']}") elif event in ("phx_error", "phx_close") and frame_join_ref == join_ref: # The channel ended on a live socket: its quotes are gone. Reconnect. print(f"[channel] {event}; reconnecting") return finally: keepalive.cancel()
async def quote(self, ws, join_ref, request): amount = quote_amount(request, self.args.win_multiple, self.args.cash_out_price) if amount is None: print(f"[skip] {short(request['rfq_id'])} no amount prices inside 0 to {request['max_price']}") return ref = str(next(self.refs)) self.pending[ref] = ("quote", request) await self.send(ws, [join_ref, ref, self.topic, "quote", { "rfq_id": request["rfq_id"], "amount": amount, # a string: a JSON number is refused with malformed_money "expires_in_ms": int(self.args.quote_seconds * 1000), "client_order_id": f"maker-{short(request['rfq_id'])}", }])
def reply(self, ref, payload): kind, request = self.pending.pop(ref, (None, None)) status, response = payload.get("status"), payload.get("response") or {} if kind == "join": if status != "ok": # unmatched topic: no combos on this exchange. "This API key does not have # write access": a read_only key. Neither changes on a retry. raise SystemExit(f"Join refused: {response.get('reason')}") self.joins += 1 if self.joins > 1: print(f"[socket] rejoined {self.topic}, join_ref {ref}") return print(f"Joined {self.topic} on {self.config['base_url']} as a maker.") print(f"Buys: win {self.args.win_multiple}x the stake. Cash outs: {self.args.cash_out_price} " f"a contract. Each quote stands {self.args.quote_seconds:g} s. Ctrl-C to stop.\n") elif kind == "quote": rfq = short(request["rfq_id"]) if status != "ok": # e.g. cannot_quote_own_request, or not_found when the request ended first print(f"[refused] {rfq} {response.get('reason')}") elif request["side"] == "buy": print(f"[quoted] {rfq} win {response['amount']} quote {response['quote_id']}") else: print(f"[quoted] {rfq} pay {response['amount']} for {request['quantity']} " f"quote {response['quote_id']}")
async def run(self): attempt = 0 while True: started = time.monotonic() try: await self.session() except (OSError, websockets.exceptions.WebSocketException) as error: # A 403 from CloudFront here, with an HTML body, means no User-Agent was sent. print(f"[socket] {type(error).__name__}: {error}") # Quotes belong to the connection that placed them, so a drop withdrew them. # Reconnecting re-signs, rejoins with a fresh join_ref and starts the heartbeat again. attempt = 0 if time.monotonic() - started > 60 else attempt + 1 delay = BACKOFF[min(attempt, len(BACKOFF) - 1)] print(f"[socket] disconnected; quotes were withdrawn. Reconnecting in {delay} s") await asyncio.sleep(delay)
def main(): parser = argparse.ArgumentParser(description="Quote every combo request as a maker.") parser.add_argument("--profile", help="profile in ~/.stx/credentials") parser.add_argument("--win-multiple", type=Decimal, default=Decimal("9"), help="on a buy, quote the stake times this (default 9)") parser.add_argument("--cash-out-price", type=Decimal, default=Decimal("0.08"), help="on a cash out, pay this a contract (default 0.08)") parser.add_argument("--quote-seconds", type=float, default=30, help="how long each quote stands (default 30)") parser.add_argument("--seconds", type=float, help="stop after this many seconds (default: run until Ctrl-C)") parser.add_argument("--frames", action="store_true", help="also print every frame sent and received") args = parser.parse_args()
config = stx.load_profile(args.profile) private_key = stx.load_private_key(config)
# The topic is your own user id, from GET /api/v1/me. headers = stx.signed_headers(private_key, config["key_id"], "GET", "/api/v1/me") try: me = requests.get(config["base_url"] + "/api/v1/me", headers=headers, timeout=15) except requests.exceptions.ConnectionError as error: sys.exit(stx.unreachable(config["base_url"], error)) me.raise_for_status()
maker = Maker(config, private_key, me.json()["me"]["user_id"], args) try: asyncio.run(asyncio.wait_for(maker.run(), args.seconds) if args.seconds else maker.run()) except (KeyboardInterrupt, asyncio.TimeoutError): print("\nStopped. Quotes still standing were withdrawn with the connection.")
if __name__ == "__main__": main()What a run looks like
Section titled “What a run looks like”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 placedA 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 placedWithout an SDK
Section titled “Without an SDK”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 a403from CloudFront, an HTML body rather than the exchange’s JSON, even when it is correctly signed. - Sign the handshake. Sign
GETand/socket/websocketwith the sameX-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 yourphx_joinis the channel’sjoin_ref, and everyquoteandwithdraw_quoteto the topic must carry it. A frame with an oldjoin_refis dropped without an answer.rfq_openedandrfq_closedarrive with thatjoin_reftoo; 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 withmalformed_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.
Testing your maker
Section titled “Testing your maker”- Get a demo
read_writekey. Aread_onlykey is refused at the join withThis 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_statefor a while. - Other makers quote too.
placed_elsewheremeans 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.

