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, typedPrice/Quantity. 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:
Error::Validation. On failure: correct configuration or input.
Create a client and hydrate catalogs
use polyester::{Client, Config};
let client = Client::new(Config {
environment: Some(polyester::POLYESTER_TESTNET_ENVIRONMENT.clone()),
api_key_id: Some(std::env::var("POLYESTER_API_KEY_ID").expect("API key ID")),
api_private_key: Some(
std::env::var("POLYESTER_API_PRIVATE_KEY").expect("API private key"),
),
default_account_id: Some(
std::env::var("POLYESTER_ACCOUNT_ID").expect("account ID"),
),
..Default::default()
})?;
client.wait_for_catalogs().await?;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
use polyester::services::CreateSubscriptionOptions;
let mut book_sub = client
.orderbook
.create_subscription(CreateSubscriptionOptions {
symbol: "BTC-USDT".into(),
depth: Some(20),
..Default::default()
})
.await?;
if let Some(book) = book_sub.updates().recv().await {
match (book.bids.first(), book.asks.first()) {
(Some(bid), Some(ask)) => println!("{:?} {:?} {}", bid.price, ask.price, book.book_seq),
_ => eprintln!("book is not quoteable"),
}
}Use 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
use polyester::proto::ledger::read::v1::GetBalancesRequest;
let balances = client.balances.list(GetBalancesRequest::default()).await?;
for balance in &balances.balances {
println!(
"{} available={}",
balance.asset_id,
polyester::codecs::scalars::format_ledger_u128(&balance.available, 18)?,
);
}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. Cap each quote by all of:
- available trading balance after existing reservations (each Asset's
reservedbalance), - API-key policy maximum order size,
- strategy inventory and notional limits,
- pair minimum quantity/notional and step size.
A buy limit order that pays its fee in the quote Asset is sized with the reserve rate, or the
post-only reserve rate for a post-only order: see Fees. Use rust_decimal, never binary floating-point arithmetic.
Place one uniquely identified post-only quote
use polyester::models::{CreateOrderParams, CreateOrderType, CreateSide, CreateTimeInForce};
use polyester::{Price, Quantity};
use std::time::{SystemTime, UNIX_EPOCH};
let quantity_scale = client
.catalogs
.base_quantity_scale_for_symbol("BTC-USDT")
.ok_or_else(|| polyester::Error::validation("BTC-USDT quantity scale is unavailable"))?;
let client_order_id = format!(
"trader-buy-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_nanos()
);
let mut params = CreateOrderParams::new("BTC-USDT", CreateSide::Buy, CreateOrderType::Limit);
params.quantity = Some(Quantity::from_decimal_str(
"0.001",
quantity_scale,
Some("BTC-USDT".into()),
None,
)?);
params.price = Some(Price::from_decimal_str("50000", Some("BTC-USDT".into()))?);
params.time_in_force = Some(CreateTimeInForce::Gtc);
params.client_order_id = Some(client_order_id.clone());
params.post_only = Some(true);
let created = client.orders.create(params).await?;
println!("admission={} order_id={}", created.status, created.order_id);Keep the same ID if you retry the same logical quote after mutation_outcome_unknown(). 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
let armed = client
.orders
.cancel_all_after(30, Some("BTC-USDT"), None, Some("dms-trader-1".into()))
.await?;
assert_eq!(armed.status, "armed");Refresh the switch on a cadence comfortably below its timeout, with a new request ID for each refresh. 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 ERROR_CODE_ORDER_UNKNOWN, which is expected. An admitted item can
still be refused when it is applied, so the planned replacement_order_id is not proof of an order.
Call wait_batch_replace to poll get_batch_replace_status through admitted, working, rejected, and terminal; it polls through the brief not-found answers after admission.
For a quote-refresh loop, use status.is_settled() or 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
let open = client.orders.list_open(None).await?;
for order in open.orders {
if !order.client_order_id.starts_with("trader-") {
continue;
}
match client
.orders
.cancel_by_client_order_id(&order.client_order_id, Some("BTC-USDT"), None)
.await
{
Ok(_) => {}
Err(err) if err.is_order_already_terminal() => {}
Err(polyester::Error::Api { code, .. }) if code == polyester::order_codes::ORDER_UNKNOWN => {}
Err(err) => return Err(err),
}
}After a timeout, reconnect, or process restart, compare bot-prefixed open orders with persisted
quote intent, cancel stale quotes, and place only missing quotes. Do not use account-wide cancel_all as bot-ownership cleanup. A targeted cleanup cancel returning 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
Use tokio::signal::ctrl_c() or a Unix signal stream. Stop the quote producer first, call book_sub.close(), run the same bot-prefix reconciliation shown above under tokio::time::timeout, then drop 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 IDsbalances.list(GetBalancesRequest): Available collateral and revisionsorderbook.get(symbol, optional depth): One-shot health checkorderbook.create_subscription(CreateSubscriptionOptions): Sequence-checked best bid/askorders.create(CreateOrderParams): Place one uniquely identified quoteorders.modify(ModifyOrderParams, stable request ID): Amend or replace with headroomorders.cancel_by_*(order or client order ID): Targeted cleanuporders.list_open_with(subaccount, pagination, include flags): Restart and timeout reconciliationorders.get/get_with(OrderKey): Resolve one ambiguous lifecycleorders.subscribe(account ID): Working, partial, terminal changesorders.cancel_all_after(timeout, optional symbol, request ID): Dead-man switchorders.batch_create(up to 20 items, request ID): Submit a quote ladderorders.batch_replace/batch_cancel(up to 50 items, request ID): Ladder maintenanceorders.wait_batch_replace(batch request ID): Reconcile a batch replaceorders.wait_for_order_trades_complete(OrderKey, overallDuration): Fee-correct post-fill accounting
Use the linked reference pages for exact typed fields and return shapes. Treat every mutation
timeout with mutation_outcome_unknown() as unresolved until reads or the private stream resolve
it.