# Two-sided quoting

Maintain one post-only bid and ask with exact book prices, independent sizing, partial-batch reconciliation, and a dead-man switch.

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 decimal prices and quantities; the SDK validates, submits, streams, and reconciles them.

> **Start on a dedicated devnet account**
>
> Fund both quote and base trading balances. Keep the write gate disabled until the dry-run plan, prefix cleanup, and dead-man switch have been verified. This is an execution pattern, not a profitable strategy or financial advice.

## Quote-cycle invariants

1. Use a fresh, sequence-checked book.
2. Size bid and ask independently from available quote and base balances.
3. Give every quote a unique client order ID under a process-owned prefix.
4. Inspect every batch item; a successful RPC is not an all-items guarantee.
5. Reconcile open orders before retrying an unknown outcome.
6. Keep the dead-man switch refreshed while writes are enabled.

1) Read a quoteable book

   ```python
   book = await client.orderbook.get(symbol="BTC-USDT", depth=20)
   if not book.bids or not book.asks:
       raise RuntimeError("cannot quote an empty or one-sided book")

   bid_price = book.bids[0].price
   ask_price = book.asks[0].price
   if bid_price is None or ask_price is None:
       raise RuntimeError("best level is missing a decoded price")
   best_bid = bid_price.format()
   best_ask = ask_price.format()
   print("book_seq", book.book_seq, "bid", best_bid, "ask", 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.

2) Size both sides independently

   ```python
   from polyester import format_ledger_u128

   balances = await client.balances.list()
   for balance in balances.balances:
       print(
           balance.asset_id,
           "available",
           format_ledger_u128(balance.available),
           "reserved",
           format_ledger_u128(balance.reserved),
       )
   ```

   `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. The tested sizing functions live in [`polyester-examples-python`](https://github.com/Fabric-Labs/polyester-examples-python).

3) Submit one bid and one ask

   ```python
   from uuid import uuid4

   book = await client.orderbook.get(symbol="BTC-USDT", depth=20)
   if not book.bids or not book.asks:
       raise RuntimeError("cannot quote an empty or one-sided book")
   bid_price = book.bids[0].price
   ask_price = book.asks[0].price
   if bid_price is None or ask_price is None:
       raise RuntimeError("best level is missing a decoded price")
   best_bid = bid_price.format()
   best_ask = ask_price.format()
   bid_id = f"quote-bid-{uuid4().hex[:20]}"
   ask_id = f"quote-ask-{uuid4().hex[:20]}"
   created = await client.orders.batch_create(
       symbol="BTC-USDT",
       items=[
           {
               "symbol": "BTC-USDT",
               "side": "buy",
               "order_type": "limit",
               "tif": "gtc",
               "qty": "0.001",
               "price": best_bid,
               "post_only": True,
               "client_order_id": bid_id,
           },
           {
               "symbol": "BTC-USDT",
               "side": "sell",
               "order_type": "limit",
               "tif": "gtc",
               "qty": "0.001",
               "price": best_ask,
               "post_only": True,
               "client_order_id": ask_id,
           },
       ],
   )
   for item in created.results:
       print(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.

4) Reconcile the desired quote set

   ```python
   open_orders = await client.orders.list_open(limit=100)
   owned = {
       order.client_order_id: order
       for order in open_orders.orders
       if order.client_order_id.startswith("quote-")
   }
   for client_order_id, order in owned.items():
       print(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.

5) Arm the dead-man switch and clean up

   ```python
   from uuid import uuid4
   from polyester import PolyesterApiError

   armed = await client.orders.cancel_all_after(
       timeout_sec=30,
       symbol="BTC-USDT",
       request_id=f"quote-dms-{uuid4().hex[:20]}",
   )
   if armed.status != "armed":
       raise RuntimeError(f"unexpected dead-man status {armed.status!r}")

   open_orders = await client.orders.list_open(limit=100)
   for order in open_orders.orders:
       if order.client_order_id.startswith("quote-"):
           try:
               await client.orders.cancel(
                   client_order_id=order.client_order_id,
                   symbol="BTC-USDT",
               )
           except PolyesterApiError as exc:
               if str(exc.code or "").lower() != "not_found":
                   raise
   ```

   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 `not_found` 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

| Observation                    | Required action                                                              |
| ------------------------------ | ---------------------------------------------------------------------------- |
| Post-only rejection            | Refresh the book and compute a new price                                     |
| `PolyesterValidationError`     | Fix input; do not retry unchanged                                            |
| Timeout/transport interruption | Treat outcome as unknown and reconcile IDs                                   |
| 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                           |
| Queue overflow                 | Replace the subscription and run a full snapshot reconciliation              |

## Evidence

The exact order, batch, book, balance, and dead-man calls above are type-checked by the documentation gate. Released-SDK example `07` exercises batch post-only create and cleanup; the long-running Python canary exercises managed books, balance-aware sizing, private order updates, post-only create, cancel, reconciliation, and staleness handling on devnet.

## Related

- [Build a production trading bot](https://testnet.polyester.com/docs/sdk/python/tutorials/trading-bot)
- [Orders](https://testnet.polyester.com/docs/sdk/python/reference/orders)
- [Order book](https://testnet.polyester.com/docs/sdk/python/reference/order-book)
- [Requests & idempotency](https://testnet.polyester.com/docs/sdk/python/concepts/requests-and-idempotency)
- [Error handling](https://testnet.polyester.com/docs/sdk/python/guides/error-handling)
