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.
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.
Prepare your Testnet account
- Python 3.11 or later
- A Subaccount for this strategy, with a Subaccount policy that allows trading
- An API key bound to that Subaccount, with an API key policy that grants TRADE_SPOT on the Pair
- Test funds in the Subaccount's Unified Trading Account: the quote Asset for bids and the base Asset for asks
If your test funds are in your Main Account, move them to the Subaccount's Unified Trading Account with an internal transfer.
Install the SDK, pinned to the release this tutorial uses, and set the values the script reads:
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, and hosts in Environments and hosts.
Build the bot
Create the client
"""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.
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.
Load the Pair rules
@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 rulesGetSpotConfig 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.
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.
Send writes safely
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_EXCEEDEDarrives asPolyesterRateLimitError; wait for itsretry_after.SYSTEM_RATE_LIMITEDmeans the venue-wide rate is exhausted, andOVERLOADEDmeans too many of your requests are in flight. Neither is your quota, so back off and retry. See Rate limits. - Outcome unknown. A timeout, a dropped connection, or a code ending in
_OUTCOME_UNKNOWNmeans the write may have applied, andmutation_outcome_unknowndetects each of them. Resending the same batch with the samerequestIdwithin its retry window returns the original results and applies nothing twice. A repeated cancel never cancels twice. See Partial failure and unknown outcomes. - Every other error is raised, because resending it unchanged would be refused again. Look it up in Error codes and 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).
Follow your orders on the private stream
@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. 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.
403 while Testnet access is being
enabled. subscribe then raises PolyesterRealtimeError, and the bot stops without quoting,
because it cannot follow its orders.Arm the dead-man switch
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:
passThe 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.
Quote post-only with client order IDs
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_CODESEach 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_CROSSinstead of taking liquidity, and the next cycle prices again from a fresh book. See Order types and lifecycle. - 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.
- 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,
resolvereads it withorders.getbefore placing another one, so an unknown outcome never becomes a second quote.
place sends both sides in one BatchCreateOrders call
(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.
Refresh quotes with batch replace
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_CODESWhen a quote drifts at least BOT_REQUOTE_TICKS ticks from its target price, the bot moves it with BatchReplaceOrders (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.
Reconcile after a reconnect
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 = FalseAfter 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 and Fills and trade history.
Shut down with cancel-all
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 (POST /v1/orders/cancel-all). Its answer means
cancellation was requested, not that every order has ended, so the bot reads open orders 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.
Run the loop
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 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
syncoperation, which waits until the stream reflects a command. The bot waits for its orders to appear instead. See 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.
Full script
"""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())