# Order book

Read order book snapshots, subscribe to depth updates, and keep a local book correct with book sequences, resets, and resyncs.

Polyester publishes each Pair's order book as aggregated price levels: one quantity per price per side. Read a snapshot with [`GET /v1/orderbook/{symbol}`](https://testnet.polyester.com/docs/api-docs/rest/GET/v1/orderbook/symbol) or [`OrderbookService.GetOrderBook`](https://testnet.polyester.com/docs/api-docs/connect/orderbook.v1.OrderbookService/GetOrderBook), then keep it current with the public order book update channel. Both are public and need no authentication.

## Snapshots

## REST JSON

```http
GET https://api.testnet.polyester.com/v1/orderbook/BTC-USDT?depth=5
```

```json
{
	"symbol": "BTC-USDT",
	"bookSeq": "835057",
	"available": true,
	"ts": 1793914668528,
	"asks": [
		["76090.29", "0.00131422"],
		["76090.3", "0.82087261"],
		["76105.52", "0.84519947"],
		["76120.74", "0.76896305"],
		["76135.97", "0.80528809"]
	],
	"bids": [
		["76059.87", "0.84582963"],
		["76044.66", "0.82029386"],
		["76029.45", "0.79617429"],
		["76014.25", "1.21666489"],
		["75999.05", "1.06646314"]
	]
}
```

Each level is a `[price, quantity]` pair of decimal strings. `bookSeq` is a decimal string and `ts` is the snapshot time in Unix milliseconds.

## ConnectRPC ProtoJSON

```http
POST https://api.testnet.polyester.com/orderbook.v1.OrderbookService/GetOrderBook
Content-Type: application/json

{"symbolId": 1, "depth": "DEPTH_5"}
```

```json
{
	"symbolId": 1,
	"bookSeq": "835057",
	"bids": [
		{ "priceTicks": "76059870000000", "qtyScaled": "84582963" },
		{ "priceTicks": "76044660000000", "qtyScaled": "82029386" }
	],
	"asks": [
		{ "priceTicks": "76090290000000", "qtyScaled": "131422" },
		{ "priceTicks": "76090300000000", "qtyScaled": "82087261" }
	],
	"ts": "2026-11-05T21:37:48.528466493Z",
	"available": true
}
```

The same snapshot as the REST tab, shortened to two levels per side. `priceTicks` uses a fixed scale of 9 and `qtyScaled` the Pair's `baseQuantityScale`; see [Scaled integers](https://testnet.polyester.com/docs/developer-docs/connectrpc/scaled-integers).

Bids are best first, highest price first. Asks are best first, lowest price first. Levels are not aggregated into wider price buckets; group them yourself for display, for example with the Pair's `marketdata.orderbookPriceBuckets` from [GetSpotConfig](https://testnet.polyester.com/docs/developer-docs/market-data/pairs-and-trading-rules).

### Depth

`depth` is the number of levels per side. REST takes the number; ConnectRPC takes the `Depth` enum.

| REST `depth`        | ConnectRPC `depth`                    |
| ------------------- | ------------------------------------- |
| `1`                 | `DEPTH_1`                             |
| `5`, `10`, `20`     | `DEPTH_5`, `DEPTH_10`, `DEPTH_20`     |
| `50` (default)      | `DEPTH_50` or `DEPTH_UNSPECIFIED`     |
| `100`, `200`, `500` | `DEPTH_100`, `DEPTH_200`, `DEPTH_500` |
| `1000`              | `DEPTH_1000`                          |

Any other REST value is rejected with HTTP 400 and code `INVALID_DEPTH`. An unknown symbol returns HTTP 404 with `UNKNOWN_SYMBOL`; symbols are case-sensitive. On ConnectRPC, an unknown `symbolId` returns HTTP 404 with `not_found`, and an unknown `Depth` name HTTP 400 with `invalid_argument`.

### When the book is unavailable

`available` is `false` while a Pair has no book yet, for example right after it opens, or after the book was invalidated. Its levels are then unknown, not empty. This is `WLD-USDT`, a disabled Pair without a listing time:

```json
{ "symbol": "WLD-USDT", "bookSeq": "0", "available": false, "ts": 0, "asks": [], "bids": [] }
```

On REST, an unavailable snapshot still carries every field: `ts` is `0`, `bookSeq` is `"0"`, and both sides are empty arrays. Read `available` before you use the levels or `ts`, and before you treat an empty side as no liquidity, then request the snapshot again later. On ConnectRPC an unavailable snapshot carries only `symbolId`, because ConnectRPC JSON omits default values, and its `ts` is unset.

With `available: true`, an empty side really is empty. `BTC-USDC`, a disabled Pair that is listed, answered with `available: true` and no levels on either side.

## Book sequence

`bookSeq` is the Pair's book sequence: it increases as the book changes, and it can also advance when no level changed. Snapshots and updates carry it so you can tell whether an update follows the state you hold.

- An update covers the range from `bookSeqStart` to `bookSeqEnd`. Each update on a channel starts where the previous one ended: its `bookSeqStart` equals the previous update's `bookSeqEnd`.
- The sequence advances by more than one between updates. Never expect `bookSeqEnd` to be `bookSeqStart + 1`.
- After a reset, the sequence can restart at a lower value or stay unchanged. A reset always replaces your book, whatever its sequence.

## Realtime updates

Subscribe to one depth tier of one Pair. The JSON channel carries decimal strings; the Protobuf channel carries `orderbook.v1.OrderBookDelta` with scaled integers.

```text
public:spot:orderbook:deltas:depth:<depth>:<symbolId>:json
public:spot:orderbook:deltas:depth:<depth>:<symbolId>:proto
```

`<depth>` is one of the published tiers `1`, `20`, `50`, `200`, or `500`. A subscription to another depth is accepted but receives nothing. Smaller tiers wait less before publishing, though a larger tier can send more updates because more of its levels change. Both channel names need the encoding suffix. See [Realtime channels](https://testnet.polyester.com/docs/developer-docs/market-data/realtime-channels) for how to connect.

These are two consecutive updates sent on `public:spot:orderbook:deltas:depth:20:1:json`:

```json
{"symbolId":1,"bookSeqStart":811343,"bookSeqEnd":811361,"reset":false,"ts":1793914066430,"bids":[["75749.82","0"],["75585.15","1.78487547"]]}
{"symbolId":1,"bookSeqStart":811361,"bookSeqEnd":811368,"reset":false,"ts":1793914066597,"bids":[["75786.57","0"],["75562.48","1.85352414"]]}
```

| Field                        | Meaning                                                                                                                             |
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `bookSeqStart`, `bookSeqEnd` | Sequence range of the update. JSON numbers here, unlike the snapshot's string `bookSeq`. `bookSeqStart` can be `0` on a reset       |
| `reset`                      | `true`: clear your book for this tier, then install the levels in this update                                                       |
| `bids`, `asks`               | `[price, quantity]` changes. The quantity replaces the level's quantity; `"0"` removes the level. A side without changes is omitted |
| `ts`                         | Best-effort time of the update, in Unix milliseconds. Protobuf carries a `google.protobuf.Timestamp`                                |

Levels within an update are not sorted. Each update describes the top `<depth>` levels per side, so a level that leaves the top `<depth>` arrives with quantity `0` even if it still rests deeper in the book, and a level that moves into the top arrives with its quantity. In the first update above, `75749.82` left the top 20 bids and `75585.15` entered.

A reset carries the whole tier. This one was sent on `public:spot:orderbook:deltas:depth:1:1:json`:

```json
{
	"symbolId": 1,
	"bookSeqStart": 0,
	"bookSeqEnd": 817439,
	"reset": true,
	"ts": 1793914219342,
	"bids": [["75983.86", "0.01314094"]],
	"asks": [["76014.26", "0.85159909"]]
}
```

## Maintain a local book

This is the same pattern as other major venues: buffer updates, fetch a snapshot, discard what the snapshot already contains, then apply updates in sequence.

1. Subscribe and buffer

   Subscribe to the depth tier you want, for example `depth:50`, and buffer the updates you receive. Subscribe with recovery enabled; see [Realtime channels](https://testnet.polyester.com/docs/developer-docs/market-data/realtime-channels#connect-and-subscribe).

2. Fetch a snapshot at the same depth

   Request `GetOrderBook` with the same depth as the tier. If `available` is `false`, wait and request it again. Install its levels and remember its `bookSeq` as the last applied sequence.

3. Drop what the snapshot contains

   Discard buffered updates whose `bookSeqEnd` is at or below the snapshot's `bookSeq`.

4. Apply updates in sequence

   Apply the next update only if its `bookSeqStart` equals your last applied sequence. Replace or remove each level it lists, then store its `bookSeqEnd` as the last applied sequence. Ignore an update whose `bookSeqEnd` is at or below it; you already hold that state.

5. Apply resets at any time

   When an update has `reset: true`, clear both sides, install its levels, and store its `bookSeqEnd`, whatever your last applied sequence was. Resets are sent when a tier starts publishing, after the Pair's book is rebuilt, and after a publication could not be delivered. Testnet also sends one on every tier about every 30 seconds, so expect them in normal operation.

6. Resync on a gap

   If a non-reset update starts at any other sequence, your book has missed changes. Discard it, keep buffering, and repeat from the snapshot step. A snapshot can also fall inside an update's range, most often on `depth:1`, so a resync can take more than one attempt. Do the same when the subscription reports that missed publications could not be recovered.

```ts
// Per tier: price string -> quantity string, for each side.
function apply(book, update) {
	if (update.reset) {
		book.bids.clear();
		book.asks.clear();
	} else if (update.bookSeqEnd <= book.seq) {
		return "ignore";
	} else if (update.bookSeqStart !== book.seq) {
		return "resync";
	}
	for (const [price, qty] of update.bids ?? []) setLevel(book.bids, price, qty);
	for (const [price, qty] of update.asks ?? []) setLevel(book.asks, price, qty);
	book.seq = update.bookSeqEnd;
	return "applied";
}

function setLevel(side, price, qty) {
	if (Number(qty) === 0) side.delete(price);
	else side.set(price, qty);
}
```

Compare prices and quantities as exact decimals in production code; the `Number(qty) === 0` check above only tests for zero. Sort a side when you read it, not on every update.

> **A quiet book and a stalled feed look the same**
>
> Between resets, a tier sends an update only when its view changes, and a Pair whose book is unavailable sends nothing at all. Check the connection itself for liveness, and request a fresh snapshot if you have not received an update for longer than you accept for that Pair.

### Choose a tier

Use the smallest tier that covers what you need: `1` for the best bid and ask, `20` or `50` for a trading ladder, `200` or `500` for depth charts. Snapshots support every depth up to `1000`, but realtime updates stop at `500`. To show fewer levels than a tier carries, keep the full tier locally and display the top of it.

## Rate limits

See [Rate limits](https://testnet.polyester.com/docs/developer-docs/shared-concepts/rate-limits). Prefer the realtime channel over polling snapshots; request a snapshot only to start or resync a book. The full request and response schema is in the [API reference](https://testnet.polyester.com/docs/api-docs/connect/orderbook.v1.OrderbookService/GetOrderBook).
