This tutorial builds the operational skeleton of an automated trading system: hydrate exact market constraints, consume a sequence-checked book, place uniquely identified post-only quotes, reconcile ambiguous outcomes, arm a dead-man switch, and remove only this process's orders during shutdown.
Production invariants
- Never invent precision:
wait_for_catalogs(), decimalPrice/Quantityinputs. On failure: Stop quoting until catalogs recover. - Never quote from a gapped book:
orderbook.create_subscription(). On failure: Wait for its snapshot refresh. - Never duplicate a logical quote: Stable
client_order_idper quote attempt. On failure: Reconcile before retrying. - Never treat admission as final state:
list_open,get, private order stream. On failure: Keep local state pending. - Never leave unknown live risk:
cancel_all_afterplus targeted cancel. On failure: Reconcile all bot-prefixed orders. - Never retry validation unchanged:
PolyesterValidationError. On failure: Correct configuration or input.
Create a client and hydrate catalogs
import os
from polyester import AsyncPolyester, POLYESTER_TESTNET_ENVIRONMENT
client = AsyncPolyester(
environment=POLYESTER_TESTNET_ENVIRONMENT,
api_key_id=os.environ["POLYESTER_API_KEY_ID"],
api_private_key=os.environ["POLYESTER_API_PRIVATE_KEY"],
default_account_id=os.environ["POLYESTER_ACCOUNT_ID"],
)
await client.wait_for_catalogs()Catalog readiness is a hard startup gate. Decimal order helpers must use the backend's actual price and quantity scales; a trading system should never fall back to an assumed scale. For a subaccount-scoped key, attach an API-key policy that permits ledger reads and the trading mutations this bot uses. The key policy is separate from, and intersects with, subaccount policy.
Start a managed order book
book_state: dict[str, object] = {}
book_sub = await client.orderbook.create_subscription(
symbol="BTC-USDT",
depth=20,
on_sequence_gap=lambda: print("book gap; waiting for refresh"),
on_snapshot_refresh=lambda: print("book refreshed"),
)
async def consume_books() -> None:
async with book_sub:
async for book in book_sub:
if not book.bids or not book.asks:
continue
book_state["best_bid"] = book.bids[0].price
book_state["best_ask"] = book.asks[0].price
book_state["book_seq"] = book.book_seqUse the managed subscription instead of applying raw deltas yourself. It fetches an initial snapshot, detects sequence gaps, and refetches after reconnects. Pause quoting whenever the book is empty, crossed, stale, or awaiting refresh.
Size from trading balance and policy limits
from polyester import format_ledger_u128
balances = await client.balances.list()
for balance in balances.balances:
print(balance.asset_id, "available", format_ledger_u128(balance.available))Balance components are raw ledger u128 strings at ledger scale 18. Format them once for display;
keep the raw values for sizing and accounting.
Orders reserve trading balance, not funding balance. Size from each Asset's available field, which already accounts for active reservations, not raw trading. Missing or empty available means zero spendable headroom. Held amounts are each Asset's reserved. Cap each quote
by all of:
- available trading balance,
- API-key policy maximum order size,
- strategy inventory and notional limits,
- pair minimum quantity/notional and step size,
client.catalogs.order_limits_for_symbol(...).
A buy limit order that pays its fee in the quote Asset is sized with the reserve fee rate, or the
post-only reserve rate for a post-only order; see Fees. Use integer
or Decimal arithmetic, never binary floating point.
Place one uniquely identified post-only quote
from uuid import uuid4
async def place_quote(side: str, price: str, qty: str) -> tuple[str, str]:
client_order_id = f"trader-{side}-{uuid4().hex[:20]}"
created = await client.orders.create(
symbol="BTC-USDT",
side=side,
order_type="limit",
tif="gtc",
price=price,
qty=qty,
post_only=True,
client_order_id=client_order_id,
)
# "accepted" is admission only; the read model may not show the order yet.
return client_order_id, created.order_idKeep the same ID if you retry the same logical quote after an ambiguous transport failure.
Generate a new ID only for a new quote decision. A post_only rejection means the price would
cross; recompute from a fresh book instead of retrying unchanged.
Arm and refresh the dead-man switch
from uuid import uuid4
armed = await client.orders.cancel_all_after(
timeout_sec=30,
symbol="BTC-USDT",
request_id=f"dms-{uuid4().hex[:20]}",
)
assert armed.status == "armed"Refresh the switch on a cadence comfortably below its timeout. If refresh fails, stop creating orders and assume the backend will cancel them at expiry. Disable it only after targeted shutdown cleanup is confirmed.
Refresh quotes through successor identities
batch_replace records admission, not a final execution outcome. An item that keeps the price,
does not raise the total quantity and keeps the client order ID is amended in place
(action_taken is "amended"): keep tracking old_order_id. Any other item is replaced
(action_taken is "replaced"): on a successful receipt, its predecessor order and client IDs
are stale. Persist the new client order ID and switch immediately to its replacement_order_id; a
predecessor lookup may return not_found / ORDER_UNKNOWN, which is expected. An admitted item can
still be refused when applied: it then reads rejected, the order keeps its previous price and
quantity, and no successor exists. Use wait_batch_replace to poll through admitted, working, rejected, and terminal.
For a quote-refresh loop, use is_batch_replace_settled(status) only to decide that every item is working, rejected, or terminal. working means the successor, or the amended order, is
live, not that execution is final. Persist and reuse the same request_id after an ambiguous retry. Do not send another
replacement against a stale predecessor.
Reconcile instead of guessing
from polyester import PolyesterApiError, is_order_already_terminal
from polyester.models import ClientOrderId
async def bot_open_orders(prefix: str = "trader-"):
page = await client.orders.list_open(limit=100)
return [order for order in page.orders if order.client_order_id.startswith(prefix)]
async def cancel_bot_order(client_order_id: str) -> None:
try:
await client.orders.cancel(
key=ClientOrderId(client_order_id),
symbol="BTC-USDT",
)
except PolyesterApiError as exc:
if not is_order_already_terminal(exc) and exc.code != "ERROR_CODE_ORDER_UNKNOWN":
raiseAfter a timeout, reconnect, or process restart:
- list open orders,
- select only IDs owned by this bot,
- compare them with persisted quote intent,
- cancel stale quotes,
- place only missing quotes.
Do not use account-wide cancel_all as bot-ownership cleanup. A targeted cleanup cancel refused
with ERROR_CODE_ORDER_ALREADY_TERMINAL or ERROR_CODE_ORDER_UNKNOWN is idempotent success: the
owned order may have filled or left the book after the snapshot. Every other cancel error remains an error. Do not assume a timeout means the create was
unapplied.
Shut down with targeted cleanup
Persist quote intent before sending mutations and install SIGINT / SIGTERM handlers. On
shutdown:
- stop the quote producer,
- close the managed book and private subscriptions,
- call
bot_open_orders()andcancel_bot_order()for each owned ID, - confirm those IDs disappear from
list_open, - close the client.
If cleanup cannot be confirmed, leave the dead-man switch armed and alert an operator.
Trading API map
wait_for_catalogs(): Startup gate for scales and symbol IDs. Inputs: none.balances.list(): Available trading collateral and revisions. Inputs: account scope.fees.get_spot_fee_rates(): Reserve rates for sizing a buy. Inputs: account scope, symbol IDs.orderbook.get(): One-shot health check or reconciliation snapshot. Inputs:symbol,depth.orderbook.create_subscription(): Sequence-checked best bid/ask and depth. Inputs:symbol,depth, optionalbucket, callbacks.orders.create(): Place one uniquely identified quote. Inputs: symbol, side, type, qty, price, TIF, post-only, ID.orders.modify(): Amend or replace with reserve headroom. Inputs:key, new price/qty, stable request ID.orders.cancel(): Targeted cleanup. Inputs:key, optional routing symbol.orders.list_open(): Restart and timeout reconciliation. Inputs: account scope, page token, limit.orders.get(): Resolve one ambiguous lifecycle. Inputs:key.orders.subscribe(): Private order events. Inputs: account ID.orders.cancel_all_after(): Dead-man switch. Inputs: timeout, optional symbol/side, request ID.orders.batch_create(): Submit a quote ladder; reconcile partial outcomes. Inputs: up to 20 uniquely identified items, request ID.orders.batch_replace(): Admission receipt for ladder maintenance. Inputs: up to 50 items, request ID.orders.wait_batch_replace(): Reconcile each replaced item. Inputs: batch request ID, optional timeout.orders.batch_cancel(): Remove a quote ladder. Inputs: up to 50 items, request ID.orders.wait_for_order_trades_complete(): Fee-correct post-fill accounting once the order has ended. Inputs:key, overall timeout.
Use the linked reference pages for exact optional fields and return shapes. Treat every mutation timeout as an unknown outcome until reads or the private stream resolve it.
The snippets on this page are type-checked against polyester-sdk==0.11.0.