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:
WaitForCatalogs, decimal price/qty helpers. On failure: stop quoting until catalogs recover. - Never quote from a gapped book:
Orderbook.CreateSubscription. On failure: wait for its snapshot refresh. - Never duplicate a logical quote: Stable
ClientOrderIDper quote attempt. On failure: reconcile before retrying. - Never treat admission as final state:
ListOpen,Get, private order stream. On failure: keep local state pending. - Never leave unknown live risk:
CancelAllAfterplus targeted cancel. On failure: reconcile all bot-prefixed orders. - Never retry validation unchanged:
*errors.ValidationError. On failure: correct configuration or input.
Create a client and hydrate catalogs
accountID := os.Getenv("POLYESTER_ACCOUNT_ID")
client, err := polyester.New(polyester.Config{
Environment: &polyester.TestnetEnvironment,
APIKeyID: os.Getenv("POLYESTER_API_KEY_ID"),
APIPrivateKey: auth.NewSecret(os.Getenv("POLYESTER_API_PRIVATE_KEY")),
DefaultAccountID: &accountID,
HydrateCatalogs: true,
})
if err != nil { log.Fatal(err) }
defer client.Close()
ctx := context.Background()
if err := client.WaitForCatalogs(ctx); err != nil { log.Fatal(err) }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
sub, err := client.Orderbook.CreateSubscription(ctx, services.CreateSubscriptionOptions{
Symbol: "BTC-USDT",
Depth: 20,
OnSequenceGap: func() {
log.Print("book gap; waiting for snapshot refresh")
},
OnSnapshotRefresh: func() {
log.Print("book snapshot refreshed")
},
})
if err != nil { log.Fatal(err) }
defer sub.Close()
book := <-sub.Updates()
if len(book.Bids) == 0 || len(book.Asks) == 0 {
log.Print("book is not quoteable")
} else {
fmt.Println(book.Bids[0].Price, book.Asks[0].Price, book.BookSeq)
}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
balances, err := client.Balances.List(ctx, nil, nil)
if err != nil { log.Fatal(err) }
for _, balance := range balances.Balances {
available, err := codecs.FormatLedgerU128(balance.Available, codecs.LedgerScale)
if err != nil { return err }
fmt.Println(balance.AssetID, "available", 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. Cap each quote by all of:
- available trading balance (
Available, which already excludesReserved), - API-key policy maximum order size,
- strategy inventory and notional limits,
- pair minimum quantity/notional and step size.
Size with exact decimal or integer arithmetic rather than binary floating point. To reserve the quote fee on a buy, use the rates in Fees.
Place one uniquely identified post-only quote
symbol := "BTC-USDT"
side := "buy"
tif := "gtc"
clientOrderID := fmt.Sprintf("trader-%s-%d", side, time.Now().UnixNano())
price := models.PriceFromDecimal("50000")
created, err := client.Orders.Create(ctx, models.CreateOrderRequest{
Symbol: &symbol,
Side: side,
OrderType: "limit",
TIF: &tif,
Qty: models.QtyFromDecimal("0.001"),
Price: &price,
PostOnly: true,
ClientOrderID: &clientOrderID,
}, nil)
if err != nil { log.Fatal(err) }
fmt.Println(created.Status, created.OrderID)Keep 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
requestID := fmt.Sprintf("dms-%d", time.Now().UnixNano())
armed, err := client.Orders.CancelAllAfter(ctx, nil, 30, nil, &symbol, nil, &requestID)
if err != nil { log.Fatal(err) }
if armed.Status != "armed" { log.Fatalf("unexpected dead-man status %q", armed.Status) }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
BatchReplace 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
(ActionTaken is amended): keep tracking OldOrderID. Any other item is replaced
(ActionTaken 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 ReplacementOrderID; a
predecessor lookup may return not_found / ORDER_UNKNOWN, which is expected. Call WaitBatchReplace, which polls GetBatchReplaceStatus through admitted, working, rejected, and terminal, including the brief not-found after admission.
For a quote-refresh loop, use models.IsBatchReplaceSettled(status) or models.BatchReplaceStatusSettled(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 requestID after an ambiguous retry. Do not send another replacement against a
stale predecessor.
Reconcile instead of guessing
open, err := client.Orders.ListOpen(ctx, nil, nil, nil, nil, true, false, nil)
if err != nil { log.Fatal(err) }
for _, order := range open.Orders {
if !strings.HasPrefix(order.ClientOrderID, "trader-") {
continue
}
key := models.OrderKeyByClientID(order.ClientOrderID)
if _, err := client.Orders.Cancel(ctx, nil, key, &symbol, nil, nil); err != nil {
var apiErr *sdkerrors.APIError
gone := sdkerrors.IsOrderAlreadyTerminal(err) ||
(errors.As(err, &apiErr) && apiErr.Code == "ERROR_CODE_ORDER_UNKNOWN")
if !gone {
return 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 CancelAll 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
Install signal.NotifyContext for SIGINT and SIGTERM. Stop the quote producer first, close
subscriptions, run the same bot-prefix reconciliation shown above with a bounded cleanup context,
then close the client. If cleanup cannot be confirmed, leave the dead-man switch armed and alert
an operator.
Trading API map
WaitForCatalogs(readiness or error): Startup gate for scales and IDsBalances.List(models.BalancesList): Available trading collateralOrderbook.Get(models.OrderbookData): One-shot health checkOrderbook.CreateSubscription(orderbook.Subscription): Sequence-checked bid/askOrders.Create(OrderMutationResult): Place one identified quoteOrders.Modify(ModifyOrderResult): Amend or replace with headroomOrders.Cancel(OrderMutationResult): Targeted cleanupOrders.ListOpen(OrdersList): Restart reconciliationOrders.Get(order plus related trades): Resolve one ambiguous lifecycleOrders.Subscribe(order stream events): Working and terminal changesOrders.CancelAllAfter(armed or disabled result): Dead-man switchOrders.BatchCreate(per-item outcomes and counts): Submit up to 20 quotesOrders.BatchReplace(admission receipt): Refresh up to 50 quotesOrders.WaitBatchReplace(settled status phases): Reconcile a refreshOrders.BatchCancel(per-item outcomes): Cancel up to 50 quotesOrders.WaitForOrderTradesComplete(order plus complete trades): Fee-correct fill accounting once the order has ended
Use the linked reference pages for exact positional arguments and return shapes. Treat every mutation timeout as an unknown outcome until reads or the private stream resolve it.
The snippets on this page compile against polyester-sdk-go v0.11.0.