# Build a trading bot

Build a small two-sided quoting bot on Testnet with the official Python SDK, from keys and Pair rules to a clean shutdown.

This tutorial builds one Python script that keeps a single post-only bid and ask on a Testnet Pair. It uses the official Python SDK, `polyester-sdk`, and covers what every quoting bot needs: Pair rules, client order IDs, batch replace, the private stream, the dead-man switch, error handling, reconnects, and a clean shutdown. Each step adds one section of the script, and the full script is at the end of the page.

Everything here runs on Testnet, at `api.testnet.polyester.com`. Mainnet is coming soon. For the wider set of decisions this bot is a small example of, see the [Market maker guide](https://testnet.polyester.com/docs/developer-docs/getting-started/market-maker-guide).

> **Example code for Testnet, not trading advice**
>
> This bot is a teaching example for Testnet. Its pricing is deliberately naive, and nothing on this page is trading advice. Run it only on Testnet, in a Subaccount that holds nothing else, and test every failure path yourself before you adapt any part of it.

## Before you start

The bot trades for one Subaccount. A Subaccount has its own balances, orders, rate-limit pools, and dead-man switch, so a crash or a bug in this strategy stays inside it.

Review checklist

Prepare your Testnet account

- Python 3.11 or later
- [A Subaccount for this strategy, with a Subaccount policy that allows trading](https://testnet.polyester.com/docs/developer-docs/account/subaccounts-and-roles)
- [An API key bound to that Subaccount, with an API key policy that grants TRADE_SPOT on the Pair](https://testnet.polyester.com/docs/developer-docs/account/api-key-policies)
- [Test funds in the Subaccount's Unified Trading Account: the quote Asset for bids and the base Asset for asks](https://testnet.polyester.com/docs/developer-docs/getting-started/quickstart#get-test-funds)

If your test funds are in your Main Account, move them to the Subaccount's Unified Trading Account with an [internal transfer](https://testnet.polyester.com/docs/developer-docs/funds-transfers/internal-transfers).

Install the SDK, pinned to the release this tutorial uses, and set the values the script reads:

```bash
pip install "polyester-sdk==0.6.0"
export POLYESTER_API_KEY_ID="ak_..."
export POLYESTER_API_PRIVATE_KEY="<64 hex characters>"
export POLYESTER_SUBACCOUNT_ID="<the Subaccount's public ID>"
export BOT_SYMBOL="BTC-USDT"
export BOT_QTY="0.0001"
```

`BOT_QTY` is an example size. The script checks it against the Pair's step size and minimums before it quotes. Key creation and signing are covered in the [Quickstart](https://testnet.polyester.com/docs/developer-docs/getting-started/quickstart), and hosts in [Environments and hosts](https://testnet.polyester.com/docs/developer-docs/getting-started/environments-and-hosts).

## Build the bot

1. Create the client

   ```python
   """Two-sided post-only quoting bot for Polyester Testnet.

   Example code for Testnet only. It is not trading advice.
   """

   import asyncio
   import os
   import random
   import signal
   import time
   import uuid
   from dataclasses import dataclass, field
   from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal

   from polyester import (
       POLYESTER_TESTNET_ENVIRONMENT,
       AsyncPolyester,
       PolyesterAuthError,
       PolyesterRateLimitError,
       PolyesterRealtimeError,
       PolyesterValidationError,
       is_not_found,
       mutation_outcome_unknown,
   )
   from polyester.models import (
       ClientOrderId,
       PrivateOrderCommandOutcome,
       PrivateOrderEvent,
       PrivateOrdersDisconnected,
       PrivateOrdersError,
       PrivateOrdersReady,
       PrivateOrdersSnapshot,
       PrivateOrdersStale,
       PrivateOrdersUnit,
   )

   SUBACCOUNT_ID = os.environ["POLYESTER_SUBACCOUNT_ID"]
   SYMBOL = os.environ.get("BOT_SYMBOL", "BTC-USDT")
   QTY = Decimal(os.environ.get("BOT_QTY", "0.0001"))  # base quantity of each quote
   HALF_SPREAD_BPS = Decimal(os.environ.get("BOT_HALF_SPREAD_BPS", "20"))
   REQUOTE_TICKS = int(os.environ.get("BOT_REQUOTE_TICKS", "5"))
   MAX_INVENTORY = Decimal(os.environ.get("BOT_MAX_INVENTORY", "0.001"))  # net base filled
   PREFIX = "qb1"  # marks this bot's client order IDs
   LOOP_SECONDS = 2.0
   PENDING_SECONDS = 5.0
   RULES_REFRESH_SECONDS = 60.0
   DMS_TIMEOUT_SECONDS = 30
   DMS_REFRESH_SECONDS = 10

   QUOTABLE = {"PAIR_STATUS_ENABLED", "PAIR_STATUS_POST_ONLY"}
   TERMINAL = {"filled", "canceled", "rejected"}
   GONE_CODES = {"ORDER_UNKNOWN", "ORDER_ALREADY_TERMINAL"}
   BACKOFF_CODES = {"SYSTEM_RATE_LIMITED", "OVERLOADED"}
   RULE_CODES = {
       "PRICE_TICK_SIZE", "QTY_STEP_SIZE", "MIN_QTY", "MIN_NOTIONAL", "MAX_QTY",
       "MAX_NOTIONAL", "PAIR_DISABLED", "PAIR_CANCEL_ONLY", "PAIR_POST_ONLY",
       "PAIR_REDUCE_ONLY", "MARKET_HALTED",
   }


   def new_id(kind: str) -> str:
       return f"{PREFIX}-{kind}-{uuid.uuid4().hex[:16]}"


   def code_of(value: object) -> str:
       """One spelling for an error code, whichever call reported it."""
       raw = value if isinstance(value, str) else getattr(value, "code", None)
       return str(raw or "").upper().removeprefix("ERROR_CODE_")


   def plain(value: Decimal) -> str:
       return format(value, "f")


   def make_client() -> AsyncPolyester:
       return AsyncPolyester(
           environment=POLYESTER_TESTNET_ENVIRONMENT,
           api_key_id=os.environ["POLYESTER_API_KEY_ID"],
           api_private_key=os.environ["POLYESTER_API_PRIVATE_KEY"],
           default_sub_account_id=SUBACCOUNT_ID,
       )
   ```

   Pass `POLYESTER_TESTNET_ENVIRONMENT` explicitly: it points the client at the Testnet API and WebSocket hosts. `default_sub_account_id` scopes every order call to the bot's Subaccount, the same Subaccount a bound key acts on. See [Target a Subaccount in API calls](https://testnet.polyester.com/docs/developer-docs/account/subaccounts-and-roles#target-a-subaccount-in-api-calls).

   Every client order ID the bot creates starts with `qb1-`, so it can tell its own orders apart. `code_of` exists because the SDK reports an error code with or without its `ERROR_CODE_` prefix, depending on the call, and the bot compares codes in one spelling.

2. Load the Pair rules

   ```python
   @dataclass
   class PairRules:
       symbol_id: int
       status: str
       tick: Decimal
       step: Decimal
       min_qty: Decimal
       min_notional: Decimal


   async def load_rules(client: AsyncPolyester) -> PairRules:
       config = await client.market_data.get_spot_config()
       pair = next((p for p in config.raw["pairs"] if p["symbol"] == SYMBOL), None)
       if pair is None:
           raise SystemExit(f"{SYMBOL} is not listed")
       rules = PairRules(
           symbol_id=int(pair["symbolId"]),
           status=pair["status"],
           tick=Decimal(pair["tickSize"]),
           step=Decimal(pair["stepSize"]),
           min_qty=Decimal(pair.get("minQtyBase") or "0"),
           min_notional=Decimal(pair.get("minNotionalQuote") or "0"),
       )
       if QTY % rules.step or QTY < rules.min_qty:
           raise SystemExit(f"BOT_QTY must be a multiple of {rules.step} and at least {rules.min_qty}")
       return rules
   ```

   [`GetSpotConfig`](https://testnet.polyester.com/docs/api-docs/connect/marketdata.v1.MarketDataService/GetSpotConfig) returns each Pair's tick size, step size, minimums, and trading status. The SDK has no typed accessor for these rules, so the bot reads them from `raw`, which uses ConnectRPC JSON names: the status reads `PAIR_STATUS_ENABLED`, not `ENABLED`. The bot quotes only while the Pair is `ENABLED` or `POST_ONLY`. It reloads the rules every minute, and at once after a rejection for a Pair rule. Every field is described in [Pairs and trading rules](https://testnet.polyester.com/docs/developer-docs/market-data/pairs-and-trading-rules).

   Prices and quantities stay `Decimal` values and reach the SDK as decimal strings. The SDK converts them to the scaled integers on the wire. Never pass floats.

3. Send writes safely

   ```python
   async def send(op, *, replayable: bool, attempts: int = 5):
       """Run one write, keeping its identifiers on every retry."""
       for attempt in range(attempts):
           try:
               return await op()
           except PolyesterRateLimitError as err:
               delay = err.retry_after or 0.5 * 2**attempt
           except Exception as err:
               if code_of(err) in BACKOFF_CODES:
                   delay = 0.5 * 2**attempt
               elif replayable and mutation_outcome_unknown(err):
                   delay = 0.5 * 2**attempt  # resend the same request to learn its outcome
               else:
                   raise
           await asyncio.sleep(delay + random.uniform(0, 0.25))
       raise RuntimeError("write did not complete; read the orders before sending it again")
   ```

   Every write goes through `send`, which keeps the same identifiers on every attempt. The SDK signs each attempt again with a fresh timestamp.

   - **Rate limits.** `RATE_LIMIT_EXCEEDED` arrives as `PolyesterRateLimitError`; wait for its `retry_after`. `SYSTEM_RATE_LIMITED` means the venue-wide rate is exhausted, and `OVERLOADED` means too many of your requests are in flight. Neither is your quota, so back off and retry. See [Rate limits](https://testnet.polyester.com/docs/developer-docs/shared-concepts/rate-limits).
   - **Outcome unknown.** A timeout, a dropped connection, or a code ending in `_OUTCOME_UNKNOWN` means the write may have applied, and `mutation_outcome_unknown` detects each of them. Resending the same batch with the same `requestId` within its retry window returns the original results and applies nothing twice. A repeated cancel never cancels twice. See [Partial failure and unknown outcomes](https://testnet.polyester.com/docs/developer-docs/shared-concepts/batch-orders#partial-failure-and-unknown-outcomes).
   - **Every other error** is raised, because resending it unchanged would be refused again. Look it up in [Error codes](https://testnet.polyester.com/docs/developer-docs/error-handling/error-codes) and [Error responses](https://testnet.polyester.com/docs/developer-docs/error-handling/error-responses).

   If `send` gives up, the outcome stays unknown. The quoting step reads the order back before it places another one. To read your effective trading limits, call `client.rate_limits.get_trading_rate_limits()` ([`GET /v1/trading/rate-limits`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/trading/rate-limits)).

4. Follow your orders on the private stream

   ```python
   @dataclass
   class Slot:
       cid: str | None = None  # client order ID of this side's quote
       sent_at: float = 0.0
       prev_cid: str | None = None  # the order a replace in flight is replacing


   @dataclass
   class Bot:
       client: AsyncPolyester
       rules: PairRules
       base_scale: int
       live: dict = field(default_factory=dict)  # order_id -> Order, from the stream
       side_of: dict = field(default_factory=dict)  # order_id -> side
       ended: set = field(default_factory=set)  # client order IDs that ended
       slots: dict = field(default_factory=lambda: {"buy": Slot(), "sell": Slot()})
       inventory: Decimal = Decimal(0)
       stream_ok: asyncio.Event = field(default_factory=asyncio.Event)
       needs_reconcile: bool = True
       snapshots: int = 0
       rules_stale: bool = False
       dms_deadline: float = 0.0
       stop: asyncio.Event = field(default_factory=asyncio.Event)


   def track(bot: Bot, order) -> None:
       if order.symbol_id != bot.rules.symbol_id:
           return
       bot.side_of[order.order_id] = order.side
       if order.status in TERMINAL:
           bot.live.pop(order.order_id, None)
           bot.ended.add(order.client_order_id)
           return
       known = bot.live.get(order.order_id)
       if known is None or order.version >= known.version:
           bot.live[order.order_id] = order


   def on_event(bot: Bot, event) -> None:
       if isinstance(event, PrivateOrderEvent):
           if event.kind == "filled" and event.fill is not None:
               qty = event.fill.qty.as_decimal(bot.base_scale)
               side = bot.side_of.get(event.order_id)
               bot.inventory += qty if side == "buy" else -qty
               print("fill", side, plain(qty), "inventory", plain(bot.inventory))
           elif event.kind == "rejected":
               print("order rejected", event.order_id, event.reject_reason)
               if code_of(event.reject_reason) in RULE_CODES:
                   bot.rules_stale = True
       elif isinstance(event, PrivateOrderCommandOutcome):
           print("command outcome", event.kind, event.reason)


   async def consume(bot: Bot, sub) -> None:
       async for event in sub:
           if isinstance(event, PrivateOrdersSnapshot):
               bot.snapshots += 1
               if bot.snapshots > 1:
                   print("stream resynced; read trade history for fills in the gap")
               bot.live.clear()
               for order in event.orders:
                   track(bot, order)
               bot.needs_reconcile = True
           elif isinstance(event, PrivateOrdersUnit):
               for order in event.orders:
                   track(bot, order)
               for order_id in event.removed_order_ids:
                   gone = bot.live.pop(order_id, None)
                   if gone is not None:
                       bot.ended.add(gone.client_order_id)
               for item in event.events:
                   on_event(bot, item)
           elif isinstance(event, PrivateOrdersReady):
               bot.stream_ok.set()
           elif isinstance(event, PrivateOrdersStale):
               if event.stale:
                   bot.stream_ok.clear()
               else:
                   bot.stream_ok.set()
           elif isinstance(event, PrivateOrdersDisconnected):
               bot.stream_ok.clear()
           elif isinstance(event, PrivateOrdersError):
               print("stream recovering:", event.kind, repr(event.error))
   ```

   `orders.subscribe` streams the Subaccount's orders over the [private WebSocket](https://testnet.polyester.com/docs/developer-docs/connectrpc/private-websocket). The bot builds its view of live orders from four kinds of event:

   - A **snapshot** replaces the whole view.
   - A **unit** carries changed orders, removed order IDs, and events such as fills and rejections. Keep the copy of an order with the higher `version`.
   - **Ready** means the stream reflects every acknowledgement given before the subscribe. The bot quotes only after it.
   - **Stale** and **disconnected** pause quoting until the stream catches up.

   The SDK resumes from its last position after a gap, an expired token, or a reconnect. When the server cannot resume, it sends a new snapshot instead, and the bot reconciles from it in a later step.

   > **Testnet access to the private WebSocket**
   >
   > The private WebSocket may currently refuse API clients with HTTP `403` while Testnet access is being enabled. `subscribe` then raises `PolyesterRealtimeError`, and the bot stops without quoting, because it cannot follow its orders.

5. Arm the dead-man switch

   ```python
   async def heartbeat(bot: Bot) -> None:
       while not bot.stop.is_set():
           request_id = new_id("dms")
           started = time.monotonic()
           try:
               armed = await send(
                   lambda: bot.client.orders.cancel_all_after(
                       timeout_sec=DMS_TIMEOUT_SECONDS, symbol=SYMBOL, request_id=request_id
                   ),
                   replayable=False,
               )
               bot.dms_deadline = started + armed.effective_timeout_sec
           except Exception as err:
               print("dead-man refresh failed; quoting pauses:", repr(err))
           try:
               await asyncio.wait_for(bot.stop.wait(), DMS_REFRESH_SECONDS)
           except TimeoutError:
               pass
   ```

   The [dead-man switch](https://testnet.polyester.com/docs/developer-docs/shared-concepts/dead-man-switch) cancels the Subaccount's open orders on the Pair when no heartbeat arrives in time. A timeout from 10 to 120 seconds arms it; this bot uses 30 seconds and refreshes every 10. Each heartbeat carries a new `requestId`, because a repeated one can return the earlier answer without restarting the timer.

   The bot quotes only while at least 10 seconds of the current timeout remain, so it stops placing orders as soon as refreshes start failing. Closing a connection does not trigger the switch, and an open connection does not keep it alive: only heartbeats do.

6. Quote post-only with client order IDs

   ```python
   def ours(bot: Bot, side: str) -> list:
       return [
           o for o in bot.live.values()
           if o.side == side and o.client_order_id.startswith(f"{PREFIX}-q")
       ]


   async def resolve(bot: Bot, side: str):
       """Return ("live", order), ("pending", None) or ("empty", None) for one side."""
       slot = bot.slots[side]
       mine = [o for o in ours(bot, side) if o.client_order_id == slot.cid]
       if mine:
           return "live", mine[0]
       waiting = slot.cid is not None and slot.cid not in bot.ended
       if waiting and time.monotonic() - slot.sent_at < PENDING_SECONDS:
           return "pending", None
       others = ours(bot, side)
       if others:  # for example the original order after a refused replace
           newest = max(others, key=lambda o: int(o.created_ts_ns or 0))
           bot.slots[side] = Slot(newest.client_order_id)
           return "live", newest
       if waiting:  # never seen on the stream: read it before quoting again
           try:
               found = await bot.client.orders.get(key=ClientOrderId(slot.cid))
               if found.order is not None and found.order.status not in TERMINAL:
                   slot.sent_at = time.monotonic()
                   return "pending", None
           except Exception as err:
               if not (is_not_found(err) or code_of(err) in GONE_CODES):
                   raise
       bot.slots[side] = Slot()
       return "empty", None


   async def quote_once(bot: Bot) -> None:
       rules = bot.rules
       if rules.status not in QUOTABLE or not bot.stream_ok.is_set():
           return
       if time.monotonic() > bot.dms_deadline - DMS_REFRESH_SECONDS:
           return  # the dead-man switch is not fresh enough to quote
       book = await bot.client.orderbook.get(symbol=SYMBOL, depth=5)
       if not book.available or not book.bids or not book.asks:
           return
       bid_level, ask_level = book.bids[0].price, book.asks[0].price
       if bid_level is None or ask_level is None:
           return
       best_bid, best_ask = bid_level.as_decimal(), ask_level.as_decimal()
       mid = (best_bid + best_ask) / 2
       offset = mid * HALF_SPREAD_BPS / 10_000
       targets = {
           "buy": min(((mid - offset) / rules.tick).to_integral_value(ROUND_FLOOR) * rules.tick,
                      best_ask - rules.tick),
           "sell": max(((mid + offset) / rules.tick).to_integral_value(ROUND_CEILING) * rules.tick,
                       best_bid + rules.tick),
       }
       creates, replaces = [], []
       for side, price in targets.items():
           if price <= 0 or QTY * price < rules.min_notional:
               print("quote below the Pair minimum notional:", side, plain(price))
               continue
           state, order = await resolve(bot, side)
           if state == "empty":
               cid = new_id(f"q{side[0]}")
               bot.slots[side] = Slot(cid, time.monotonic())
               creates.append({
                   "symbol": SYMBOL, "side": side, "order_type": "limit", "tif": "gtc",
                   "qty": plain(QTY), "price": plain(price), "post_only": True,
                   "client_order_id": cid,
               })
           elif state == "live" and order.price is not None:
               if abs(order.price.as_decimal() - price) >= REQUOTE_TICKS * rules.tick:
                   cid = new_id(f"q{side[0]}")
                   bot.slots[side] = Slot(cid, time.monotonic(), prev_cid=order.client_order_id)
                   replaces.append((side, {
                       "key": ClientOrderId(order.client_order_id),
                       "new_price": plain(price),
                       "new_client_order_id": cid,
                   }))
       if creates:
           await place(bot, creates)
       if replaces:
           await replace(bot, replaces)


   async def place(bot: Bot, items: list) -> None:
       request_id = new_id("bc")
       result = await send(
           lambda: bot.client.orders.batch_create(
               symbol=SYMBOL, items=items, request_id=request_id
           ),
           replayable=True,
       )
       for item, sent in zip(result.results, items):
           if item.status == "rejected":
               code = code_of(item.code)
               print("create rejected", sent["side"], code)
               bot.slots[sent["side"]] = Slot()
               bot.rules_stale |= code in RULE_CODES
   ```

   Each cycle, the bot reads the top of the order book, prices a bid and an ask around the mid, and rounds them to the tick size away from the mid. Each side holds at most one quote, tracked by its client order ID.

   - **Post-only.** Every quote is a post-only, good-till-cancelled limit order. If it would cross the book, it is rejected with `POST_ONLY_CROSS` instead of taking liquidity, and the next cycle prices again from a fresh book. See [Order types and lifecycle](https://testnet.polyester.com/docs/developer-docs/shared-concepts/order-types-and-lifecycle#post-only).
   - **A new client order ID for every quote.** A client order ID stays reserved after its order ends, and a reused one is rejected, not replayed. See [Client order IDs](https://testnet.polyester.com/docs/developer-docs/shared-concepts/client-order-ids).
   - **Read before you resend.** A new quote counts as pending for 5 seconds while the bot waits for it on the stream. If it never appears, `resolve` reads it with `orders.get` before placing another one, so an unknown outcome never becomes a second quote.

   `place` sends both sides in one [`BatchCreateOrders`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/BatchCreateOrders) call ([`POST /v1/orders/batch`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/batch)) of up to 20 orders. Each item is decided on its own, so the bot checks every result: one side can be accepted while the other is rejected.

7. Refresh quotes with batch replace

   ```python
   async def replace(bot: Bot, pending: list) -> None:
       request_id = new_id("br")
       items = [item for _, item in pending]
       receipt = await send(
           lambda: bot.client.orders.batch_replace(
               symbol=SYMBOL, items=items, request_id=request_id
           ),
           replayable=True,
       )
       for result in receipt.results:
           if result.status != "rejected":
               continue
           side = pending[result.item_index][0]
           code = code_of(result.code)
           print("replace rejected", side, code)
           slot = bot.slots[side]
           bot.slots[side] = Slot() if code in GONE_CODES else Slot(slot.prev_cid)
           bot.rules_stale |= code in RULE_CODES
   ```

   When a quote drifts at least `BOT_REQUOTE_TICKS` ticks from its target price, the bot moves it with [`BatchReplaceOrders`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/BatchReplaceOrders) ([`POST /v1/orders/replace-batch`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/replace-batch)), which modifies up to 50 orders of one Pair in one request. Each item names the old quote by its client order ID and gives the successor a new one, so the item is a replacement with a new order ID.

   The answer is an admission receipt, not the final result. The market can still refuse an admitted replacement, for example one that would cross as post-only. The original order then keeps its price, and the stream reports a `replace_rejected` outcome. The bot needs no special case: the new client order ID never appears, so after the pending window the side falls back to the original order. A rejected item falls back the same way, and `ORDER_UNKNOWN` or `ORDER_ALREADY_TERMINAL` means the original has already ended.

   To shrink a quote and keep its queue priority, send the same price with a lower quantity and no new client order ID: the order is amended in place. See [Batch replace](https://testnet.polyester.com/docs/developer-docs/shared-concepts/batch-orders#batch-replace).

8. Reconcile after a reconnect

   ```python
   async def cancel_order(bot: Bot, cid: str) -> None:
       try:
           await send(
               lambda: bot.client.orders.cancel(key=ClientOrderId(cid), symbol=SYMBOL),
               replayable=True,
           )
       except Exception as err:
           if not (is_not_found(err) or code_of(err) in GONE_CODES):
               raise


   async def reconcile(bot: Bot) -> None:
       """Keep at most one quote per side after a fresh snapshot."""
       for side, slot in bot.slots.items():
           orders = sorted(ours(bot, side), key=lambda o: int(o.created_ts_ns or 0))
           if not orders:
               continue
           keep = next((o for o in orders if o.client_order_id == slot.cid), orders[-1])
           for order in orders:
               if order is not keep:
                   print("canceling extra quote", order.client_order_id)
                   await cancel_order(bot, order.client_order_id)
           bot.slots[side] = Slot(keep.client_order_id)
       bot.needs_reconcile = False
   ```

   After every snapshot, `reconcile` keeps at most one quote per side and cancels any other `qb1-` quote, for example one left by a replace that was in flight when the connection dropped. A cancel answered with `ORDER_ALREADY_TERMINAL`, or `ORDER_UNKNOWN` for a client order ID, means the order has already ended, so `cancel_order` treats it as done.

   A snapshot sent after a resync skips the events between your old view and the new one, so fills in that gap are not replayed on the stream, and this bot's inventory count misses them. The bot prints a warning; a production bot reads those fills from its trade history, with `client.trades.list` in the SDK. See [Recover after reconnect](https://testnet.polyester.com/docs/developer-docs/shared-concepts/recover-after-reconnect) and [Fills and trade history](https://testnet.polyester.com/docs/developer-docs/shared-concepts/fills-and-trade-history).

9. Shut down with cancel-all

   ```python
   async def open_on_symbol(bot: Bot) -> list:
       orders, token = [], None
       while True:
           page = await bot.client.orders.list_open(page_token=token, limit=100)
           orders += [o for o in page.orders if o.symbol_id == bot.rules.symbol_id]
           token = page.next_page_token or None
           if token is None:
               return orders


   async def shutdown(bot: Bot) -> None:
       request_id = new_id("ca")
       await send(
           lambda: bot.client.orders.cancel_all(symbol=SYMBOL, request_id=request_id),
           replayable=True,
       )
       for _ in range(10):
           if not await open_on_symbol(bot):
               break
           await asyncio.sleep(1)
       else:
           print("orders are still open; the dead-man switch stays armed")
           return
       await bot.client.orders.cancel_all_after(timeout_sec=0, request_id=new_id("dms"))
       print("all quotes canceled; dead-man switch disarmed")
   ```

   `shutdown` cancels every open order on the Pair with [`CancelAllOrders`](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersService/CancelAllOrders) ([`POST /v1/orders/cancel-all`](https://testnet.polyester.com/docs/api-docs/rest/POST/v1/orders/cancel-all)). Its answer means cancellation was requested, not that every order has ended, so the bot reads [open orders](https://testnet.polyester.com/docs/api-docs/connect/orders.v1.OrdersReadService/GetOpenOrders) until none are left on the Pair. Only then does it disarm the switch with a timeout of `0`. If orders are still open, it leaves the switch armed, and they are canceled when it expires.

   Cancel-all is safe here because the Subaccount belongs to this bot alone. If other strategies share it, cancel your quotes by client order ID instead. Cancel-all does not cancel standalone triggers, and this bot places none. See [Cancel all orders](https://testnet.polyester.com/docs/developer-docs/shared-concepts/cancel-all-orders).

10. Run the loop

    ```python
    async def run(bot: Bot) -> None:
        request_id = new_id("ca")  # start clean: this Subaccount belongs to this bot
        await send(
            lambda: bot.client.orders.cancel_all(symbol=SYMBOL, request_id=request_id),
            replayable=True,
        )
        beat = asyncio.create_task(heartbeat(bot))
        try:
            sub = await asyncio.wait_for(bot.client.orders.subscribe(account_id=SUBACCOUNT_ID), 20)
        except (PolyesterRealtimeError, PolyesterAuthError, TimeoutError) as err:
            print("private stream unavailable; not quoting:", repr(err))
            bot.stop.set()
            sub = None
        stream = asyncio.create_task(consume(bot, sub)) if sub is not None else None
        rules_loaded = time.monotonic()
        try:
            while not bot.stop.is_set():
                if stream is not None and stream.done():
                    print("private stream ended:", repr(stream.exception()))
                    break
                if abs(bot.inventory) >= MAX_INVENTORY:
                    print("inventory limit reached; stopping")
                    break
                try:
                    if bot.rules_stale or time.monotonic() - rules_loaded > RULES_REFRESH_SECONDS:
                        bot.rules = await load_rules(bot.client)
                        bot.rules_stale, rules_loaded = False, time.monotonic()
                    if bot.needs_reconcile and bot.stream_ok.is_set():
                        await reconcile(bot)
                    await quote_once(bot)
                except (PolyesterAuthError, PolyesterValidationError):
                    raise
                except Exception as err:
                    print("quote cycle failed:", repr(err))
                try:
                    await asyncio.wait_for(bot.stop.wait(), LOOP_SECONDS)
                except TimeoutError:
                    pass
        finally:
            bot.stop.set()
            beat.cancel()
            await asyncio.gather(beat, return_exceptions=True)
            await shutdown(bot)
            if sub is not None:
                await sub.aclose()


    async def main() -> None:
        client = make_client()
        try:
            await client.wait_for_catalogs()
            rules = await load_rules(client)
            base_scale = client.catalogs.base_quantity_scale_for_symbol(SYMBOL)
            if base_scale is None:
                raise SystemExit(f"no quantity scale for {SYMBOL}")
            bot = Bot(client=client, rules=rules, base_scale=base_scale)
            loop = asyncio.get_running_loop()
            for sig in (signal.SIGINT, signal.SIGTERM):
                loop.add_signal_handler(sig, bot.stop.set)
            await run(bot)
        finally:
            await client.aclose()


    if __name__ == "__main__":
        asyncio.run(main())
    ```

    `run` starts from an empty book on the Pair, arms the dead-man switch before the first quote, and gives the private stream 20 seconds to confirm its subscription. The bot stops on Ctrl+C or `SIGTERM`, when the stream ends, or when its net fills in this run reach `BOT_MAX_INVENTORY`, and every exit goes through `shutdown`. Authentication and validation errors stop it, because they do not fix themselves; any other error skips one cycle.

    Save the script as `quote_bot.py` and run it with `python quote_bot.py`.

## Check that it works

Within a few seconds, [`GET /v1/orders/open`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/orders/open) for the Subaccount shows one bid and one ask whose client order IDs start with `qb1-q`. Each fill prints a `fill` line with the running inventory. After Ctrl+C, the bot prints `all quotes canceled; dead-man switch disarmed`, and the Pair has no open orders left.

The bot quotes only when the Pair's book has both a best bid and a best ask, because it prices from the mid. On an empty or one-sided book it waits.

## What the SDK does not cover

- **Waiting for your own writes.** The SDK does not expose the private WebSocket's `sync` operation, which waits until the stream reflects a command. The bot waits for its orders to appear instead. See [Order versions and your own writes](https://testnet.polyester.com/docs/developer-docs/connectrpc/private-websocket#order-versions-and-your-own-writes).
- **Typed Pair rules.** Tick size, step size, minimums, and status come from the raw response, as in the Pair rules step.

Every quote uses the default self-trade prevention mode, `EXPIRE_MAKER`. To choose another mode, add `self_trade_prevention_mode` (`expire_taker`, `expire_maker`, or `expire_both`) to each batch create item. See [Self-trade prevention](https://testnet.polyester.com/docs/developer-docs/shared-concepts/self-trade-prevention).

## Full script

### quote_bot.py

```python
"""Two-sided post-only quoting bot for Polyester Testnet.

Example code for Testnet only. It is not trading advice.
"""

import asyncio
import os
import random
import signal
import time
import uuid
from dataclasses import dataclass, field
from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal

from polyester import (
    POLYESTER_TESTNET_ENVIRONMENT,
    AsyncPolyester,
    PolyesterAuthError,
    PolyesterRateLimitError,
    PolyesterRealtimeError,
    PolyesterValidationError,
    is_not_found,
    mutation_outcome_unknown,
)
from polyester.models import (
    ClientOrderId,
    PrivateOrderCommandOutcome,
    PrivateOrderEvent,
    PrivateOrdersDisconnected,
    PrivateOrdersError,
    PrivateOrdersReady,
    PrivateOrdersSnapshot,
    PrivateOrdersStale,
    PrivateOrdersUnit,
)

SUBACCOUNT_ID = os.environ["POLYESTER_SUBACCOUNT_ID"]
SYMBOL = os.environ.get("BOT_SYMBOL", "BTC-USDT")
QTY = Decimal(os.environ.get("BOT_QTY", "0.0001"))  # base quantity of each quote
HALF_SPREAD_BPS = Decimal(os.environ.get("BOT_HALF_SPREAD_BPS", "20"))
REQUOTE_TICKS = int(os.environ.get("BOT_REQUOTE_TICKS", "5"))
MAX_INVENTORY = Decimal(os.environ.get("BOT_MAX_INVENTORY", "0.001"))  # net base filled
PREFIX = "qb1"  # marks this bot's client order IDs
LOOP_SECONDS = 2.0
PENDING_SECONDS = 5.0
RULES_REFRESH_SECONDS = 60.0
DMS_TIMEOUT_SECONDS = 30
DMS_REFRESH_SECONDS = 10

QUOTABLE = {"PAIR_STATUS_ENABLED", "PAIR_STATUS_POST_ONLY"}
TERMINAL = {"filled", "canceled", "rejected"}
GONE_CODES = {"ORDER_UNKNOWN", "ORDER_ALREADY_TERMINAL"}
BACKOFF_CODES = {"SYSTEM_RATE_LIMITED", "OVERLOADED"}
RULE_CODES = {
    "PRICE_TICK_SIZE", "QTY_STEP_SIZE", "MIN_QTY", "MIN_NOTIONAL", "MAX_QTY",
    "MAX_NOTIONAL", "PAIR_DISABLED", "PAIR_CANCEL_ONLY", "PAIR_POST_ONLY",
    "PAIR_REDUCE_ONLY", "MARKET_HALTED",
}


def new_id(kind: str) -> str:
    return f"{PREFIX}-{kind}-{uuid.uuid4().hex[:16]}"


def code_of(value: object) -> str:
    """One spelling for an error code, whichever call reported it."""
    raw = value if isinstance(value, str) else getattr(value, "code", None)
    return str(raw or "").upper().removeprefix("ERROR_CODE_")


def plain(value: Decimal) -> str:
    return format(value, "f")


def make_client() -> AsyncPolyester:
    return AsyncPolyester(
        environment=POLYESTER_TESTNET_ENVIRONMENT,
        api_key_id=os.environ["POLYESTER_API_KEY_ID"],
        api_private_key=os.environ["POLYESTER_API_PRIVATE_KEY"],
        default_sub_account_id=SUBACCOUNT_ID,
    )


@dataclass
class PairRules:
    symbol_id: int
    status: str
    tick: Decimal
    step: Decimal
    min_qty: Decimal
    min_notional: Decimal


async def load_rules(client: AsyncPolyester) -> PairRules:
    config = await client.market_data.get_spot_config()
    pair = next((p for p in config.raw["pairs"] if p["symbol"] == SYMBOL), None)
    if pair is None:
        raise SystemExit(f"{SYMBOL} is not listed")
    rules = PairRules(
        symbol_id=int(pair["symbolId"]),
        status=pair["status"],
        tick=Decimal(pair["tickSize"]),
        step=Decimal(pair["stepSize"]),
        min_qty=Decimal(pair.get("minQtyBase") or "0"),
        min_notional=Decimal(pair.get("minNotionalQuote") or "0"),
    )
    if QTY % rules.step or QTY < rules.min_qty:
        raise SystemExit(f"BOT_QTY must be a multiple of {rules.step} and at least {rules.min_qty}")
    return rules


async def send(op, *, replayable: bool, attempts: int = 5):
    """Run one write, keeping its identifiers on every retry."""
    for attempt in range(attempts):
        try:
            return await op()
        except PolyesterRateLimitError as err:
            delay = err.retry_after or 0.5 * 2**attempt
        except Exception as err:
            if code_of(err) in BACKOFF_CODES:
                delay = 0.5 * 2**attempt
            elif replayable and mutation_outcome_unknown(err):
                delay = 0.5 * 2**attempt  # resend the same request to learn its outcome
            else:
                raise
        await asyncio.sleep(delay + random.uniform(0, 0.25))
    raise RuntimeError("write did not complete; read the orders before sending it again")


@dataclass
class Slot:
    cid: str | None = None  # client order ID of this side's quote
    sent_at: float = 0.0
    prev_cid: str | None = None  # the order a replace in flight is replacing


@dataclass
class Bot:
    client: AsyncPolyester
    rules: PairRules
    base_scale: int
    live: dict = field(default_factory=dict)  # order_id -> Order, from the stream
    side_of: dict = field(default_factory=dict)  # order_id -> side
    ended: set = field(default_factory=set)  # client order IDs that ended
    slots: dict = field(default_factory=lambda: {"buy": Slot(), "sell": Slot()})
    inventory: Decimal = Decimal(0)
    stream_ok: asyncio.Event = field(default_factory=asyncio.Event)
    needs_reconcile: bool = True
    snapshots: int = 0
    rules_stale: bool = False
    dms_deadline: float = 0.0
    stop: asyncio.Event = field(default_factory=asyncio.Event)


def track(bot: Bot, order) -> None:
    if order.symbol_id != bot.rules.symbol_id:
        return
    bot.side_of[order.order_id] = order.side
    if order.status in TERMINAL:
        bot.live.pop(order.order_id, None)
        bot.ended.add(order.client_order_id)
        return
    known = bot.live.get(order.order_id)
    if known is None or order.version >= known.version:
        bot.live[order.order_id] = order


def on_event(bot: Bot, event) -> None:
    if isinstance(event, PrivateOrderEvent):
        if event.kind == "filled" and event.fill is not None:
            qty = event.fill.qty.as_decimal(bot.base_scale)
            side = bot.side_of.get(event.order_id)
            bot.inventory += qty if side == "buy" else -qty
            print("fill", side, plain(qty), "inventory", plain(bot.inventory))
        elif event.kind == "rejected":
            print("order rejected", event.order_id, event.reject_reason)
            if code_of(event.reject_reason) in RULE_CODES:
                bot.rules_stale = True
    elif isinstance(event, PrivateOrderCommandOutcome):
        print("command outcome", event.kind, event.reason)


async def consume(bot: Bot, sub) -> None:
    async for event in sub:
        if isinstance(event, PrivateOrdersSnapshot):
            bot.snapshots += 1
            if bot.snapshots > 1:
                print("stream resynced; read trade history for fills in the gap")
            bot.live.clear()
            for order in event.orders:
                track(bot, order)
            bot.needs_reconcile = True
        elif isinstance(event, PrivateOrdersUnit):
            for order in event.orders:
                track(bot, order)
            for order_id in event.removed_order_ids:
                gone = bot.live.pop(order_id, None)
                if gone is not None:
                    bot.ended.add(gone.client_order_id)
            for item in event.events:
                on_event(bot, item)
        elif isinstance(event, PrivateOrdersReady):
            bot.stream_ok.set()
        elif isinstance(event, PrivateOrdersStale):
            if event.stale:
                bot.stream_ok.clear()
            else:
                bot.stream_ok.set()
        elif isinstance(event, PrivateOrdersDisconnected):
            bot.stream_ok.clear()
        elif isinstance(event, PrivateOrdersError):
            print("stream recovering:", event.kind, repr(event.error))


async def heartbeat(bot: Bot) -> None:
    while not bot.stop.is_set():
        request_id = new_id("dms")
        started = time.monotonic()
        try:
            armed = await send(
                lambda: bot.client.orders.cancel_all_after(
                    timeout_sec=DMS_TIMEOUT_SECONDS, symbol=SYMBOL, request_id=request_id
                ),
                replayable=False,
            )
            bot.dms_deadline = started + armed.effective_timeout_sec
        except Exception as err:
            print("dead-man refresh failed; quoting pauses:", repr(err))
        try:
            await asyncio.wait_for(bot.stop.wait(), DMS_REFRESH_SECONDS)
        except TimeoutError:
            pass


def ours(bot: Bot, side: str) -> list:
    return [
        o for o in bot.live.values()
        if o.side == side and o.client_order_id.startswith(f"{PREFIX}-q")
    ]


async def resolve(bot: Bot, side: str):
    """Return ("live", order), ("pending", None) or ("empty", None) for one side."""
    slot = bot.slots[side]
    mine = [o for o in ours(bot, side) if o.client_order_id == slot.cid]
    if mine:
        return "live", mine[0]
    waiting = slot.cid is not None and slot.cid not in bot.ended
    if waiting and time.monotonic() - slot.sent_at < PENDING_SECONDS:
        return "pending", None
    others = ours(bot, side)
    if others:  # for example the original order after a refused replace
        newest = max(others, key=lambda o: int(o.created_ts_ns or 0))
        bot.slots[side] = Slot(newest.client_order_id)
        return "live", newest
    if waiting:  # never seen on the stream: read it before quoting again
        try:
            found = await bot.client.orders.get(key=ClientOrderId(slot.cid))
            if found.order is not None and found.order.status not in TERMINAL:
                slot.sent_at = time.monotonic()
                return "pending", None
        except Exception as err:
            if not (is_not_found(err) or code_of(err) in GONE_CODES):
                raise
    bot.slots[side] = Slot()
    return "empty", None


async def quote_once(bot: Bot) -> None:
    rules = bot.rules
    if rules.status not in QUOTABLE or not bot.stream_ok.is_set():
        return
    if time.monotonic() > bot.dms_deadline - DMS_REFRESH_SECONDS:
        return  # the dead-man switch is not fresh enough to quote
    book = await bot.client.orderbook.get(symbol=SYMBOL, depth=5)
    if not book.available or not book.bids or not book.asks:
        return
    bid_level, ask_level = book.bids[0].price, book.asks[0].price
    if bid_level is None or ask_level is None:
        return
    best_bid, best_ask = bid_level.as_decimal(), ask_level.as_decimal()
    mid = (best_bid + best_ask) / 2
    offset = mid * HALF_SPREAD_BPS / 10_000
    targets = {
        "buy": min(((mid - offset) / rules.tick).to_integral_value(ROUND_FLOOR) * rules.tick,
                   best_ask - rules.tick),
        "sell": max(((mid + offset) / rules.tick).to_integral_value(ROUND_CEILING) * rules.tick,
                    best_bid + rules.tick),
    }
    creates, replaces = [], []
    for side, price in targets.items():
        if price <= 0 or QTY * price < rules.min_notional:
            print("quote below the Pair minimum notional:", side, plain(price))
            continue
        state, order = await resolve(bot, side)
        if state == "empty":
            cid = new_id(f"q{side[0]}")
            bot.slots[side] = Slot(cid, time.monotonic())
            creates.append({
                "symbol": SYMBOL, "side": side, "order_type": "limit", "tif": "gtc",
                "qty": plain(QTY), "price": plain(price), "post_only": True,
                "client_order_id": cid,
            })
        elif state == "live" and order.price is not None:
            if abs(order.price.as_decimal() - price) >= REQUOTE_TICKS * rules.tick:
                cid = new_id(f"q{side[0]}")
                bot.slots[side] = Slot(cid, time.monotonic(), prev_cid=order.client_order_id)
                replaces.append((side, {
                    "key": ClientOrderId(order.client_order_id),
                    "new_price": plain(price),
                    "new_client_order_id": cid,
                }))
    if creates:
        await place(bot, creates)
    if replaces:
        await replace(bot, replaces)


async def place(bot: Bot, items: list) -> None:
    request_id = new_id("bc")
    result = await send(
        lambda: bot.client.orders.batch_create(
            symbol=SYMBOL, items=items, request_id=request_id
        ),
        replayable=True,
    )
    for item, sent in zip(result.results, items):
        if item.status == "rejected":
            code = code_of(item.code)
            print("create rejected", sent["side"], code)
            bot.slots[sent["side"]] = Slot()
            bot.rules_stale |= code in RULE_CODES


async def replace(bot: Bot, pending: list) -> None:
    request_id = new_id("br")
    items = [item for _, item in pending]
    receipt = await send(
        lambda: bot.client.orders.batch_replace(
            symbol=SYMBOL, items=items, request_id=request_id
        ),
        replayable=True,
    )
    for result in receipt.results:
        if result.status != "rejected":
            continue
        side = pending[result.item_index][0]
        code = code_of(result.code)
        print("replace rejected", side, code)
        slot = bot.slots[side]
        bot.slots[side] = Slot() if code in GONE_CODES else Slot(slot.prev_cid)
        bot.rules_stale |= code in RULE_CODES


async def cancel_order(bot: Bot, cid: str) -> None:
    try:
        await send(
            lambda: bot.client.orders.cancel(key=ClientOrderId(cid), symbol=SYMBOL),
            replayable=True,
        )
    except Exception as err:
        if not (is_not_found(err) or code_of(err) in GONE_CODES):
            raise


async def reconcile(bot: Bot) -> None:
    """Keep at most one quote per side after a fresh snapshot."""
    for side, slot in bot.slots.items():
        orders = sorted(ours(bot, side), key=lambda o: int(o.created_ts_ns or 0))
        if not orders:
            continue
        keep = next((o for o in orders if o.client_order_id == slot.cid), orders[-1])
        for order in orders:
            if order is not keep:
                print("canceling extra quote", order.client_order_id)
                await cancel_order(bot, order.client_order_id)
        bot.slots[side] = Slot(keep.client_order_id)
    bot.needs_reconcile = False


async def open_on_symbol(bot: Bot) -> list:
    orders, token = [], None
    while True:
        page = await bot.client.orders.list_open(page_token=token, limit=100)
        orders += [o for o in page.orders if o.symbol_id == bot.rules.symbol_id]
        token = page.next_page_token or None
        if token is None:
            return orders


async def shutdown(bot: Bot) -> None:
    request_id = new_id("ca")
    await send(
        lambda: bot.client.orders.cancel_all(symbol=SYMBOL, request_id=request_id),
        replayable=True,
    )
    for _ in range(10):
        if not await open_on_symbol(bot):
            break
        await asyncio.sleep(1)
    else:
        print("orders are still open; the dead-man switch stays armed")
        return
    await bot.client.orders.cancel_all_after(timeout_sec=0, request_id=new_id("dms"))
    print("all quotes canceled; dead-man switch disarmed")


async def run(bot: Bot) -> None:
    request_id = new_id("ca")  # start clean: this Subaccount belongs to this bot
    await send(
        lambda: bot.client.orders.cancel_all(symbol=SYMBOL, request_id=request_id),
        replayable=True,
    )
    beat = asyncio.create_task(heartbeat(bot))
    try:
        sub = await asyncio.wait_for(bot.client.orders.subscribe(account_id=SUBACCOUNT_ID), 20)
    except (PolyesterRealtimeError, PolyesterAuthError, TimeoutError) as err:
        print("private stream unavailable; not quoting:", repr(err))
        bot.stop.set()
        sub = None
    stream = asyncio.create_task(consume(bot, sub)) if sub is not None else None
    rules_loaded = time.monotonic()
    try:
        while not bot.stop.is_set():
            if stream is not None and stream.done():
                print("private stream ended:", repr(stream.exception()))
                break
            if abs(bot.inventory) >= MAX_INVENTORY:
                print("inventory limit reached; stopping")
                break
            try:
                if bot.rules_stale or time.monotonic() - rules_loaded > RULES_REFRESH_SECONDS:
                    bot.rules = await load_rules(bot.client)
                    bot.rules_stale, rules_loaded = False, time.monotonic()
                if bot.needs_reconcile and bot.stream_ok.is_set():
                    await reconcile(bot)
                await quote_once(bot)
            except (PolyesterAuthError, PolyesterValidationError):
                raise
            except Exception as err:
                print("quote cycle failed:", repr(err))
            try:
                await asyncio.wait_for(bot.stop.wait(), LOOP_SECONDS)
            except TimeoutError:
                pass
    finally:
        bot.stop.set()
        beat.cancel()
        await asyncio.gather(beat, return_exceptions=True)
        await shutdown(bot)
        if sub is not None:
            await sub.aclose()


async def main() -> None:
    client = make_client()
    try:
        await client.wait_for_catalogs()
        rules = await load_rules(client)
        base_scale = client.catalogs.base_quantity_scale_for_symbol(SYMBOL)
        if base_scale is None:
            raise SystemExit(f"no quantity scale for {SYMBOL}")
        bot = Bot(client=client, rules=rules, base_scale=base_scale)
        loop = asyncio.get_running_loop()
        for sig in (signal.SIGINT, signal.SIGTERM):
            loop.add_signal_handler(sig, bot.stop.set)
        await run(bot)
    finally:
        await client.aclose()


if __name__ == "__main__":
    asyncio.run(main())
```
