# Quote combos as a maker

> Join the maker channel, quote every combo request at your own amount and expiry, and handle each outcome. A complete maker in Python, TypeScript, C# and over a plain WebSocket.

Source: https://docs.stxapp.io/websockets/combo-maker/

A player asks for quotes on a [combo](/concepts/combos/): 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](/websockets/channels/combo-negotiation/) documents every frame, field and refusal it relies on.

## Before you start

Quoting needs an API key with `read_write` scope: a `read_only` key is refused when it joins. [Environments](/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

| 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](/concepts/combos/#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

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.

**Python**

[`examples/combo_maker.py`](https://github.com/stxapp/stx-python-demo/blob/main/examples/combo_maker.py) in [stx-python-demo](https://github.com/stxapp/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.

```python
"""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`](https://github.com/stxapp/stx-python-demo/blob/main/examples/helpers.py):

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

**TypeScript**

[`examples/combo_maker.ts`](https://github.com/stxapp/stx-typescript-demo/blob/main/examples/combo_maker.ts) in [stx-typescript-demo](https://github.com/stxapp/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.

```ts
// 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`](https://github.com/stxapp/stx-typescript-demo/blob/main/src/helpers.ts):

```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);
}
```

**C#**

[`combo-maker/`](https://github.com/stxapp/stx-csharp-demo/tree/main/combo-maker) in [stx-csharp-demo](https://github.com/stxapp/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.

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

```csharp
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);
}
```

**Raw WebSocket**

[`python/websockets/combo_maker.py`](https://github.com/stxapp/stx-api-examples/blob/main/python/websockets/combo_maker.py) in [stx-api-examples](https://github.com/stxapp/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`](https://github.com/stxapp/stx-api-examples/blob/main/javascript/websockets/combo_maker.mjs). `--frames` prints every frame sent and received; the repository's [CHANNELS.md](https://github.com/stxapp/stx-api-examples/blob/main/CHANNELS.md#combos-combo_quoteuser_id-and-rfquser_id) shows them.

```python
#!/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 combo
they hold to cash out. Every maker joined to its `combo_quote:<user_id>` topic
receives each request as `rfq_opened`. This script quotes all of them and
prints 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 a
cash 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 in
python/.venv.
"""

import argparse
import asyncio
import itertools
import json
import os
import sys
import time
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal

try:
    import requests
    import websockets
except 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

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:

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

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

## 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 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](/websockets/#sign-the-socket-then-join-what-you-need) and [Authentication](/api/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](/websockets/#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](/websockets/channels/combo-negotiation/#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](/websockets/#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`](https://github.com/stxapp/stx-api-examples/blob/main/javascript/websockets/watch.mjs) in stx-api-examples does. It manages `join_ref`, heartbeats and rejoins for you.

## Testing your maker

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