Metadata-Version: 2.5
Name: polyester-sdk
Version: 0.6.0
Summary: Python SDK for the Polyester API with generated protobuf types and ConnectRPC clients.
Project-URL: Homepage, https://github.com/Fabric-Labs/polyester-sdk-python
Project-URL: Documentation, https://polyester.com/docs/sdk/python/get-started/overview
Project-URL: Repository, https://github.com/Fabric-Labs/polyester-sdk-python
Project-URL: Issues, https://github.com/Fabric-Labs/polyester-sdk-python/issues
Author: Fabric Blockchain Labs Inc.
License: Proprietary
License-File: LICENSE
Keywords: connectrpc,polyester,protobuf,sdk,trading
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: Other/Proprietary License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: base58>=2.1.1
Requires-Dist: connectrpc<0.11,>=0.10.0
Requires-Dist: cryptography>=42
Requires-Dist: eth-abi>=5.1
Requires-Dist: eth-account>=0.13
Requires-Dist: eth-hash[pycryptodome]>=0.7
Requires-Dist: eth-utils>=5.0
Requires-Dist: httpx>=0.27
Requires-Dist: msgspec>=0.18
Requires-Dist: protobuf>=7.35.0
Requires-Dist: pyqwest>=0.5.1
Requires-Dist: websockets>=15
Provides-Extra: chain
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: mypy>=1.13; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest-httpserver>=1.1; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: python-dotenv>=1.0; extra == 'dev'
Requires-Dist: ruff>=0.8; extra == 'dev'
Requires-Dist: twine>=5; extra == 'dev'
Provides-Extra: realtime
Description-Content-Type: text/markdown

# Polyester Python SDK

Official Python SDK for Polyester APIs, built for trading bots, backend jobs,
research notebooks, and automation.

**Status:** `0.6.0`, pre-1.0: a minor release may include breaking changes (see
[CHANGELOG.md](CHANGELOG.md)). Proprietary license (not open source).
API-key only; no browser login or JWT flows.

Requires **Python 3.11+**.

## Supported surface

<!-- sdk-capabilities:start -->
<!-- Generated by scripts/check_sdk_coverage.py --write-capabilities. Do not edit. -->

| Capability | Supported |
| --- | --- |
| Public market data (spot config, trades, candles) | Yes |
| Order book snapshot + realtime | Yes |
| Market overview (list + subscribe) | Yes |
| Order book heatmap | Yes |
| API-key (Ed25519 signature) auth | Yes |
| Wallet / browser login | No |
| Session MFA enrollment and challenges | No |
| Profile (identity subscribe) | Yes |
| API keys (list/get/subscribe/local keypair generation) | Yes |
| Subaccounts (list/get/members/invites/activity/subscribe) | Yes |
| Address book (list/view/create/update/subscribe) | Yes |
| Policies (realtime subscribe) | Yes |
| Guard signer | Yes |
| VIP tiers + status | Yes |
| Spot fee rates | Yes |
| Trading rate limits | Yes |
| Product changelog (posts + changes) | Yes |
| Balances and equity history | Yes |
| Orders (create, cancel, modify, batch, cancel-all) | Yes |
| User trades | Yes |
| Triggers | Yes |
| Internal transfers | Yes |
| Transfer history | Yes |
| Deposit addresses | Yes |
| Trading / funding withdraws | Yes |
| Zipper deposit-withdraw config | Yes |
| Chain analytics | Yes |
| Lifecycle flows | Yes |
| Realtime account and market streams | Yes |
| Reference catalogs + wait-for-ready | Yes |
| Qty / price decimal + scaled-int inputs | Yes |
| Social verification | Yes |
| Account resolve / lookup | No |

Rows marked **No** are intentional for API-key SDKs (use the TypeScript
browser client for wallet login and session MFA).

Full cross-language comparison:
[SDK capability matrix](https://polyester.com/docs/developer-docs/getting-started/sdk-capability-matrix).
<!-- sdk-capabilities:end -->

Rows marked **Yes** mean that an SDK wrapper exists; deployment authorization
still applies. In particular, social-verification routes may require a JWT
session or may not be mounted.
A sub-account member call that targets the sub-account's owner (inviting the
owner, accepting an invite for the owner, changing the owner's role or
removing the owner) raises `PolyesterSubaccountOwnerRoleLockedError`, a
`PolyesterApiError` with `code == "AUTH_SUBACCOUNT_OWNER_ROLE_LOCKED"`
(Connect `failed_precondition`, REST 412). The request changed nothing and is
not retryable: show "The owner's role can't be changed", and don't offer these
actions for the member whose role is `OWNER`.

`sub_accounts.get(sub_account_id=..., include_balances=True)` also returns the
sub-account's current `balances`, a `BalancesList` in the shape of
`balances.list` (one `AssetBalance` per asset: `funding`, `trading`,
`reserved`, `available`). Without `include_balances`, `balances` is `None`.
It needs the `READ_BALANCES` action on the sub-account (its role, its attached
policy and, for an API key, the key's policy), or the call raises
`PermissionDenied` with `AUTH_SUBACCOUNT_ACCESS_DENIED`. An API key bound to a
sub-account reads only that one; a root-scoped API key cannot read a
sub-account's balances. When balances cannot be read right now the call raises
`AUTH_TEMPORARILY_UNAVAILABLE`, which `is_retryable_error(err)` reports
retryable: retry, or omit `include_balances` for the rest of the view.

```python
view = await client.sub_accounts.get(sub_account_id=sub_id, include_balances=True)
for row in view.balances.balances if view.balances else []:
    print(row.asset_id, row.trading, row.available)
```

Private streams require an Account ID and the corresponding API-key permission.
A successful subscribe call means the token exchange and realtime handshake
completed. Treat a structured permission denial as non-transient and update the
API-key policy before retrying.

## Install

PyPI: https://pypi.org/project/polyester-sdk/

```bash
pip install "polyester-sdk==0.6.0"
```

Realtime (Centrifugo) and on-chain Funding helpers are included by default.

The PyPI sdist/wheel do **not** include `tests/`. Full `pytest` (unit, hardening,
live) requires a git checkout of this repository.

For development from a git checkout:

```bash
git clone https://github.com/Fabric-Labs/polyester-sdk-python.git
cd polyester-sdk-python
python3.11 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```

## Examples

Runnable cookbook examples live in the sibling repo
[`polyester-examples-python`](https://github.com/Fabric-Labs/polyester-examples-python)
(REST market data, realtime streams, decimal + scaled-int order paths, and bots).

## Quickstart

Create an API key in the Polyester app (**API** in the sidebar). Copy the key id
and private key when shown. The private key is only displayed once.
Open the key's **Permissions**, enable **Spot trading**, select the markets it
may trade, and set a maximum order size appropriate for the strategy.
For a subaccount-scoped key, attach an **API-key policy** that grants ledger
reads for balances and private balance streams, plus Spot trading for order
mutations. This is separate from the **subaccount policy**: authorization is the
intersection of both policies, so configuring only the subaccount policy is
insufficient.

```python
import asyncio

from polyester import AsyncPolyester, POLYESTER_DEVNET_ENVIRONMENT

async def main() -> None:
    async with AsyncPolyester(
        environment=POLYESTER_DEVNET_ENVIRONMENT,
        api_key_id="ak_...",           # from API key creation
        api_private_key="...",       # 64-char hex secret from API key creation
        default_account_id="...",    # Profile → Account ID (see below)
    ) as client:
        overview = await client.market_overview.list(limit=5)
        for market in overview.markets:
            last = market.last_price.ticks if market.last_price is not None else None
            index = market.index_price.ticks if market.index_price is not None else None
            print(market.symbol, last, index)

        open_orders = await client.orders.list_open()
        print(f"{len(open_orders.orders)} open orders")

asyncio.run(main())
```

## Credentials

| Value | Where to find it | Constructor parameter |
| --- | --- | --- |
| API key id | **API** → create or view key | `api_key_id` |
| API private key | Shown once when the key is created | `api_private_key` |
| Account ID | **Profile** → **Account ID** | `default_account_id` |

Pass an `environment` preset so API, websocket, RPC, and contract pins stay
together. `POLYESTER_DEVNET_ENVIRONMENT` is the constructor default.
`POLYESTER_TESTNET_ENVIRONMENT` targets public testnet (`api.testnet.polyester.com`,
chain `888169`). Custom / VPC endpoints use `create_polyester_environment` or
`environment.with_urls(...)`. `api_url` / `ws_url` remain as transport overrides.

Pass all credentials as **constructor parameters**. The SDK does not read
environment variables unless you pass them in yourself (or use `from_env()` in
scripts; see below). `from_env()` also reads `POLYESTER_ENV=devnet|testnet`.

`api_private_key` accepts the 64-character hex Ed25519 secret from key creation,
or raw 32-byte key material.

`default_account_id` is the **Account ID** string from your Profile page. Use the
value exactly as shown in the app. Do not use an internal numeric id.
An account username is optional; Account-ID-based authentication and private
channel scoping are valid without one.

`default_account_id` is optional for public market-data calls. It is required for
account-scoped operations such as private realtime channels, bucket transfers, and
some ledger writes.

Automatic request signing gives concurrent identical calls distinct authentication tuples.
Timestamps can lead the local clock by at most five seconds. The async client applies bounded,
cooperative backpressure without blocking the event loop. The low-level synchronous
`polyester.auth.sign_request` helper instead raises `PolyesterRateLimitError` immediately when
capacity is exhausted; honor `retry_after`. Neither path reuses a signature or drifts outside the
API's 10-second freshness window.

Connect `resource_exhausted` and HTTP 429 responses raise `PolyesterRateLimitError`. When the
server attaches `polyester.ratelimit.v1.RateLimitDetail` (top-level Connect detail or nested under
`orders.v1.ErrorDetail.rate_limit`), inspect `error.detail` for `policy_class`, `scope`,
`operation_id`, and presence-aware quota fields. `retry_after` prefers `detail.retry_after_ms`,
then `Retry-After` / `Retry-After-Ms` / `Grpc-Retry-Pushback-Ms` headers on HTTP paths. Preview
and batch rejections expose the same payload on `OrderErrorDetail.rate_limit` / batch item
`rate_limit`. That error payload is distinct from `client.rate_limits`
(`ratelimit.v1.RateLimitService`), which returns the public trading quota catalog and
authenticated account / API-key limits.

## Authentication patterns

**Recommended: explicit parameters**

```python
from polyester import AsyncPolyester

client = AsyncPolyester(
    api_key_id="ak_...",
    api_private_key="...",
    default_account_id="YOUR_ACCOUNT_ID",
)
```

**If your deployment stores secrets in environment variables**, read them in your
application and pass them to the constructor:

```python
import os

from polyester import AsyncPolyester

client = AsyncPolyester(
    api_key_id=os.environ["POLYESTER_API_KEY_ID"],
    api_private_key=os.environ["POLYESTER_API_PRIVATE_KEY"],
    default_account_id=os.environ["POLYESTER_ACCOUNT_ID"],
)
```

The plain `AsyncPolyester(...)` / `Polyester(...)` constructors never implicitly
read `os.environ`.

**Scripts and local tests only:** `AsyncPolyester.from_env()` and
`Polyester.from_env()` load `POLYESTER_API_KEY_ID`, `POLYESTER_API_PRIVATE_KEY`,
`POLYESTER_ACCOUNT_ID`, and optional `POLYESTER_API_URL` / `POLYESTER_WS_URL`
from the process environment. This is a convenience helper, not the primary
integration pattern.

## Create and cancel orders

```python
from polyester import AsyncPolyester
from polyester.models import ClientOrderId

async with AsyncPolyester(
    api_key_id="ak_...",
    api_private_key="...",
    default_account_id="YOUR_ACCOUNT_ID",
    default_sub_account_id="",  # main account; omit subaccount scoping
) as client:
    result = await client.orders.create(
        symbol="BTC-USDT",
        side="buy",
        order_type="limit",
        tif="gtc",  # gtc, ioc, fok, or gtd (gtd requires expires_at)
        qty="0.01",
        price="100",
        post_only=True,
        client_order_id="my-bot-001",
    )
    print(result.status, result.order_id)

    await client.orders.cancel(key=ClientOrderId("my-bot-001"))
```

`client_order_id` is **optional** (matches the API). Omit it for one-shot
creates. **Set a stable non-empty value when you may need to reconcile** after
an ambiguous transport/server failure. Without it you cannot tell whether the
first attempt admitted the order. After an unknown outcome, look up that id
(`get` / `list_open`) before creating again. A second create with the same id
is rejected (`CONFLICT_DUPLICATE_CLIENT_ORDER_ID`) even when the payload
matches. Only create again with that id if reconciliation shows the first
attempt did not admit. When the server knows the order that already holds the
id, the rejection carries it: `PolyesterApiError.existing_order_id` on a
single create, `BatchCreateResultItem.existing_order_id` on a batch create
item (empty otherwise).
Client order ids accept 1 to 36 ASCII letters, digits, `.`, `_`, `:`, `/`, and
`-`. Batch create, cancel, and replace accept at most 20 items. `batch_create`
always sends `request_id` (caller value or a generated `batch-create-*` key);
that batch-level id is the idempotency boundary, so per-item `client_order_id`
may be omitted. Treat a cancel
response as an admission acknowledgement and reconcile with `list_open` before
releasing local state.

Attached take-profit / stop-loss use the same friendly keys as the read
model (`trigger_price`, `trigger_price_source`, `order_type`, `limit_price`). Child execution is
`market` (default) or `limit`, not `limit_ioc` / `limit_fok`, and not a
nested `child.execution` wrapper.

```python
result = await client.orders.create(
    symbol="BTC-USDT",
    side="buy",
    order_type="limit",
    tif="gtc",
    qty="0.01",
    price="100",
    post_only=True,
    attached_risk={
        "take_profit": {"trigger_price": "140", "order_type": "market"},
        "stop_loss": {"trigger_price": "80", "order_type": "market"},
        "oco": True,
    },
)
detail = await client.orders.get(
    key=ClientOrderId(result.client_order_id),
    include_attached_risk=True,
)
```

Create sizing is explicit: set exactly one of base `qty` or `max_quote_debit`
(a hard all-in quote-debit budget). Decimal/str quote budgets use the pair's
catalog `quote_quantity_scale`. Typed budgets must use
`QuantityDomain.ORDER_QUOTE` and embed that scale; construct with
`Quantity.from_quote_decimal_str` / `from_quote_decimal` / `from_quote_scaled`
and validate against `client.catalogs.quote_quantity_scale_for_symbol`.

```python
quote_scale = client.catalogs.quote_quantity_scale_for_symbol("BTC-USDT")
assert quote_scale is not None  # await client.wait_for_catalogs() first
result = await client.orders.create(
    symbol="BTC-USDT",
    side="buy",
    order_type="market",
    max_quote_debit=Quantity.from_quote_decimal_str(
        "25.00", quote_scale, symbol="BTC-USDT"
    ),
)
```

Use `orders.preview_order(...)` to check current order admissibility and, when
available, resolved base quantity plus protected price bound before submitting.
Preview accepts the same public create kwargs and encodes them as an
`OrderIntent` (same wire contract as create). It does not return fee or
quote-debit estimates. `protected_price_bound` is a protective execution
boundary, not an expected fill price. Rejections carry stable labels such as
`BAD_QTY` plus field violations; `evaluated_at_ms` records when admission was
evaluated. Preview is not deployed on every API
host, so handle an unimplemented/not-found response and do not make Preview a
prerequisite for order submission.

Market orders are IOC and enforce a slippage-derived execution boundary. See
[Market Order Price Protection](https://polyester.com/docs/developer-docs/shared-concepts/market-order-price-protection)
before overriding pair defaults. Set at most one of `max_slippage_bps` or
`max_slippage_ticks`. `max_slippage_bps` is 1–10000 for a buy and 1–9999 for a
sell. Omit both to keep the pair default. The fields are rejected on limit
orders and if both are supplied.

```python
result = await client.orders.create(
    symbol="BTC-USDT",
    side="buy",
    order_type="market",
    qty="0.01",
    max_slippage_bps=25,  # or max_slippage_ticks=10; not both
)
```

Decimals may carry zeros beyond their scale: `qty="1.500"` on a pair with base
scale 2 is `1.5`, and `price="70000.0000000000"` is `70000`. A nonzero digit
beyond the scale is off the pair's step or tick, so create, preview, batch
create and modify raise `PolyesterValidationError` with the code and violation
the server answers for that value: `ERROR_CODE_QTY_STEP_SIZE` / `step_size`
at `order.base_qty_scaled` for the quantity, `ERROR_CODE_PRICE_TICK_SIZE` /
`tick_size` for the limit price, the attached take-profit and stop-loss
trigger and limit prices, the attached trailing stop's activation and limit
prices, and modify's `new_price` (`new_price_ticks`). Other decimals
(`max_quote_debit`, `market_client_ref_price`) raise it with no `code`.

`self_trade_prevention_mode` (`expire_taker`, `expire_maker` or
`expire_both`) sets the order's self-trade prevention on create, preview and
batch create items; omit it for the server default. The order's attached
take-profit, stop-loss and trailing stop exits use the same mode: they have
none of their own. `Order.self_trade_prevention_mode` reads it back (empty
when unset; a mode newer than this SDK reads as its number), and each leg's
`Trigger.self_trade_prevention_mode` shows the mode its exit uses.

```python
await client.orders.create(
    symbol="BTC-USDT",
    side="buy",
    order_type="limit",
    post_only=True,
    qty="0.001",
    price="60000",
    self_trade_prevention_mode="expire_taker",
    attached_risk={"oco": True, "take_profit": {"trigger_price": "90000"},
                   "stop_loss": {"trigger_price": "55000"}},
)
```

A buy sized by a quote amount (`max_quote_debit`, market or limit IOC) takes
`attached_risk` too. Its legs protect what it buys: they arm as its fills
settle and follow each later fill. At create, each leg's exit is checked
against the pair's minimums at the quantity the whole amount buys at the
order's limit price (a market order's maximum slippage price), and refused
with `ERROR_CODE_MIN_NOTIONAL`, `ERROR_CODE_MIN_QTY` or
`ERROR_CODE_QTY_STEP_SIZE` below them.

Scaled transfer/withdraw `AssetAmount` inputs must carry their source scale.
`AssetAmount.from_scaled(..., scale=None)` is accepted for composition only and
fails closed on encoding unless the request's `amount_scale` /
`quantity_scale` is explicit.

Use **decimal strings** or `Decimal` for human-facing `qty` / `price` inputs.
Do **not** pass floats. `ticks` on `Price` means Polyester protocol price units
(fixed 1e9), not market tick-size alignment (server validates tick size).
Catalog hydration resolves `symbol` → `symbol_id` and quantity scales for
encoding; pair admission (tick/step/min qty/notional) stays on the API.

Order limits are read from the spot config. `client.catalogs.order_limits_for_symbol("BTC-USDT")`
(or `get_spot_config().pair_order_limits`) returns `PairOrderLimits` with
decimal strings `max_limit_qty_base`, `max_market_qty_base` and
`max_order_notional_quote` (`None` when the pair has no maximum), and
`max_open_orders_per_pair`. `max_market_notional_quote` caps the notional of
one market order, valued at a buy's protection price or a sell's reference
price (`None` means no cap). `client.catalogs.max_open_orders_total` (or
`SpotConfig.max_open_orders_total`) is the cross-pair resting-order limit.
The open-order counts are the venue defaults for a typical account, `0` when
unpublished; an account may have a lower limit of its own.

```python
limits = client.catalogs.order_limits_for_symbol("BTC-USDT")
if limits is not None and limits.max_limit_qty_base is not None:
    print("largest limit order:", limits.max_limit_qty_base, "BTC")
```
`orders.preview_order(...)` remains the authority for balances, reservations,
liquidity, and other stateful rules.

### For bots (scaled integers)

Stay in integer space; no string round-trip:

```python
from polyester import Price, Quantity

result = await client.orders.create(
    symbol="BTC-USDT",
    side="buy",
    order_type="limit",
    tif="gtc",
    qty=Quantity.from_scaled(1_000_000, scale=8),  # already wire units
    price=Price.from_ticks(100_000_000_000),        # 100.000000000 at 1e9
    post_only=True,
)
# Reads expose the same types: order.price.ticks, order.orig_qty.scaled
# orig_qty is the current accepted total and changes after a successful modify.
```

Compatible values from fills/books can be passed back into writes when the
instrument/domain matches.

`orders.batch_replace(...)` modifies up to 50 orders on one pair as one quote
refresh, each item admitted or rejected on its own. 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: same order ID, queue priority kept. A new price, a
larger total quantity or a new client order ID replaces the order with a
successor under a new order ID, with its queue priority reset. A new total
quantity at or below the filled quantity ends the order at its filled
quantity, with no successor. Each admission and status item carries
`action_taken`: `"amended"` when the order keeps `old_order_id`, `"replaced"`
when the successor `replacement_order_id` takes its place.

The call returns an admission receipt, not final execution. An admitted item
can still be refused when it is applied, for example a post-only price that
would now cross: its status then reads `rejected` with a `code`, the order
keeps its previous price and quantity, and no successor exists. So the
receipt's `replacement_order_id` is a planned ID, not proof of an order.
`orders.wait_batch_replace(batch_request_id=...)` polls
`get_batch_replace_status` with a short backoff until no item is `admitted`,
and returns that status. The status appears a few seconds after admission, so
the wait polls through `not_found`, `unavailable` and other server or network
errors; a rate limit or any other error is raised. A `rejected` item's `error_code` names the refusal as a
single modify raises it (`ERROR_CODE_POST_ONLY_CROSS`; `code` is the raw
`POST_ONLY_CROSS`), and its `replacement_order_id` is empty. The server keeps
a batch's status for 60 seconds from acceptance, so the wait stops there (or
at `timeout=`), even during a read, with `PolyesterBatchReplaceTimeoutError`,
whose `last_status` is the last status read: a `not_found` proves nothing, so
reconcile the orders themselves with order reads. `working` means the order is live, not that it is final. Retry an
ambiguous batch request with the same `request_id`.

```python
receipt = await client.orders.batch_replace(symbol="BTC-USDT", items=items)
status = await client.orders.wait_batch_replace(batch_request_id=receipt.batch_request_id)
for item in status.items:
    if item.phase == "rejected":
        print(item.old_order_id, "unchanged:", item.error_code)
```

A replacement order's `cum_qty` includes the quantity filled by its
predecessors; `Order.inherited_cum_qty` is that carried-over part (zero for an
order never replaced).

An order says why it ended. A `rejected` order's `reject_reason` is the error
code the Orders API returns for the same refusal, without the `ERROR_CODE_`
prefix (for example `POST_ONLY_CROSS` or `INSUFFICIENT_FUNDS`). A `canceled`
order's `cancel_reason` is one of `USER_REQUEST`, `IOC_REMAINDER`,
`MARKET_REMAINDER`, `STP_EXPIRE_MAKER`, `STP_EXPIRE_TAKER`, `STP_EXPIRE_BOTH`,
`REPLACED`, `GTD_EXPIRED`, `QTY_AT_OR_BELOW_FILLED`, `PAIR_DRAIN`,
`ATTACHED_RISK_REFUSED`, `CANCEL_ALL_AFTER`, `ACCOUNT_REPAIR`,
`TWAP_ROLLOVER`, `STRATEGY_CANCEL`, `SYSTEM`, `MARKET_HALTED` (the exchange
halted the market and canceled its open orders) or `ATTACHED_EXIT_FIRED` (its
take-profit, stop-loss or trailing stop fired while it still rested, so its
unfilled remainder was canceled first; fills before it stand, and it can no
longer be modified). Both are `None` for every other
status. A number this SDK does not know yet reads `UNKNOWN_ERROR_CODE(n)` or
`UNKNOWN_CANCEL_REASON(n)`; treat it as a generic refusal or end.

```python
order = (await client.orders.get(key=ClientOrderId(client_order_id))).order
if order is not None and order.reject_reason == "POST_ONLY_CROSS":
    ...  # reprice and resend
```

A buy sized by a quote amount (`max_quote_debit`) that spends its amount ends
`filled`, with nothing remaining; `MARKET_REMAINDER` and `IOC_REMAINDER` mean
the book, its price limit or the market's maximum quantity stopped it first.

Orders carry their exact execution totals across the lineage. `avg_price` is
the exact average execution price as a `Decimal` with up to 18 decimals
(`None` without fills); prefer it to `avg_px`, which is rounded down to 9
decimals. `cum_notional_scaled` is the quote amount of the fills before
fees and `cum_quote(base_quantity_scale)` returns it in quote units. Each fill
settles its quote rounded down to 18 decimals, so both, and `quote_used`, are
exact when the pair's `base_quantity_scale` is at most 9; above that they can
exceed the settled quote by less than 1e-18 per fill, and `quote_unspent` can
read lower than what is left by the same margin.
`fee_total` (`amount`, `amount_e18`, `is_rebate`, `qty`) is the fee of those
fills net of rebates, in `fee_asset`; order history and single-order reads
with execution history carry it, open-order reads and stream updates do not.
`quote_used(base_quantity_scale)` is what a buy's fills used, fees included,
and `quote_unspent(quote_quantity_scale=..., base_quantity_scale=...)` what
is left of its `submitted_max_quote_debit_scaled`. Both are `None` until
`fee_total` covers every fill (`fee_total.qty == cum_qty`): read the order
again. Take the scales from the catalog
(`catalogs.base_quantity_scale_for_symbol_id`, `quote_quantity_scale_for_symbol`).

```python
order = (await client.orders.get(key=OrderId(order_id))).order
base_scale = client.catalogs.base_quantity_scale_for_symbol_id(order.symbol_id)
quote_scale = client.catalogs.quote_quantity_scale_for_symbol("BTC-USDT")
used = order.quote_used(base_scale)
unspent = order.quote_unspent(quote_quantity_scale=quote_scale, base_quantity_scale=base_scale)
```

A cancel of an order that has already ended, a repeat of the cancel that ended
it included, raises `PolyesterApiError` `ERROR_CODE_ORDER_ALREADY_TERMINAL`:
`is_order_already_terminal(err)` tells it apart. Treat it as "already done"
and read the order for how it ended. A client order ID names its order for at
least a minute after the order ends; a later cancel by client order ID may
read `ERROR_CODE_ORDER_UNKNOWN`, so retry by order ID once you have it. Every
cancel, a repeat included, counts against the cancel rate limit.

`cancel_all(...)` requests cancellation of every matching order. The result
lists up to 200 of them, oldest first, in `cancelled_orders` (each with
`order_id` and `client_order_id`); `total_cancelled` counts all of them and
`truncated` is true when the list is shorter. Each order's final state comes
from the order stream and reads. A dry run lists none. Retrying 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"`; the
empty list does not mean nothing was cancelled, so read open orders instead.

`cancel_all` and the dead-man switch (`cancel_all_after`) cancel open orders
only, never triggers: an armed stop still fires and places its order later, a
TWAP keeps placing slices, and the triggers keep their reserved funds. Orders
that triggers placed are canceled like any other. To stop all automated
trading, `orders.stop_all_trading()` lists the live triggers (`created`,
`armed`, `running`, `paused`, attached legs included) page by page, cancels
each, and only then cancels all orders, so an order a trigger places before
its cancel is still canceled; `symbol=` limits it to one market. An error
while listing the triggers is raised before anything is canceled. A trigger
whose cancel fails, for any reason, is recorded and the run goes on: the other
triggers are still canceled and the cancel-all always runs; an error from the
cancel-all itself is raised. The result has `canceled_trigger_ids`,
`trigger_failures` (each a `StopAllTradingTriggerFailure` with `trigger_id`
and `error`: check them, such a trigger may still be live) and `cancel_all`.


Your API key needs a policy that allows trading. Spot orders spend **trading**
balance (see below).

## User trade fees

`UserTrade` fee fields are fixed **18-decimal** magnitudes of `fee_asset`
(`fee_amount_e18`, `referral_share_amount_e18`), not catalog asset-scaled
integers. Convert e18 → the fee asset's catalog scale before subtracting from a
BUY fill's base quantity.

Magnitudes are unsigned. Treat `fee_amount_e18` as a **debit** unless
`fee_is_rebate` is true (then it is a **credit**). Proto3 omits false, so the
rebate flag is sparse on the wire.

`client.fees.get_spot_fee_rates()` returns the account target's current effective maker/taker
percents per listed symbol (optional `symbol_id` filter). That is the live effective-rate
surface, not a guaranteed quote of every future fill. The completed user-trade record still
owns the exact charge.

Rows also expose `reserve_fee_rate_percent`,
`post_only_reserve_fee_rate_percent`, `next_change_at` and `breakdown`. Size a
buy limit order that pays its fee in the quote asset with the reserve rate,
using the post-only reserve rate for a post-only order. A buy sized by a quote
amount reserves at the lower of the reserve and taker rates. Effective
maker/taker rates apply to fills. Refetch rates at
`next_change_at` when present. `breakdown` contains tier rates, a market
multiplier, account discounts and a referral discount. Optional components
and times may be `None`; absent account discounts decode as an empty list.

See the [contract migration notes](docs/contract-748066a.md) for the full
fee model and exact integer budgeting guidance.

`trades.list(after_match_id=...)` is a durable fill-replay cursor and requires
`symbol` or `symbol_id`.

## VIP, spot fees, and trading rate limits

Public catalog reads need no credentials. Authenticated VIP status, effective spot fees, and
account trading limits require an API key. `GetVIPStatus` has no subaccount selector: JWT and
API-key callers receive the owning root group only. USD amounts and fee percents are decimal
strings. Optional qualification metrics, timestamps, and next-tier thresholds stay omitted
when unset. `VIPStatus.settled_volume_quality` and `average_aop_quality` say how complete
each 30-day metric is: `status` is `complete`, `price_unavailable` (the metric is a lower
bound) or `incomplete_history`, with `incomplete_hours` out of 720. Both are `None` when the
assignment carries no qualification evidence. `policy_class` uses full protobuf enum names
(`TRADING_RATE_LIMIT_CLASS_PLACE` / `_CANCEL`). Rate-limit rules expose
`vip_tier` (not `tier`).

```python
tiers = await client.vip.list_vip_tiers()
status = await client.vip.get_vip_status()
fees = await client.fees.get_spot_fee_rates()
catalog = await client.rate_limits.get_rate_limit_config()
limits = await client.rate_limits.get_trading_rate_limits()
```

## Address book and social handles

Address-book reads (`list_books`, `list_entries`, `get_view`) and writes
(`create_entry`, `update_entry`, `delete_entry`, tag CRUD) are wrapped.
`get_view(minimum_view_revision=...)` asks the server for a newer snapshot;
the view and invalidation events expose `view_revision`.
Create and update accept `new_tags` so a tag can be created and attached in
the same request. Update is a durable PATCH: pass `expected_revision` from a
prior read and only the fields you set are selected. When `tag_ids` is also
set, the resulting set is those ids plus `new_tags`; otherwise `new_tags` are
appended to the current tags.

```python
entry = await client.address_book.create_entry(
    label="ops",
    address="0x…",
    polychain_chain_id=1,
    new_tags=[{"name": "hot", "color": "#f00"}],
)
entry = await client.address_book.update_entry(
    address_book_entry_id=entry.address_book_entry_id,
    expected_revision=entry.revision,
    new_tags=[{"name": "vip"}],
)
```

`social_verification.start(..., handle="@alice")` accepts an optional leading
`@` on Twitter handles. The SDK forwards the handle as supplied.

## Product changelog API

`client.changelog` reads the product changelog published for the environment
the client points at (devnet, testnet, ...). It is public and read-only: it
needs no API key, and the server rate-limits it per client address.

- `list_posts(limit=, page_token=)` returns one page of post summaries, newest
  first, with change counts by kind. `iter_posts(...)` yields every post
  across pages.
- `get_post(slug)` returns one full post with its sections and changes. A slug
  is the post's coverage cutoff date, `YYYY-MM-DD`; a second or third post on
  the same day gets `-2` or `-3`. An unknown slug raises `PolyesterApiError`
  with code `not_found` (`is_not_found(err)` is true).
- `list_changes(...)` returns one page of changes across posts, each with its
  post slug, publication time and section heading. It filters by
  `categories`, `action_required`, `surfaces`, `audiences`, `start_time`
  (inclusive), `end_time` (exclusive), `post_slug` and `sort` (`"desc"`, the
  default, or `"asc"`). Filters on different fields combine with AND; values
  within one field combine with OR. `iter_changes(...)` yields every match
  across pages. An `end_time` not after `start_time` raises `PolyesterApiError`
  with code `invalid_argument`.
- `breaking_changes_since(t)` and `action_required_since(t)` return every
  breaking or action-required change published at or after `t`, newest post
  first.

Categories (`breaking_change`, `new_feature`, `improvement`, `bug_fix`),
audiences (`all_users`, `traders`, `market_makers`, `developers`) and surfaces
(`trading`, `funding`, `accounts`, `api`, `explorer`, `documentation`,
`platform`, `market_data`, `quickswap`, `rewards`) are lowercase labels. A
value newer than this SDK reads as its number instead of failing. Each
section's `surface` is the product area it covers, or `""` for a section not
tied to one, such as the changes that need your attention; a change keeps its
own `surfaces`, which the `surfaces` filter matches. `change.id` is the
change's base58 public ID.
Datetimes must be timezone-aware. The service raises `PolyesterServerError`
(`Unavailable`) while it is starting.

```python
from datetime import UTC, datetime

async for post in client.changelog.iter_posts():
    print(post.slug, post.title, post.breaking_change_count)
post = await client.changelog.get_post("2026-09-24")
for entry in await client.changelog.breaking_changes_since(datetime(2026, 9, 1, tzinfo=UTC)):
    print(entry.post_slug, entry.change.id, entry.change.title, entry.change.migration_guidance)
```

The sync `Polyester` client returns plain iterators:
`for post in client.changelog.iter_posts(): ...`.

## Triggers

`triggers.list(status=...)` filters by lifecycle status. Valid values:

`created`, `armed`, `running`, `completed`, `cancelled`, `failed`, `paused`

Invalid status and event-type labels raise `PolyesterValidationError`.
Connect requests and responses use numeric `symbol_id`. Public methods still
accept display `symbol` strings and resolve them through the hydrated catalog
after `wait_for_catalogs()`. Unknown symbols fail closed. Only
`get_spot_config` returns both `symbol` and `symbol_id`; other Connect
payloads are ID-only. Display `symbol` on models such as market overview,
fees, triggers, and policy rules is filled from the catalog when available.

Unknown values raise `PolyesterValidationError` (they do not silently return
an empty list). Response `status` uses the same labels (British spelling
`cancelled`).

`orders.get(..., include_attached_risk=True)` returns policy data on
`order.attached_risk` (take-profit / stop-loss / trailing-stop). Independently,
`include_attached_risk_state=True` can return state-only leg wrappers when
policy inclusion is false. Each returned `RiskLeg` / `TrailingStop` exposes
that runtime `AttachedRiskLegState` at `.state` with `status`, nanosecond
armed/terminal timestamps, `trigger_id`, and `child_order_id`; absent policy
fields remain unset. `Order` also exposes `post_only`. Create/modify accept the
same policy shape as a dict or `AttachedRisk`.

A stop-loss or take-profit with `order_type="market"` takes an optional
slippage cap, `max_slippage_ticks` (Q9 ticks) or `max_slippage_bps` (1–10000
for a buy, 1–9999 for a sell), at most one; without one the pair's default
market slippage applies. A limit child
takes no cap (its limit price bounds it). Either side may use either child.
Trigger reads show the cap as `max_slippage` (ticks, as a price delta) or
`max_slippage_bps`; both unset means the pair default.

```python
await client.triggers.create(
    symbol="BTC-USDT",
    trigger_type="stop_loss",
    side="sell",
    qty="0.01",
    trigger_price="83000",
    order_type="market",
    max_slippage_bps=50,
)
```

A standalone trailing stop may sell or buy. A buy trailing stop tracks the
lowest price and fires when the price rises the trailing distance from it.
It requires `limit_price`, its maximum price: the most its child pays per
unit (a 9-decimal string or `Decimal`, or `Price.from_ticks` for ticks),
fixed at create, and at least `activation_price` when that is set. A sell
trailing stop takes no `limit_price`. Every trailing stop, standalone or
attached to an order, takes `max_slippage_bps` from 1 to 9999.

```python
await client.triggers.create(
    symbol="BTC-USDT",
    trigger_type="trailing_stop",
    side="buy",
    qty="0.01",
    trailing_distance_bps=100,
    limit_price="85000",
)
```

A trailing stop attached to an order that sells buys back, so it requires
`limit_price` too: the most its exit pays per unit, at least
`activation_price` when that is set. A trailing stop attached to an order that
buys takes none. Order reads do not return it yet; keep the value you sent.
Create, preview and batch create check this before sending and raise
`PolyesterValidationError` with the server's `code` and `violations` (field
`order.attached_risk.trailing_stop.limit_price_ticks`): set on a buying order
`ERROR_CODE_BAD_PRICE` / `sell_unset`, missing or not positive on a selling
order `ERROR_CODE_TRIGGER_BUY_REQUIRES_LIMIT` / `buy_required`, below the
activation price `ERROR_CODE_BAD_PRICE` / `buy_at_least_activation`. `modify`
sends the maximum as given: it does not know the order's side, so the server
applies the side rule and answers `ERROR_CODE_BAD_PRICE` with no violation.

```python
await client.orders.create(
    symbol="BTC-USDT",
    side="sell",
    order_type="limit",
    tif="gtc",
    qty="0.01",
    price="84000",
    attached_risk={
        "trailing_stop": {"trailing_distance_bps": 100, "limit_price": "86000"},
    },
)
```

Trigger reads show a buy trailing stop with `side="buy"`,
`order_type="limit"`, `time_in_force="ioc"` and its maximum price as
`limit_price`; `details.trailing.trough_price` is the lowest price tracked so
far (`peak_price` for a sell), and `details.trailing.trigger_price` is the
current threshold.

Stop-loss, take-profit and trailing-stop triggers, standalone or attached to
an order, watch the last trade price unless `trigger_price_source` names
another: `"last"` or `"index"`. Spot markets have no mark price, so `"mark"`
raises `PolyesterValidationError`. Reads report the same label
(`Trigger.trigger_price_source`, `RiskLeg.trigger_price_source`,
`TrailingStop.trigger_price_source`), so a leg read from an order can be
passed back to create unchanged.

```python
await client.triggers.create(
    symbol="BTC-USDT",
    trigger_type="stop_loss",
    side="sell",
    qty="0.01",
    trigger_price="83000",
    trigger_price_source="index",
)
```

`triggers.list_events(...)` returns the same not-found error as
`triggers.get(...)` for an unreadable trigger. It retries catching-up
`UNAVAILABLE` responses with the same bounded policy and request as trigger
reads.

A trailing stop created with an activation price the market has already
reached now ends `FAILED` with reason
`TRIGGER_FAILURE_REASON_TRIGGER_WOULD_TRIGGER_IMMEDIATELY` (244).
`ModifyTrigger` and order modifies that would make it act at once are refused
with `ERROR_CODE_TRIGGER_WOULD_TRIGGER_IMMEDIATELY` (302), leaving the
existing state unchanged. To start trailing from the market, clear the
activation price with a present zero.

When modifying a trigger, omit `activation_price` / `max_slippage_ticks` /
`max_slippage_bps` to preserve the current values. Send an explicit zero
(`Price.from_ticks(0)` or `0`) to clear an existing activation price or
maximum-slippage cap. Modify `max_slippage_bps` is 1–9999 (the patch does not
say what it modifies, so it takes the cap shared by trailing stops and
sells); `0` clears it. Trailing distance in bps is 1–10000; trigger modify
accepts `0` to clear. A child's limit price is fixed at create, so `modify`
takes no `limit_price`: cancel the trigger and create a new one instead.

`triggers.modify(...)` and `triggers.resume(...)` do not need a symbol: without
`symbol` / `symbol_id` they target the trigger's own market. When given, it
must be that market, and an API key limited to specific markets must give it.

```python
await client.triggers.modify(trigger_id=trigger_id, trigger_price="82500")
await client.triggers.resume(trigger_id=trigger_id)
```

`orders.list_open(trigger_id=...)` / `orders.list_history(trigger_id=...)`
return only child orders created by that trigger (TWAP/ladder slices).
Trigger-event `fire_price` is `None` for time-scheduled TWAP slice fires
(`fire_price_ticks` is optional on the wire). Canceled and failed triggers
expose typed `cancel_reason` / `failure_reason` labels (empty when unset). A
market halt ends every open trigger of the market with `cancel_reason`
`market_halted`, and a trigger the halted market refused ends `failed` with
`failure_reason` `market_halted`. A reason number this SDK does not name reads
as its number, for example `failure_reason == "321"`.

A trigger's history records every fire refused while the trigger stayed live,
as a `fire_refused` event: `child_seq` is the refused attempt (the next one
takes the next number) and `fire_refusal_reason` says why, with the
`failure_reason` labels. `self_trade_prevented` means self-trade prevention
(`expire_taker` or `expire_both`) expired the exit's order against another of
the account's resting orders: `child_seq` names that order, which ends
`canceled` with `STP_EXPIRE_TAKER` or `STP_EXPIRE_BOTH`, and the trigger
records it once per blocked episode and fires again at a new trade at or
beyond its trigger price, or once the blocking order is gone or no longer at
the best price. Every other refusal placed no order. `market_halted` or `pair_post_only`
waits for the next price through the trigger price; `min_notional`,
`min_quantity` or `step_size` means the order would have been below the
market's minimum, and the trigger fires again once the position changes;
`rate_limited` or `account_repairing` (an automatic account repair) fires
again shortly. `fire_refused` is not terminal: the trigger stays `armed` or
`running`. A refusal that ends the trigger is a `failed` event with
`failure_reason`. `is_terminal_trigger_event(event_type)` is true only for
`canceled` and `failed`; read the trigger's `status` rather than inferring it
from its last event. `list_events(event_type="fire_refused")` filters them.

A ladder whose levels were all canceled before any filled ends `cancelled`
with `cancel_reason` `user_request`; one that filled anything ends
`completed`. An attached leg's `qty` follows the order's whole settled
position, less what its exit orders sold, so it can be larger than its working
exit order.

`triggers.get(...)` and `triggers.list(...)` show a trigger as the latest
answered `modify`, `pause` or `resume` left it, or answer `unavailable`
(HTTP 503) until they can. Both resend the same request on `unavailable`:
after 25 ms, doubling up to 500 ms, for about 2 seconds, then raise
`PolyesterServerError`. Read a trigger back after changing it with these
methods.

`Trigger.version` is 1 for a trigger's first state and higher for every later
state change. Reads and realtime trigger updates carry the same version for
the same state; list results can lag realtime updates, and an update can
repeat after a reconnect. Keep the copy of each trigger with the highest
version and ignore any copy whose version is not higher than the one you hold.

## Balances: funding vs trading

Ledger balances have separate **funding** and **trading** buckets per asset.

- An external deposit can stop in **funding** or continue to **trading**,
  depending on its configured route.
- Spot orders spend **trading** balance.
- Move funds funding → trading in the Polyester UI (**Funding → Unified Trading**)
  or on-chain via the funding wallet.

SDK notes:

- **Funding → trading:** on-chain `TradingGateway.deposit` (not an API-key RPC).
  Either encode calldata or submit a UserOp: pass an owner EOA private key to
  `PolyesterSmartAccount` (SDK derives the Polyester Safe; no UI-exported owner key).
- **Funding → external:** on-chain `FundingAccount.withdrawToChain` (same `polyester.chain`).
- **Funding → another user's funding wallet:** on-chain `FundingAccount.UAssetTransfer`
  via wallet/smart-account signing in the Polyester app (not an API-key RPC).
- **Trading → funding:** prepare and persist an exact API-key signature with
  `client.trading_withdraws.prepare_api_key_to_funding(...)`, then call
  `submit_prepared(...)`. One-call `create_api_key_to_funding(...)` is also available.
- **Trading → external destination check:**
  `await client.trading_withdraws.validate_destination(destination_chain_id=..., destination_address=...)`
  returns user-safe `valid` / `code` / `message` / `canonical_destination_address`
  without creating a withdraw (create RPCs remain authoritative).
- **Trading → trading (another account):** `client.internal_transfers.create(...)`.

```python
from polyester.chain import (
    POLYESTER_DEVNET_ENVIRONMENT,
    PolyesterSmartAccount,
    encode_trading_gateway_deposit,
    encode_funding_withdraw_to_chain,
    encode_withdraw_destination,
    quote_zipper_fee,
)

account = PolyesterSmartAccount(owner_private_key="0x…")  # caller-supplied EOA

# Funding → Trading
deposit = encode_trading_gateway_deposit(
    trading_gateway=POLYESTER_DEVNET_ENVIRONMENT.contracts.trading_gateway_address,
    u_asset_id="0x…",
    quantity_scaled=10**18,  # 1 USDT at 18 decimals
)
account.send_calls([deposit])

# Funding → external (quote Zipper fee first)
fee = quote_zipper_fee(
    chain_id=6,  # BSC testnet Zipper id
    z_token="0x…",
    zipper_endpoint=POLYESTER_DEVNET_ENVIRONMENT.contracts.zipper_endpoint_address,
)
withdraw = encode_funding_withdraw_to_chain(
    funding_account=POLYESTER_DEVNET_ENVIRONMENT.contracts.funding_account_address,
    chain_id=6,
    z_token="0x…",
    withdraw_destination=encode_withdraw_destination(address="0x…", is_case_sensitive=False),
    z_amount=5 * 10**18,
    max_fee=fee.fee + fee.fee // 10,
)
account.send_calls([withdraw])
```

Whitelist toggles / destination entries / GuardRegistry signer setup are also encoded under
`polyester.chain` (`encode_add_allowed_external_destinations`, …).

Pass `default_account_id` (your Profile **Account ID**) on the client for bucket
transfers and other account-scoped ledger operations.

Format u128 wire amounts with the public helper (18-decimal scale):

```python
from polyester import format_ledger_u128

print(format_ledger_u128(balance.funding), format_ledger_u128(balance.trading))
```

## Public market data

Public endpoints do not require an API key. Authenticated endpoints use the
credentials above.

```python
candles = await client.market_data.get_candles(symbol="BTC-USDT", timeframe="1m", limit=50)
current = await client.market_data.get_current_candle(symbol="BTC-USDT", timeframe="1m")
if current is not None:
    print(current.close, current.is_closed)
trades = await client.market_data.get_trades(symbol="BTC-USDT", limit=20)

subscription = await client.market_data.subscribe_trades(symbol="BTC-USDT")
subscription.set_on_error(lambda error: print(f"realtime interruption: {error}"))
async with subscription:
    async for trade in subscription:
        print(trade.price.ticks if trade.price else None, trade.qty.scaled if trade.qty else None)
        break
```

List and heatmap `limit` arguments must be integers (booleans and other types
are rejected for wire safety). Optional limits omit when unset so the server
default applies; allowed ranges are enforced by each API, not a global SDK cap.
Response fields named `ts_ns` are treated as epoch nanoseconds per the API
contract.

`get_trades()` lists trades newest first. Narrow it with `start_time` /
`end_time` (inclusive), `side` (`"buy"` or `"sell"` aggressor), and page with
`page_token=result.next_page_token`.

To backfill trades a live stream missed, walk by trade ID instead: oldest
first and gap-free. Start from `after_trade_id=0` (or the last trade ID you
hold) and pass each result's `next_after_trade_id` to the next call. Every
trade up to `complete_through_trade_id` is returned, across trade-ID epoch
ends (`epoch_ends`). When `next_after_trade_id` equals the ID you sent,
nothing newer is available yet; retry later with backoff. `after_trade_id`
cannot be combined with the time range, side or page token.

```python
after = 0
while True:
    page = await client.market_data.get_trades(symbol="BTC-USDT", after_trade_id=after, limit=1000)
    for trade in page.trades:
        handle(trade)
    if page.next_after_trade_id == after:
        break  # caught up; resume later from `after`
    after = page.next_after_trade_id
```

`orderbook.get(symbol=...)` returns a depth snapshot. When its `available` is
`False`, the server is building the book or invalidated it: its levels are
unknown, not empty, so do not read the empty sides as no liquidity; request
the snapshot again later. Only a snapshot carries `available`: books from
`orderbook.create_subscription(...)` report the value of the last snapshot the
subscription applied, and deltas, resets included, do not change it.

```python
book = await client.orderbook.get(symbol="BTC-USDT", depth=10)
if not book.available:
    ...  # unknown book: retry later instead of quoting into an empty side
```

`get_candles()` returns row-oriented candles newest-first (an incomplete open
candle, when requested, is prepended). `get_candles_columns()` normalizes its
column response oldest-first within each page; continuation pages go toward
older candles. Sort explicitly by `ts_sec` before feeding a chronological
indicator, rather than appending continuation pages as newer candles.

Merged market overview stream (snapshot + live updates). The create call waits
for the WebSocket handshake and initial snapshot. With `symbols=[...]`, the
snapshot and every live or buffered update keep only those markets:

```python
sub = await client.market_overview.create_subscription(
    on_error=lambda error: print(f"managed overview failed: {error}")
)
async with sub:
    async for markets in sub:
        print(len(markets), "rows")
        break
```

### Realtime delivery contract

- Realtime is binary-only. The client negotiates the `centrifuge-protobuf`
  WebSocket subprotocol and consumes protobuf publications from `:proto`
  channels; live orders use the private WebSocket (see Live orders below).
  ConnectRPC's optional JSON wire mode does not apply to realtime.
  Inbound WebSocket messages are capped at 4 MiB.
- Market trade, user fill (`trades.subscribe`), ledger transfer
  (`transfers.subscribe`) and trigger event (`triggers.subscribe_events`)
  channels publish batches. Each event of a batch is yielded on its own, in
  order. An event that fails to decode is reported through `set_on_error(...)`
  without dropping the rest of its batch. Both legs of a ledger transfer share
  `LedgerTransfer.transfer_id`; with `account_code` it identifies a leg, and
  a repeated leg replaces the earlier copy. A trigger event is identified by
  `(trigger_id, event_type, child_seq, ts_ns)`. For your own batched channel,
  use `realtime.subscribe_proto_batch(channel, decode_batch=...)`.
- Subscription queues are bounded. If the consumer falls behind, the SDK raises
  `PolyesterRealtimeOverflowError` and faults the subscription; it does **not**
  silently drop updates.
- Reconnects use capped exponential backoff with per-subscription jitter.
  `set_on_error(...)` observes background feed interruptions; async iteration
  still raises terminal failures.
- Orderbook sequence gaps trigger a REST snapshot refresh. Use
  `on_sequence_gap` / `on_reconnect` / `on_snapshot_refresh` on
  `orderbook.create_subscription(...)` for recovery observability.
- Managed snapshot-then-stream subscriptions disable transport auto-reconnect
  so they can rebuild REST state between reconnect attempts. Buffered
  publications survive a failed snapshot retry and are merged exactly once
  after recovery; cancellation closes the replacement socket. Managed create
  methods await the handshake and initial snapshot before returning.
- Private order stream quantities are raw scaled values. Their
  `Quantity.scale` may be `None`; resolve the hydrated catalog scale with
  `client.catalogs.base_quantity_scale_for_symbol_id(order.symbol_id)` (or by
  symbol), then call `quantity.format(scale)`. Never guess or inject scale 8.

### Live orders

`client.orders.subscribe()` streams an account's orders over the private
WebSocket (`/v1/ws/private`, binary protobuf, subprotocol
`polyester.v1.proto`). It authenticates with the same realtime tokens as the
other private streams. One connection carries every account, and each account
has one server subscription shared by all of its consumers.

```python
from polyester.models import (
    PrivateOrdersConnected,
    PrivateOrdersDisconnected,
    PrivateOrdersError,
    PrivateOrdersReady,
    PrivateOrdersSnapshot,
    PrivateOrdersStale,
    PrivateOrdersUnit,
)

orders = {order.order_id: order for order in (await client.orders.list_open()).orders}
subscription = await client.orders.subscribe()  # default_account_id, or account_id=...
async with subscription:
    async for event in subscription:
        if isinstance(event, PrivateOrdersReady):
            # The stream now reflects every order read made before the subscribe.
            print("ready")
        elif isinstance(event, PrivateOrdersSnapshot):
            # The full live view: replace local state.
            orders = {order.order_id: order for order in event.orders}
        elif isinstance(event, PrivateOrdersUnit):
            for order in event.orders:
                orders[order.order_id] = order  # keep the higher version
            for order_id in event.removed_order_ids:
                orders.pop(order_id, None)  # left the live view; not a cancel
            for change in event.events:
                print(change)  # PrivateOrderEvent or PrivateOrderCommandOutcome
        elif isinstance(event, PrivateOrdersStale):
            print("stale" if event.stale else "caught up")
        elif isinstance(event, PrivateOrdersError):
            print(f"recovering from {event.kind}: {event.error}")
```

- `subscribe()` returns once the server confirmed the subscription; the first
  event is `PrivateOrdersConnected`. A failure before that raises (for
  example `PolyesterAuthError` for a refused token, or
  `PolyesterRealtimeError` with `code == "FORBIDDEN"`).
- Orders are the same `Order` model the order reads return. Two copies with
  the same `version` are identical, and a higher `version` is newer. To merge
  with a read: read `list_open()`, subscribe, wait for `PrivateOrdersReady`,
  and keep the higher version of each order.
- A `PrivateOrderEvent` says why an order ended, as the order's own fields
  do: `reject_reason` on `rejected`, and `cancel_reason` on `canceled` and on
  the `replaced` event of the order a replace ended (`REPLACED`). Both are
  `None` otherwise, including on the `replaced` event that installs the
  successor.
- `PrivateOrdersReady` means the subscription reached the account's read
  boundary, sampled when the server received the subscribe: from then on it
  reflects every acknowledgement given before the subscribe. Wait for it
  before relying on the stream. It comes once per server subscription, so
  again after a resubscribe or reconnect, and a consumer that joins an
  account that is already ready gets it right after its catch-up snapshot.
- A sequence gap, an expired token or a dropped connection resumes from the
  last applied position. Consumers see `PrivateOrdersDisconnected` and then
  `PrivateOrdersConnected`; a position the server can no longer resume is
  answered with a fresh `PrivateOrdersSnapshot` that replaces local state.
  Outcomes between the two states are not replayed; fills stay readable from
  trade history.
- `PrivateOrdersStale(stale=True)` means the account's live view is
  temporarily behind; it recovers by itself.
- Recoverable failures arrive as `PrivateOrdersError` events and through
  `set_on_error(...)`. A failure that ends the stream (a refused token, a
  revoked permission, close code 4400 or 4403) is raised from iteration, with
  `PolyesterRealtimeError.code` or `.close_code` set.
- Batch-replace progress has no stream: poll
  `client.orders.get_batch_replace_status(...)` (or
  `client.orders.wait_batch_replace(...)`) and watch each order's revisions on
  this stream.

## Sync client

The sync `Polyester` client exposes the same service tree and constructor
parameters:

```python
from polyester import Polyester

with Polyester(
    api_key_id="ak_...",
    api_private_key="...",
    default_account_id="YOUR_ACCOUNT_ID",
) as client:
    balances = client.balances.list()
```

Realtime subscriptions are available via `subscribe_sync` helpers on the sync
client. Private API-key policy snapshots use
`client.policies.subscribe_api_policies_sync(...)` (async:
`await client.policies.subscribe_api_policies(...)`).

## Testing (contributors)

**CI (no network):**

```bash
python -m pytest tests/unit tests/hardening -q
```

**Live selection (A7):** public smoke vs credentialed suites are selectable via markers.

```bash
# Public smoke (read-only smoke; no mutation/funded)
python -m pytest -m "public_smoke" -q

# Named-environment pins vs live Zipper catalog (no API key)
python -m pytest tests/integration/test_environment_pins.py -q

# Credentialed live follows POLYESTER_ENV=devnet|testnet (and optional URL overrides)
# POLYESTER_ENV=testnet python -m pytest -m "credentialed and not mutation and not funded" -q

# Credentialed live integration (requires API-key env)
python -m pytest -m "credentialed and not mutation and not funded" -q

# Local L2 hardening only (no network)
python -m pytest tests/hardening -q
```

**Live devnet tests** use a local `.env` file in the test harness only. Fixtures
load values from env and pass them as explicit constructor parameters; the same
pattern application code should use.

```bash
cp .env.example .env
# fill in POLYESTER_API_KEY_ID, POLYESTER_API_PRIVATE_KEY, POLYESTER_ACCOUNT_ID

pip install -e ".[dev]"
python -m pytest tests/unit tests/hardening -q
./scripts/test_all.sh   # optional: unit + live tiers
./scripts/smoke_realtime.sh   # realtime unit + live heartbeat before release
```

Use `python -m pytest` (not bare `pytest`) so tests run in the same venv as `pip install`.
Set `POLYESTER_TEST_MUTATION=1` for state-changing tests. Funded mutations
require both `POLYESTER_TEST_MUTATION=1` and `POLYESTER_TEST_FUNDED=1`. For a
release-certification run, set `POLYESTER_TEST_STRICT_LIVE=1`; any skipped test
then fails instead of making an incomplete live run appear green. Missing or
malformed credentials must fail under strict live (not soft-skip as green). The
session printer scopes executed/skipped/failed counts to `@pytest.mark.integration`
tests and enforces `POLYESTER_TEST_MIN_EXECUTED` (default 5) when credentials are
present.

Legacy stress tests that use non-dry-run `cancel_all` require
`POLYESTER_TEST_ACCOUNT_WIDE_CLEANUP=1`. Set it only for a dedicated test
account; those tests may cancel every open order in their selected symbol.

CI requires every public Connect RPC in gen to be wrapped or listed in
`sdk-coverage.toml`. Contributors: `python scripts/check_sdk_coverage.py`.
CI fails when `sdk-capabilities.json` or the README capability table is stale;
refresh both with `python scripts/check_sdk_coverage.py --write-capabilities`
and commit the result.

**Pre-release checklist (realtime changes):**

```bash
cd polyester-sdk-python
python -m venv /tmp/polyester-pypi-test && source /tmp/polyester-pypi-test/bin/activate
pip install -e ".[dev]"
python -m pytest tests/unit -q
./scripts/smoke_realtime.sh

cd ../polyester-examples-python
pip install -e "../polyester-sdk-python"
python -m pytest -q
python3 examples/04_public_realtime_trades.py
python3 examples/05_public_orderbook_stream.py
```

Then bump the version, update `CHANGELOG.md`, build, and publish to PyPI. Install from the new wheel (not editable) and rerun `smoke_realtime.sh` once to confirm the published artifact.

## Lifecycle transaction lookups

One chain transaction can reference multiple lifecycle flows when operations
are bundled. Transaction lookups therefore return a page, not one flow:

```python
page = await client.lifecycle.list_flows_by_tx(tx_hash=tx_hash, limit=50)
flow_ids = [flow.intent_id for flow in page.flows]

while page.next_page_token:
    page = await client.lifecycle.list_flows_by_tx(
        tx_hash=tx_hash,
        limit=50,
        page_token=page.next_page_token,
    )
    flow_ids.extend(flow.intent_id for flow in page.flows)
```

`get_flow_by_tx(...)` also returns the complete first page. Use
`list_flows_by_tx(...)` when following pagination.

## Changelog

See [CHANGELOG.md](CHANGELOG.md).

## Transport

Connect RPC over HTTP via generated clients in `src/polyester/gen/`. Wire format
defaults to **binary protobuf**; pass `wire_format="json"` for debugging.

Some RPCs may return HTTP 404 on devnet. The SDK raises `PolyesterRouteNotFoundError`
with a clearer message than `[unimplemented]: Not Found`. HTTP 501 and Connect
`unimplemented` raise `PolyesterNotImplementedError`, the parent class of
`PolyesterRouteNotFoundError`; neither is retryable.

API-key rejections from the gateway carry a code. A request whose signed
timestamp falls outside the server's skew window raises
`PolyesterTimestampSkewError` (`code == "TIMESTAMP_SKEW"`). It is
retryable, and the gateway rejected it before any service saw it: a retry
signs again with a fresh timestamp. If it persists, fix the local clock. For
large bursts, bound concurrency so requests do not wait locally past the skew
window. Other gateway codes (`SIGNATURE_INVALID`, `KEY_DISABLED`,
`IP_NOT_ALLOWED`, ...) raise a non-retryable `PolyesterAuthError` whose
message is the problem `detail`, or the code without one.

Order reads, order and trigger writes and balance reads answer a refused
API-key call over ConnectRPC with `unauthenticated` and an
`auth.v1.AuthErrorDetail`; REST answers HTTP 401 with the same reason as the
problem `code`, without the `AUTH_` prefix. `auth_error_code(err)` reads both
the same way and returns the `AuthErrorCode` name: `AUTH_SIGNATURE_INVALID`,
`AUTH_TIMESTAMP_SKEW`, `AUTH_MISSING_HEADERS`, `AUTH_KEY_EXPIRED`,
`AUTH_KEY_DISABLED`, `AUTH_IP_NOT_ALLOWED`, `AUTH_API_KEY_NOT_ACCEPTED`
(exported as constants), and `AUTH_AUTHENTICATION_REQUIRED` for a request
with no or an invalid session token (REST `UNAUTHENTICATED`). It returns
`None` for an error without an auth code. `AUTH_TIMESTAMP_SKEW` raises
`PolyesterTimestampSkewError` on both transports, the one retryable reason:
fix the clock offset and the retry signs again. The others raise
`PolyesterAuthError` and need a change by the caller.

```python
from polyester import AUTH_IP_NOT_ALLOWED, auth_error_code

try:
    await client.orders.list_open()
except PolyesterAuthError as err:
    if auth_error_code(err) == AUTH_IP_NOT_ALLOWED:
        ...  # call from an address on the key's allowlist
```

A method that accepts only a session token (withdrawals, auto-withdraw rules,
portfolio equity reads, VIP status, ...) refuses an account API key with
`AUTH_SESSION_KIND_NOT_ALLOWED`, Connect `permission_denied` or REST 403. It
raises `PolyesterSessionRequiredError` (`is_session_required(err)`), which is
both a `PolyesterAuthError` and a `PolyesterApiError`, so existing handlers
keep catching it. Call the method with a session token instead.

A refused call's server message reaches the caller unchanged. An API key whose
policy lacks the action a withdraw or auto-withdraw read checks raises
`PolyesterAuthError` with, for example, "api key policy does not allow
READ_AUTO_WITHDRAW": grant that action in the key's policy.

`PolyesterApiError.violations` names the request fields to fix, each with
`field_path`, `rule_id` and `message`: from the `orders.v1.ErrorDetail` of a
ConnectRPC answer, and from the `violations` of a REST problem body. ConnectRPC
paths are proto field names (`order.base_qty_scaled`,
`order.limit_gtc.price_ticks`, `items[1].new_attached_risk...`); REST paths
are the REST request fields (`order.baseQty`). Branch on `code`, then on
`rule_id`; messages are for people. A REST problem body at HTTP 400 raises
`PolyesterApiError` with the `ERROR_CODE_` code (`ERROR_CODE_QTY_STEP_SIZE`),
not a retryable server error.

A request whose deadline expires raises `PolyesterTimeoutError`, a
`PolyesterTransportError`: retryable, but a mutation may still have been
applied, so reconcile first (`mutation_outcome_unknown(error)` is true).
