This tutorial extends the single-order trading bot into a two-sided quote cycle. It deliberately keeps strategy math outside the SDK: your strategy chooses valid typed prices and quantities; the SDK validates, submits, streams, and reconciles them.
Quote-cycle invariants
- Use a fresh, sequence-checked book.
- Size bid and ask independently from available quote and base balances.
- Give every quote a unique client order ID under a process-owned prefix.
- Inspect every batch item; a successful RPC is not an all-items guarantee.
- Reconcile open orders before retrying an unknown outcome.
- Keep the dead-man switch refreshed while writes are enabled.
Read a quoteable book
let book = client.orderbook.get("BTC-USDT", Some(20)).await?;
let best_bid = book
.bids
.first()
.and_then(|level| level.price.as_ref())
.ok_or_else(|| polyester::Error::validation("book has no bid"))?
.format();
let best_ask = book
.asks
.first()
.and_then(|level| level.price.as_ref())
.ok_or_else(|| polyester::Error::validation("book has no ask"))?
.format();
println!("book_seq={} bid={} ask={}", book.book_seq, best_bid, best_ask);For a long-running bot, use orderbook.create_subscription and pause writes during snapshot
refresh. Quoting at the displayed best bid and ask is only a teaching baseline. The backend's post_only validation remains the final protection against a stale quote crossing the book.
Size both sides independently
use polyester::proto::ledger::read::v1::GetBalancesRequest;
let balances = client.balances.list(GetBalancesRequest::default()).await?;
for balance in &balances.balances {
println!(
"{} available={} reserved={}",
balance.asset_id,
polyester::codecs::scalars::format_ledger_u128(&balance.available, 18)?,
polyester::codecs::scalars::format_ledger_u128(&balance.reserved, 18)?,
);
}available and reserved are raw ledger u128 strings at ledger scale 18. Format once for
display, but retain raw values for exact sizing.
The bid consumes quote balance; the ask consumes base balance. Compute each quantity from the
catalog step size, minimum quantity/notional, available balance, and your own inventory cap. Do not
mirror one quantity blindly across both sides. A bid that pays its fee in the quote Asset is sized
with the post-only reserve rate: see Fees. Use rust_decimal, never
binary floating-point arithmetic.
Submit one bid and one ask
use polyester::models::{CreateOrderType, CreateSide, CreateTimeInForce};
use polyester::services::OrdersService;
use polyester::{Price, Quantity};
use std::time::{SystemTime, UNIX_EPOCH};
let book = client.orderbook.get("BTC-USDT", Some(20)).await?;
let best_bid = book
.bids
.first()
.and_then(|level| level.price.as_ref())
.ok_or_else(|| polyester::Error::validation("book has no bid"))?
.format();
let best_ask = book
.asks
.first()
.and_then(|level| level.price.as_ref())
.ok_or_else(|| polyester::Error::validation("book has no ask"))?
.format();
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_nanos();
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 mut bid = OrdersService::create_params(
"BTC-USDT",
CreateSide::Buy,
CreateOrderType::Limit,
Quantity::from_decimal_str("0.001", quantity_scale, Some("BTC-USDT".into()), None)?,
Some(Price::from_decimal_str(&best_bid, Some("BTC-USDT".into()))?),
Some(&format!("quote-bid-{nonce}")),
);
bid.time_in_force = Some(CreateTimeInForce::Gtc);
bid.post_only = Some(true);
let mut ask = OrdersService::create_params(
"BTC-USDT",
CreateSide::Sell,
CreateOrderType::Limit,
Quantity::from_decimal_str("0.001", quantity_scale, Some("BTC-USDT".into()), None)?,
Some(Price::from_decimal_str(&best_ask, Some("BTC-USDT".into()))?),
Some(&format!("quote-ask-{nonce}")),
);
ask.time_in_force = Some(CreateTimeInForce::Gtc);
ask.post_only = Some(true);
let created = client
.orders
.batch_create(vec![bid, ask], None, Some(format!("quote-batch-{nonce}")))
.await?;
for item in &created.results {
println!("{} {} {} {}", item.client_order_id, item.status, item.order_id, item.code);
}Replace the demonstration quantities with catalog-aligned, balance-capped values. batch_create accepts at most 20 items. Inspect results, accepted_count, and rejected_count; accepted and
rejected sides can coexist in one response.
Reconcile the desired quote set
let open = client.orders.list_open(None).await?;
let owned: Vec<_> = open
.orders
.iter()
.filter(|order| order.client_order_id.starts_with("quote-"))
.collect();
for order in owned {
println!("{} {} {:?}", order.client_order_id, order.status, order.leaves_qty);
}After a timeout, never resubmit with new IDs first. List open orders and query the known IDs. Cancel stale or duplicate owned quotes, then place only the missing side. Admission responses and the open-order read model can be briefly out of sync.
Arm the dead-man switch and clean up
use std::time::{SystemTime, UNIX_EPOCH};
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock before Unix epoch")
.as_nanos();
let armed = client
.orders
.cancel_all_after(
30,
Some("BTC-USDT"),
None,
Some(format!("quote-dms-{nonce}")),
)
.await?;
if armed.status != "armed" {
return Err(polyester::Error::transport(format!(
"unexpected dead-man status {:?}",
armed.status
)));
}
for order in client.orders.list_open(None).await?.orders {
if order.client_order_id.starts_with("quote-") {
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),
}
}
}Refresh the switch well before expiry. During shutdown, stop quote creation first, cancel only
owned IDs, confirm they disappear, and leave the switch armed if confirmation fails. Treat ERROR_CODE_ORDER_ALREADY_TERMINAL and ERROR_CODE_ORDER_UNKNOWN from an owned targeted cancel as
idempotent success because the order may have filled
or left the book after listing; propagate every other cancel error. Never substitute account-wide
cancel for ownership filtering.
Requote policy
Do not cancel and recreate continuously. Requote only when a deterministic condition is met, such as price drift beyond a configured number of ticks, quantity/inventory change, approaching expiry, or loss of one side. Preserve enough balance headroom for replace behavior and fees.
Inventory skew belongs in strategy code. A common control is to reduce or disable bids when base inventory is above its ceiling, and reduce or disable asks when it is below its floor. The SDK does not choose those limits for you.
Failure decisions
- Post-only rejection: Refresh the book and compute a new price
Error::Validation: Fix input; do not retry unchangedmutation_outcome_unknown(): Reconcile IDs before retrying- One batch item rejected: Keep/reconcile the accepted side; decide whether to replace the missing side
- Book gap or reconnect: Pause writes until the managed snapshot refresh completes
- Dead-man refresh failure: Stop new orders and assume expiry cleanup will run
Error::QueueOverflow: Replace the subscription and run a full snapshot reconciliation