client.orders is the spot order surface. Methods are async and return Result<T>. Prefer typed
params (CreateOrderParams, ModifyOrderParams, …) with Price / Quantity wrappers.
Create/modify/batch paths wait for catalog hydration when enabled. You can still call client.wait_for_catalogs().await? before other decimal helpers.
post_only = Some(true) works on limit GTC and limit GTD orders. It is rejected for market, limit
IOC, and limit FOK (Error::Validation).price on a market create is rejected by the SDK. Use market_client_ref_price when you need a
reservation / slippage reference. The server may still accept a stray price if you bypass the SDK.create / batch_create synthesize status: "accepted".
That is an admission ack, not a lifecycle state. Do not assert "created" on the create
response. Use list_open / get / subscribe for working /
partial / terminal statuses. Spot orders spend trading balance, not funding.
There is no allow_partial argument on Rust batch_create.Methods
| Method | Summary |
|---|---|
create | Place a limit or market order (CreateOrderParams). |
preview | Admissibility check (PreviewOrderParams). |
modify | Patch price / qty / attached risk (ModifyOrderParams). |
cancel / cancel_with | Cancel via proto or CancelOrderParams. |
cancel_by_client_order_id / cancel_by_order_id | Convenience cancels. |
cancel_all / cancel_all_with | Bulk cancel (dry_run). |
cancel_all_after | Dead-man switch. |
batch_create / batch_replace / batch_cancel | Batch mutations. |
get_batch_replace_status / wait_batch_replace | Batch replace status. |
list_open / list_open_with | Open orders. |
list_history / list_history_with | History. |
get / get_with | One order + trades. |
wait_for_order_trades_complete | Terminal order with its complete trades. |
subscribe | Private order stream (PrivateOrdersEvent). |
No get_details, use get.
Create
use polyester::models::{
CreateOrderParams, CreateOrderType, CreateSide, CreateTimeInForce,
FeeAsset, OrderSelfTradePrevention,
};
use polyester::{Price, Quantity};
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 params = CreateOrderParams::new("BTC-USDT", CreateSide::Buy, CreateOrderType::Limit);
params.quantity = Some(Quantity::from_decimal_str("0.25", quantity_scale, Some("BTC-USDT".into()), None)?);
params.price = Some(Price::from_decimal_str("64250.5", Some("BTC-USDT".into()))?);
params.time_in_force = Some(CreateTimeInForce::Gtc);
params.client_order_id = Some("mm-bot-001".into());
params.post_only = Some(true);
params.fee_asset = Some(FeeAsset::Base);
params.self_trade_prevention = Some(OrderSelfTradePrevention::ExpireBoth);
let result = client.orders.create(params).await?;
println!("{} {}", result.status, result.order_id); // status == "accepted"client_order_id is optional. Pass Some(...) when you may need to reconcile an uncertain create;
omit (None) for one-shot creates. Reuse of a retained ID, including an identical request, returns CONFLICT_DUPLICATE_CLIENT_ORDER_ID; it does not replay the earlier result. Reconcile by client
order ID before deciding whether to resubmit. CreateOrderParams is #[non_exhaustive]: build it
with CreateOrderParams::new(symbol, side, order_type), then set optional fields. OrdersService::create_params(...) also builds a defaults-filled CreateOrderParams you can mutate.
For MARKET orders, market_max_slippage accepts MaxSlippage::Ticks or MaxSlippage::Bps (1 to 10,000 for a buy, 1 to 9,999 for a sell); SELL orders must use FeeAsset::Quote.
CreateOrderParams fields
Required: symbol (String), side (CreateSide: Buy or Sell), and order_type (CreateOrderType: Limit or Market). Set exactly one sizing field, quantity or max_quote_debit_scaled. Limit orders also need price.
quantity(Option<Quantity>): Exact base quantitymax_quote_debit_scaled(Option<Quantity>): Hard all-in quote budget, BUY market or limit IOCprice(Option<Price>): Exact limit pricetime_in_force(Option<CreateTimeInForce>):Gtc,Ioc,Fok, orGtdexpire_at(Option<SystemTime>): Required withGtdclient_order_id(Option<String>): Account-scoped duplicate guard, 1 to 36 characterssubaccount_id(Option<u64>): Explicit scope override, neverSome(0)post_only(Option<bool>):Some(true)only for limit GTC or GTDmarket_client_ref_price(Option<Price>): Client reservation reference (market)fee_asset(Option<FeeAsset>):Quotedefault or BUY-onlyBaseself_trade_prevention(Option<OrderSelfTradePrevention>):ExpireTaker,ExpireMaker, orExpireBothmarket_max_slippage(Option<MaxSlippage>):Ticks(i64)orBps(i32)(market)attached_risk(Option<AttachedRisk>): Typed TP/SL/trailing policy
Build max_quote_debit_scaled with Quantity::from_quote_decimal_str and the pair's quote_quantity_scale from the catalog. self_trade_prevention defaults to ExpireMaker and also
applies to the order's attached exits. Size a buy limit order that pays its fee in the quote Asset with the reserve rate from Fees.
A create response can include resolved_base_qty and, for quote-budget sizing, submitted_max_quote_debit_scaled. Use client.orders.preview(PreviewOrderParams::new(...)) with the same sizing and fee fields for an
admissibility check (admissible, optional typed rejection, resolved base size, and protected_price_bound when price protection applied). Preview does not return fee or
quote-debit estimates. Create always re-evaluates the intent.
PreviewOrderResult exposes:
admissible: whether the intent passed current admission checks.rejection: optionalOrderErrorDetailwith a stablecodelabel such asBAD_QTYand field-levelviolations(field_path,rule_id,message).- Typed
resolved_base_qtywhen the service resolved a base size. protected_price_bound: optional protective execution boundary, not an expected fill price.evaluated_at_ms: evaluation time in Unix milliseconds.
Modify
use polyester::models::{ModifyOrderParams, OrderKey};
use polyester::{Price, Quantity};
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 order_id = "order-id-from-create".to_owned();
client.orders.modify(ModifyOrderParams {
symbol: "BTC-USDT".into(),
key: OrderKey::OrderId(order_id),
subaccount_id: None,
request_id: Some("mod-1".into()),
new_price: Some(Price::from_decimal_str("64100", Some("BTC-USDT".into()))?),
new_qty: Some(Quantity::from_decimal_str(
"0.2",
quantity_scale,
Some("BTC-USDT".into()),
None,
)?),
new_attached_risk: None,
behavior: None,
new_client_order_id: None,
}).await?;key is OrderKey::OrderId or OrderKey::ClientOrderId. Requires at least one of new_price, new_qty, new_attached_risk. If you omit request_id, the SDK generates one for that single
call; supply a stable value when retrying the same logical modify. See Requests & idempotency.
ModifyOrderParams fields
| Field | Required | Contract |
|---|---|---|
symbol | yes | Pair symbol used for routing and scale |
key | yes | Existing order identity (OrderKey) |
subaccount_id | no | Explicit scope override, never Some(0) |
request_id | no | SDK generates one when absent; provide and reuse one for retries |
new_price / new_qty / new_attached_risk | at least one | Desired patch |
behavior | no | Backend modify behavior string |
new_client_order_id | no | Replacement identity, locally validated |
Cancel by client order ID or order ID
let order_id = "order-id-from-create";
client.orders.cancel_by_client_order_id("mm-bot-001", Some("BTC-USDT"), None).await?;
client.orders.cancel_by_order_id(order_id, None).await?;Omitting both symbol and symbol_id deliberately sends symbol_id = 0, allowing a targeted
cancel to route through the order directory without waiting for catalog hydration. If you supply a
symbol, the SDK waits for catalog readiness and returns Error::Validation when it cannot resolve
that symbol. An explicitly supplied symbol_id must be non-zero.
A cancel of an order that has already ended, a repeat of the cancel that ended it included, is
refused with ERROR_CODE_ORDER_ALREADY_TERMINAL (err.is_order_already_terminal()). Treat it as
done and read the order for how it ended. Retry a cancel by order ID once you have it: a client
order ID names its order for at least a minute after the order ends, then reads ERROR_CODE_ORDER_UNKNOWN.
Cancel all
use polyester::models::CancelAllOpts;
let preview = client.orders.cancel_all_with(CancelAllOpts {
symbol: Some("BTC-USDT".into()),
dry_run: true,
request_id: Some("ca-preview-1".into()),
..Default::default()
}).await?;
println!("{}", preview.matched_orders);
client.orders.cancel_all_with(CancelAllOpts {
symbol: Some("BTC-USDT".into()),
dry_run: false,
side: Some("buy".into()),
request_id: Some("ca-1".into()),
..Default::default()
}).await?;If you omit request_id on cancel_all / cancel_all_with / cancel_all_after, the SDK
generates one for that single call. Supply a stable value when retrying the same logical bulk
cancel. Cancellation responses acknowledge that the request was accepted. The order read model can
lag briefly, so reconcile with list_open or get before releasing local state. Retrying the same
cancel for an order that remains visible is safe.
A cancel_all_with retry with the same request_id within 120 seconds returns the original counts
with an empty cancelled_orders and listing_status not_retained_read_open_orders. An empty
retried list does not mean nothing was canceled: read open orders for current state.
For process-owned cleanup, select owned client IDs and cancel them individually. A targeted cancel
that returns ERROR_CODE_ORDER_ALREADY_TERMINAL or ERROR_CODE_ORDER_UNKNOWN is idempotent success
because the order may have filled or left the book after the preceding read; every other cancel
error remains an error. Do not use
account-wide cancel_all as an ownership filter.
For cancel_all_after, use timeout_sec=0 to disable or 10 to 120 to arm. The API enforces this
range; the Rust SDK currently passes the value through.
cancel_all accepts only backend statuses submitted / dry_run; cancel_all_after accepts armed / disabled. Empty or unknown statuses and inconsistent cancel counts return the
non-retryable Error::ResponseContract. Because the server may already have accepted the
mutation, reconcile before taking further action.
Batch create
use polyester::models::{CreateOrderType, CreateSide, CreateTimeInForce};
use polyester::services::OrdersService;
use polyester::{Price, Quantity};
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 first = 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("50000", Some("BTC-USDT".into()))?),
Some("batch-001"),
);
first.time_in_force = Some(CreateTimeInForce::Gtc);
first.post_only = Some(true);
let mut second = first.clone();
second.client_order_id = Some("batch-002".into());
second.price = Some(Price::from_decimal_str("49950", Some("BTC-USDT".into()))?);
let batch = client
.orders
.batch_create(vec![first, second], None, Some("batch-request-1".into()))
.await?;
println!("{}", batch.results.len());Batch replace
use polyester::models::{BatchReplaceItem, OrderKey};
use polyester::Price;
let replace_price = Price::from_decimal_str("63900", Some("BTC-USDT".into()))?;
let receipt = client
.orders
.batch_replace(
vec![BatchReplaceItem {
key: OrderKey::ClientOrderId("batch-001".into()),
new_price: Some(replace_price),
new_qty: None,
new_attached_risk: None,
new_client_order_id: None,
}],
"BTC-USDT",
None,
Some("batch-replace-1".into()),
)
.await?;
println!("{} {}", receipt.batch_request_id, receipt.status);
let status = client
.orders
.get_batch_replace_status(&receipt.batch_request_id, None)
.await?;
println!("{} items", status.items.len());batch_replace is a same-symbol quote refresh only. Each item follows the API's default
amend-or-replace rule:
- An item that keeps the order's price, does not raise its total quantity and keeps its client
order ID amends the order in place.
action_takenis"amended": the order keeps its order ID and queue priority, andreplacement_order_idis empty. A new total quantity at or below the filled quantity ends the order at its filled quantity. - A new price, a larger total quantity or a new client order ID replaces the order.
action_takenis"replaced": a successor takes a new order ID, with its queue priority reset.
The write RPC returns a durable admission receipt (batch_request_id, accepted/rejected counts),
not a final execution outcome. After a replaced item is admitted, its predecessor order and client
IDs are stale. Switch immediately to its replacement_order_id and new client order ID in the
receipt. A predecessor get may return ERROR_CODE_ORDER_UNKNOWN; that is expected. For an amended
item, keep tracking old_order_id. A receipt item with no successor, such as an amended or rejected
item, has an empty replacement_order_id (never "0"), and old_order_id is empty when the server
named no order. An admitted item can still be refused when it is applied, so the
planned replacement_order_id is not proof of an order: only the batch status or order reads
confirm one. Call client.orders.wait_batch_replace(&receipt.batch_request_id, None, None) to poll get_batch_replace_status through admitted, working, rejected, and terminal. It polls
through the brief not-found answers after admission, and stops at the 60-second status window (or an
earlier timeout) with Error::Transport; reconcile through the orders after that. A refused item
reads rejected with code, and item.error_code() gives the full order error code.
For quote-refresh bots, status.is_settled() or is_batch_replace_settled(&status) means every
item is working, rejected, or terminal. It is a reconciliation checkpoint, not a final
execution outcome: working means the successor, or the amended order, is live. Reuse the same request_id for an ambiguous retry
and never replace against a stale predecessor.
Batch size contracts: the API declares batch_create max 20 and batch_replace / batch_cancel max 50. The Rust SDK accepts the same limits: at most 20 items in batch_create and 50 in batch_replace or batch_cancel. It returns Error::Validation above
that before sending, so split larger batches. Non-empty client order IDs must be unique
within one batch.
Every successful batch-create response item is either accepted or rejected. The SDK reconciles
the aggregate counts for create, replace, and cancel batches with their per-item outcomes. A
malformed or inconsistent success response returns non-retryable Error::ResponseContract with mutation_outcome_unknown() == true: reconcile every item rather than blindly retrying. Unknown
rejection enums remain visible as UNKNOWN_ERROR_CODE(<number>).
Batch timeouts and reconciliation
A timeout on a batch mutation is an unknown outcome, not proof that nothing committed. Do not
blindly resubmit the batch with new identifiers. Give the batch a stable request_id, give every
create item a unique client_order_id, and reconcile every item with get, list_open, or order
history before retrying. Reuse the same request_id for the same logical attempt, but do not treat
that ID as a whole-batch atomic replay guarantee: a retry may finish remaining items, replay a
cached result, or reject stale source items. The SDK sends each call once and does not promise
server-side atomicity.
Trade projection after fills
get can report cum_qty before every fill is visible on the trades list. Prefer orders.wait_for_order_trades_complete after fills when you need a terminal order whose trade rows
match cum_qty. It completes only once the order has ended (filled, canceled or rejected) and
its trades add up to cum_qty. executions_complete alone is not completion: it is also true for a
working order whose fills so far are all recorded, so a partly filled working order keeps the wait
polling until it ends or the timeout passes. To check the trades recorded so far on a working order,
read it with get and compare its trades to cum_qty.
Its timeout is an overall deadline, including each in-flight GetOrder call and every execution
page it reads (15 seconds when zero). A read that finishes after the deadline is a timeout
(Error::Transport), not a success, so pass a timeout long enough to read every page. A next_page_token that repeats a page token of the same read returns Error::ResponseContract;
handle it like any other failed read:
use polyester::models::OrderKey;
use std::time::Duration;
let order_id = "order-id-from-create";
let details = client
.orders
.wait_for_order_trades_complete(OrderKey::OrderId(order_id.to_owned()), Duration::from_secs(15))
.await?;
println!("{} trades", details.trades.len());Market BUY execution quantity can differ from the submitted quantity after venue normalization.
For cleanup, use the completed trade projection and convert fee_amount_e18 to the symbol's base
quantity scale for BUY fills whose fee_asset is "base"; subtract when fee_is_rebate is
false and add when it is true. Sell that net received base quantity, not the requested decimal
or gross cum_qty.
Dead-man's switch
client.orders.cancel_all_after(15, Some("BTC-USDT"), None, Some("caa-1".into())).await?;Cancel-all and the dead-man switch cancel open orders, never triggers: an unfired stop stays armed
and a TWAP keeps running. To stop all automated trading, call client.stop_all_trading(StopAllTradingScope::default()). It pages through the live triggers,
cancels each, then cancels all open orders. Triggers it could not cancel are listed in trigger_failures.
Read orders
use polyester::models::{ListOrderHistoryOpts, OrderKey};
let order_id = "order-id-from-create";
let open = client.orders.list_open(None).await?;
let hist = client.orders.list_history_with(ListOrderHistoryOpts {
symbol: Some("BTC-USDT".into()),
limit: Some(100),
..Default::default()
}).await?;
let details = client.orders.get(OrderKey::OrderId(order_id.to_owned()), None).await?;
let _ = (open, hist, details);Subscribe
subscribe streams the account's live orders over the private WebSocket and yields PrivateOrdersEvent values, not bare orders. The call returns at once: the handshake result arrives
as Connected or Error. Wait for Ready before relying on the stream.
use polyester::models::PrivateOrdersEvent;
let mut sub = client
.orders
.subscribe(client.default_account_id.as_deref())
.await?;
while let Some(event) = sub.recv_result().await? {
match event {
PrivateOrdersEvent::Snapshot(live) => println!("{} live orders", live.len()),
PrivateOrdersEvent::Ready => println!("order stream is current"),
PrivateOrdersEvent::Unit(unit) => {
for order in unit.orders {
let scale = client
.catalogs
.base_quantity_scale_for_symbol_id(order.symbol_id)
.ok_or_else(|| polyester::Error::validation("stream symbol quantity scale is unavailable"))?;
let leaves = match &order.leaves_qty {
Some(qty) => Some(qty.format(Some(scale))?),
None => None,
};
println!("{} {} {:?}", order.status, order.order_id, leaves);
}
}
PrivateOrdersEvent::Error(error) => eprintln!("order stream: {error}"),
_ => {}
}
}Keep the order copy with the higher version. See Streaming for every event. A consumer that falls
behind ends the stream with Error::QueueOverflow.
Private order payloads may omit quantity scale metadata. Resolve scale from the hydrated catalog by symbol_id (or the corresponding symbol) before formatting orig_qty, cum_qty, or leaves_qty. Never trust or invent a stream scale; fail closed when catalog lookup fails.
Identifier constraints
client_order_id and new_client_order_id accept 1 to 36 characters. Allowed characters are
ASCII letters, digits, ., _, :, /, and -. The SDK validates non-empty IDs locally
(Error::Validation) before send. If create omits client_order_id, the wire value is empty; the
SDK does not generate one. Mutation request IDs use the same character set, accept 1 to 64
characters, and are generated when omitted by the relevant mutation method.
Collateral during replace
Leave balance headroom when repricing a heavily reserved book. A replacement requires sufficient available balance for the new order; do not assume only the incremental collateral difference is reserved. Reconcile the original order after an ambiguous response. If necessary, cancel and recreate the quote.
Attached risk
Typed AttachedRisk on create/modify: take_profit / stop_loss (RiskLeg), optional trailing_stop (TrailingStop), and oco. RiskLeg and TrailingStop are #[non_exhaustive]:
build them with RiskLeg::market(trigger_price), RiskLeg::limit(trigger_price, limit_price), or TrailingStop::new(distance), then chain the setters with_activation_price, with_limit_price, with_trigger_price_source, and with_max_slippage.
use polyester::models::{
AttachedRisk, CreateOrderParams, CreateOrderType, CreateSide, RiskLeg, TrailingDistance,
TrailingStop,
};
use polyester::{Price, Quantity};
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 params = CreateOrderParams::new("BTC-USDT", CreateSide::Sell, CreateOrderType::Limit);
params.quantity = Some(Quantity::from_decimal_str("0.01", quantity_scale, Some("BTC-USDT".into()), None)?);
params.price = Some(Price::from_decimal_str("100000", Some("BTC-USDT".into()))?);
params.attached_risk = Some(AttachedRisk {
take_profit: Some(RiskLeg::market(Price::from_decimal_str("95000", Some("BTC-USDT".into()))?)),
// On a SELL order the trailing stop buys back, so it needs its maximum price.
trailing_stop: Some(
TrailingStop::new(TrailingDistance::Bps(100))
.with_limit_price(Price::from_decimal_str("105000", Some("BTC-USDT".into()))?),
),
..Default::default()
});
let result = client.orders.create(params).await?;A leg watches the last trade price unless trigger_price_source is set. Set it to TriggerPriceSourceKind::IndexPrice to watch the index price. MarkPrice returns Error::Validation, because spot markets have no mark price.
An attached trailing stop on a SELL order buys back, so it requires limit_price: the most its exit
pays per unit, at least its activation_price when that is set. An attached trailing stop on a BUY
order sells and takes no limit_price. Order creates, previews, and batch creates check both locally
and refuse a mistake as Error::Api with an entry in err.violations(). A modify or batch replace
does not carry the order's side, so the server applies the side rule there. Order reads do not return
the limit price.
Attached TrailingStop distance (ticks or bps) and optional max slippage must be positive. Its max_slippage_bps takes at most 9,999 on either side. TrailingStop::order_type is a deprecated
field: the child is always market-IOC, and supplying Some(...) returns Error::Validation instead
of being silently ignored. A trailing stop or modify that would act at once is covered under Triggers. An attached exit never trades with the order it
protects: when it fires while that order still rests, the order's unfilled rest is canceled first and
the order ends with CANCEL_REASON_ATTACHED_EXIT_FIRED.
Standalone triggers have their own independently validated trigger semantics.
Order shape
order_id, symbol_id, client_order_id, side, status, order_type, tif, orig_qty / cum_qty / leaves_qty / inherited_cum_qty (Option<Quantity>), price / avg_px (Option<Price>), avg_price_e18, cum_notional, fee_total, created_ts_ns, terminal_ts_ns, version, post_only, fee_asset, self_trade_prevention_mode, submitted_max_quote_debit_scaled, attached_risk, lineage, origin, market_client_ref_price, market_max_slippage, market_max_slippage_bps, expire_at, reject_reason, cancel_reason.
terminal_ts_ns(String)
When the order became terminal, in Unix nanoseconds as a decimal string; empty while it is open.origin(Option<OrderOrigin>)
How the order was created;Nonewhen the read does not say. See below.market_client_ref_price(Option<Price>)
Reference price a MARKET order's slippage protection was measured from;Nonewhen none was supplied.market_max_slippage(Option<Price>)
A MARKET order's maximum slippage as an absolute price distance;Nonewhen it has no cap of that form.market_max_slippage_bps(i32)
A MARKET order's maximum slippage in basis points; 0 when it has no cap of that form.
OrderOrigin carries:
scopedirect,attached_risk,standalone_triggerorsystem(UNKNOWN(<number>)for a newer scope).trigger_type
Strategy that placed the order:stop_loss,take_profit,trailing_stop,twaporladder; empty for a direct order.trigger_id/parent_order_id
The placing trigger, and the parent order of an attached-risk child.child_seq(u32)
The child's sequence number in a multi-child strategy such as a TWAP.
Each OrderOrigin field is empty or 0 when it does not apply. Order has no Default, so an Order { .. } literal you build, for example in tests, must set every field, including the five
above.
order.avg_price() is the exact average execution price, and order.cum_quote(base_scale) the exact
quote amount of the fills before fees, both as Decimal. order.quote_used(base_scale) and order.quote_unspent(base_scale, quote_scale) say how much of a quote-sized buy's amount the fills
used. Each is exact at any base quantity scale, never rounded: None only when the amount needs more
digits than a Decimal holds (and, for quote_used, until the fee total covers every fill).
Prices use the fixed 1e9 tick scale. reject_reason is the order error code the API returns for
the same refusal, and cancel_reason a CANCEL_REASON_... name; both are None for every other
status.