Metadata-Version: 2.5
Name: polyester-sdk
Version: 0.18.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.18.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 `start` and `mark_ready`
require an interactive session, and a route 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. The connection and subscription tokens are renewed on the same
connection before they expire; a renewal the API or the server refuses is
reported through `set_on_error(...)`, and the subscription then resubscribes
when the server ends it. The live order stream renews its own tokens. 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.18.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.
Credentials with the same API key ID share one timestamp allocator in the process, including
separate `AsyncPolyester` and `Polyester` clients and independently loaded `ApiKeyCredentials`.
The signing protocol has no cross-process nonce, so assign one API key per process; sharing a key
between processes can produce identical 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.

`ApiKeyCredentials` redacts the private key in `repr`, `str` and logging. Pickling, deep-copying
and `dataclasses.asdict`/`astuple` raise `TypeError`, also for a dataclass that holds the
credentials; read `private_key` explicitly when you need it.

`api_keys.generate_keypair()` and `guard_signer.export_wallet()` return the private key as a
`polyester.Secret`: `repr`, `str`, logging and Rich print `[REDACTED]`, pickling raises
`TypeError` and msgspec refuses to encode it. Read it with `keypair.secret_key_hex.reveal()`,
`keypair.secret_key.reveal()` or `result.private_key.reveal()`.

`recv_window_ms` on `AsyncPolyester` / `Polyester` sets the receive window of every signed API
request: the server processes a request only until its signing timestamp plus that many
milliseconds, sent as the signed `X-API-RECV-WINDOW` header. It must be an `int` from 1 to 60000,
and any other value raises `PolyesterValidationError` before anything is sent. `None`, the default,
sends no header, and the server applies 10000. A request that arrives after its window raises the
retryable `PolyesterTimestampSkewError`; one that waited past it before admission raises
`PolyesterApiError` with `code` `REQUEST_DEADLINE_EXCEEDED`, also retryable. Both changed nothing,
and a retry signs again with a fresh timestamp. Realtime token requests always use the default
window. `polyester.auth.sign_request` and `canonical_signing_string` take the same
`recv_window_ms` for requests you sign yourself; the window's line follows the timestamp line.

Recognized retry-later Connect `resource_exhausted` and REST admission-limit 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. A REST `RATE_LIMIT_EXCEEDED` problem raises the
same `PolyesterRateLimitError`, whichever side refused, with its `rate_limit` object in
`error.detail`; every field is optional. The gateway's own refusal names only `reason` and `scope`
(`CLIENT_IP`, no `operation_id`); a service's also names the operation, the policy class and the
remaining quota. The gateway's Connect refusal (`resource_exhausted`, "rate limit exceeded, retry
later") has no detail at all. `retry_after` prefers `detail.retry_after_ms`, then `Retry-After` /
`Retry-After-Ms` / `Grpc-Retry-Pushback-Ms` headers on HTTP paths, and is `None` when the server
gave no hint, as for a refusal of too many concurrent requests: back off on your own schedule
then. A 429 problem with another code, such as `AUTH_RESOURCE_EXHAUSTED` (no new account can be
created), raises `PolyesterApiError`, which waiting does not resolve.

A request body over an endpoint's size limit is refused with HTTP 413 and the problem
`REQUEST_TOO_LARGE` on every service, before the request is authenticated. It raises
`PolyesterApiError` with `code` `REQUEST_TOO_LARGE` and the problem in `error.problem`;
`is_request_too_large(err)` reports it. Nothing changed and the same body is refused again: reduce
it to fit the limit. A ConnectRPC message over the limit once decoded keeps its Connect
`resource_exhausted` answer and maps as before. 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`.

**Account permissions.** An API key reads account data only with the matching
permission on its API-key policy, granted from an interactive session. Each
grants exactly its reads: `READ_API_KEYS` (`api_keys.list`, `api_keys.get`),
`READ_VIP_STATUS` (`vip.get_vip_status`), `READ_SOCIAL_VERIFICATION`
(`social_verification.get`, link status only) and `READ_WITHDRAW_VALIDATION`
(`trading_withdraws.validate_destination`). `READ_API_POLICIES`,
`READ_SUBACCOUNT_POLICIES`, `READ_SUBACCOUNT_PERMISSIONS`, `READ_PROFILE`,
`READ_ACCOUNT_DIRECTORY`, `READ_DAILY_CLAIMS`, `CLAIM_DAILY_REWARD`,
`READ_CHECKPOINTS` and `READ_REWARDS` open calls this SDK does not wrap yet.
These permissions are valid on API-key policies only. VIP status and social
verification refuse a key scoped to a sub-account. A key without the permission
raises `PolyesterApiError` whose `auth_error_code(err)` is
`AUTH_API_KEY_ACCESS_DENIED`; its message names the missing permission. Policy
subscriptions decode them like every action, as lowercase names (`read_api_keys`).

**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 accepts at most 20 items; batch cancel and replace accept at
most 50, and refuse an item field they don't support. `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 is retained for 60 seconds from
acceptance and is readable as soon as `batch_replace` returns.
`get_batch_replace_status` raises the retryable `PolyesterServerError` for
`unavailable` (send the same read again within the window), and `not_found`
means no retained batch with that ID, never a rejected replacement. The wait
polls through `unavailable`, other server or network errors, and `not_found`,
because an older API could answer it for a few seconds after admission; a rate
limit or any other error is raised. After the window, reconcile the individual
orders with `orders.get` and never send an accepted batch again. An item's
`updated_ts_ns` is an observation time, not the refusal's identity. 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)
```

### Waiting for an order's trades

An order shows a fill as soon as it executes; its trades are recorded shortly
after. `orders.wait_for_order_trades_complete(key=...)` polls `orders.get`,
every execution page included, until the order has ended (`status`
`filled`, `canceled` or `rejected`) and its trades add up to `cum_qty`, then
returns that read. It reports completion at the same point as the Go and Rust
SDKs. `executions_complete` alone is not completion: it is also true for a
working order whose fills so far are all recorded, for example after a partial
fill. A working order keeps the wait polling until it ends or `timeout`
(seconds) passes, which raises `PolyesterTransportError`. To check the trades
recorded so far on an order that is still working, read it with `orders.get`
and compare its trades to `cum_qty`.

```python
detail = await client.orders.wait_for_order_trades_complete(key=OrderId(order_id), timeout=20.0)
print(detail.order.status, len(detail.trades), "trades")
```

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. `get_vip_status` needs `READ_VIP_STATUS` on a
key scoped to the main account. It 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. A published post can be republished, for example to correct
  its copy or withdraw a change: its `slug`, `published_at` and change ids stay
  the same, `revision` (from 1) increases and `updated_at` is when the current
  revision was published. To follow edits, read a post again when its
  `revision` differs from the one you hold, and drop a post that is no longer
  listed.
- `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, first 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, each change id once: when a post is republished during the
  walk, paging restarts at that post's first change. 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, each change once with the wording last received. They filter on first
  publication, so they don't report an edit to an older post; compare
  `revision` for that.

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 catalogs the
client loads when it is created. A method that names a market by symbol, or
needs a pair's scales, waits for that load itself, so you can call it right
after creating the client; there is no need to call `wait_for_catalogs()`
first. If the load failed, the method raises `PolyesterCatalogHydrationError`,
whose `__cause__` is the failure; create a new client to load the catalogs
again. A symbol the loaded catalogs don't know raises
`PolyesterValidationError` ("Unknown symbol"). With `hydrate_catalogs=False`
nothing is loaded and nothing waits: pass `symbol_id`, or hydrate
`client.catalogs` yourself. 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; those reads wait for a load in progress, and leave
the label empty when it failed.

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. A TWAP with
`order_type="market"` takes the same cap for each slice: `max_slippage_bps`
1–10000 to buy, 1–9999 to sell.

```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",
)
```

`trailing_distance_ticks` (and `trailing_distance_ticks` / `distance_ticks` on an
attached trailing stop) is a price delta in 1e-9 quote-unit ticks, the same scale as
every price tick, sent as given. It is not a count of the pair's tick size. On
BTC-USDT, a distance of 50 USDT is `50 * 10**9 = 50_000_000_000` ticks and 0.5 USDT is
`500_000_000`; a value of `1` trails by 0.000000001 USDT. Convert a decimal distance
exactly:

```python
from decimal import Decimal

distance = Decimal("0.5")  # USDT
ticks = distance.scaleb(9)  # Decimal("5E+8"): 500_000_000 ticks
assert ticks == ticks.to_integral_value(), "at most 9 decimal places"

await client.triggers.create(
    symbol="BTC-USDT",
    trigger_type="trailing_stop",
    side="sell",
    qty="0.01",
    trailing_distance_ticks=int(ticks),
)
```

Trigger reads decode the distance back to quote units (`details.trailing.trailing_distance`,
a `Price`).

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. When it fires, its child is a limit IOC
order at `limit_price` (lower when `max_slippage_ticks` or `max_slippage_bps`
cap it below): it fills what it can at or below the limit, and the rest is
canceled instead of resting. A trailing stop attached to an order that buys
takes none and exits with a market child. `orders.get`, `orders.list_open` and
`orders.list_history` with attached risk, and order stream updates, return it
as `TrailingStop.limit_price`, `None` when there is none.
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` and `DEADLINE_EXCEEDED` 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"`.

`client_trigger_id` identifies a create. Left out or empty, `triggers.create`
generates one, which reads back on the trigger. Set your own to find the
trigger after an unknown outcome, such as
`ERROR_CODE_TRIGGER_CREATE_OUTCOME_UNKNOWN`. A blank one is refused before
sending.

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.

`TriggerEvent.event_type` is `fired`, `canceled`, `updated`, `failed`,
`activated` (a trailing stop started tracking), `armed` (an attached
take-profit, stop-loss or trailing stop started protecting its parent's filled
quantity, once) or `fire_refused`. A ladder has no event types of its own: read
its `status`, `completed`, `cancelled` or `failed`. A completed ladder may not
have filled: read its executed quantity and levels.

A trigger change that names a listed pair other than the trigger's own is
refused with `ERROR_CODE_TRIGGER_SYMBOL_MISMATCH` (305). Nothing changed: name
the trigger's own pair or omit the symbol. A pair that is not listed is
`ERROR_CODE_UNKNOWN_SYMBOL`. A balance or other ledger read given a zero or
malformed sub-account ID is refused with the ledger code
`ERROR_CODE_INVALID_SUBACCOUNT_ID` (11), read from `PolyesterApiError.code`.

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). An API key needs
  `READ_WITHDRAW_VALIDATION`; `EXTERNAL_WITHDRAW` alone no longer allows it.
- **Trading → trading (another account):** `client.internal_transfers.create(...)`.
  The receipt's `flow_id` reads the transfer's lifecycle flow with
  `client.lifecycle.get_flow(flow_id=...)`; the flow may become readable after the receipt.

API-key withdrawals always use the authenticated key's bound account or
subaccount. The SDK's default subaccount does not change that source.
`create_to_funding` and `create_to_external_chain` reject explicit `account`
or `sub_account_id` arguments. Omit these arguments and use a key bound to the
intended source. For wallet/JWT withdrawals, `create_wallet_trading_withdraw`
supports explicit account selection and the configured default subaccount.

```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. A managed
subscription requests an unavailable snapshot again by itself, after 1 s doubling
to 8 s, until the book is available or the subscription stops; repeated
unavailable answers add no view. A market whose book stays unavailable keeps being
requested once every 8 s, so a bot recovers by itself when the book comes back;
close the subscription to stop.

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

`orderbook.get` takes the snapshot depths 1, 5, 10, 20, 50, 100, 200, 500 and 1000.
Any other depth from 1 to 1000 requests the smallest of them that covers it, 20 for
`11`, and 0 means 50; the result's `depth` is the depth requested
(`polyester.codecs.orderbook.orderbook_snapshot_depth`). A depth below 0 or above
1000, or one that isn't an exact integer, raises `PolyesterValidationError` before any
request. An exact integer is an `int` or any type with `__index__`, such as a NumPy
integer; `bool` and `float` are refused. The same rule applies to every depth and
`limit` argument, and the SDK sends and reports a plain `int`.
Each level's price and quantity carry the book's symbol, so passing one to an order
for another market is refused like any other cross-market value.
The delta stream is published at fewer depths, 1, 20, 50, 200 and 500
(`polyester.codecs.orderbook.ORDERBOOK_STREAM_DEPTHS`), and the server accepts a
subscribe at any other depth and then sends nothing on it, so the SDK does not
subscribe there:

- `orderbook.create_subscription(symbol=..., depth=10)` takes any depth from 1 to
  500. It rides the smallest published depth that covers it, 20 here, with its
  snapshot, and trims each view to 10 levels. Its update rate is that of the depth
  it rides, and each published update adds a view, also when only levels outside
  yours changed. With `bucket`, levels are aggregated first and the depth then counts
  buckets. A depth above 500, or below 1, raises `PolyesterValidationError` before
  connecting; for more than 500 levels, read a snapshot with `orderbook.get`.
- `orderbook.subscribe_deltas(symbol_id=..., depth=...)` delivers raw deltas, which
  can't be trimmed, so it takes only a published depth and raises
  `PolyesterValidationError` for any other.

A stream that stops and a market with nothing to report look alike, since no view
arrives in either case. `since_last_publication()` on a managed order book tells them
apart: it is the number of seconds since the stream last delivered a publication (or
since the subscription started); views from a snapshot or a bucket change don't count.
The server republishes each book's whole view about every 30 s even when nothing
changed (seen on every market of devnet and testnet; the public contract does not
promise a cadence), so a quiet market stays under that. The SDK sets no threshold and
acts on none. Poll it, and treat an age far above 30 s, such as 90 s, as a stall: call
`refresh_snapshot()`, or close and subscribe again. A sync subscription offers the same
method.

```python
if book_sub.since_last_publication() > 90:
    await book_sub.refresh_snapshot()
```

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

### Subscriptions the server ends

When the server ends a realtime subscription, the iterator yields what is queued
and raises `PolyesterSubscriptionEndedError`, also reported through `error` and
the error callback. It names the channel, the server's code and text
(`unsubscribe_code`, `server_reason`), and `reason`. Managed order books and
market overviews finish their iterator and report the same error through
`last_error` and `on_error`.

The exception is a sub-account's balances, transfers, trades, triggers or
lifecycle channel: the server can end one of those after access was granted
again, so the SDK reads `ListSubaccounts` and, while the sub-account is listed
with a role, subscribes again with a fresh token. `take_resubscribed()` reports
the gap. It stops, with the reason, when a `removed` update for that sub-account
arrived on this client, when the list no longer has it, when the list or the
token is refused (`__cause__` holds the refusal), or after 6 attempts that did
not stay up for a minute. Requests are paced across the client, so many
subscriptions ending together share one list read per second and at most five
attempts per second. An API key bound to a sub-account sees that sub-account in
the list; a root key cannot subscribe to a sub-account's channels.

### 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. `LedgerTransfer.balance_after` is
  the account bucket's balance after the transfer, an exact integer at the
  18-decimal ledger scale like `amount`, or `None` when the server sends none.
  `LedgerTransfer.link_id` is the decimal correlation ID of related legs, empty
  when there is none. 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, and a snapshot with the publications buffered behind it adds
  one view; 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(...)`).

A sync call ends when its async call ends. The sync client adds no deadline of
its own: the configured `timeout` and a method's own `timeout` bound each call,
and a mutation they cut short raises `PolyesterTimeoutError`, for which
`mutation_outcome_unknown` is true, so reconcile before you send it again. A
caller interrupted while it waits, for example by Ctrl-C, gets the interruption
once the call has been cancelled, so nothing is sent for it afterwards. A call on
a closed client raises `PolyesterError`.

With `on_error`, setup and iteration failures are delivered to it once. Without
`on_error`, `subscribe_sync` waits for setup and raises a setup failure from the
call, returning no handle.

`on_event` and `on_error` run on the client's event loop. From a callback you
can close a subscription, the callback's own included, with `close()` or
`unsubscribe()`: it stops delivery and returns at once, and the stream closes
after the callback returns. The calls that would have to wait for that loop
raise `PolyesterError` at once instead of blocking it: every other sync method
that runs on the client loop,
`subscribe_sync` (`PolyesterRealtimeError`) and `Polyester.close()`. Make those
calls from another thread.

```python
holder = {}

def on_event(trade):
    print(trade)
    holder["sub"].close()  # stop after the first trade

holder["sub"] = client.market_data.subscribe_trades_sync(
    symbol="BTC-USDT", on_event=on_event, on_error=print
)
```

## 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 flow lists

A flow list that selects an account (`owner_account_id` or
`smart_account_address`) returns the flows it owns and the transfers other
accounts sent to it, such as a transfer from another user or sub-account, or a
reward payout. Each row's `participation` says which: `"owner"` (its deposit,
withdrawal, or a transfer it sent, including a move between its own Funding
and Trading balances) or `"recipient"`. For a transfer, the counterparty is
`destination_address` when the account sent it and `source_address` (with
`owner_account_id`) when it received it. `participation=` narrows the list to
one side; the server refuses it without an account.

```python
page = await client.lifecycle.list_flows(
    owner_account_id="YOUR_ACCOUNT_ID", participation="recipient", limit=20
)
```

A page token is bound to the request it was returned for: continue with the
same account, participation, scope and order; only `limit` may change.

## 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. A bare 404 (no JSON body, or a router's
own "not found" message) raises `PolyesterRouteNotFoundError` with a clearer message
than `[unimplemented]: Not Found`. Connect `unimplemented` and a contract code at
HTTP 501 raise `PolyesterNotImplementedError`, the parent class of
`PolyesterRouteNotFoundError`; neither is retryable. A route error has an unknown
mutation outcome, because whatever answered wasn't the service.

An HTTP error with no code the contract defines can come from a gateway or an edge,
so it doesn't prove a request was refused. Only a 401 or 403 is a refusal on its
status alone (`PolyesterAuthError`). Every other status raises
`PolyesterServerError` with `mutation_outcome_unknown(error)` true and
`is_retryable_error(error)` false, and keeps any code it did send in `error.code`.
Read back before sending a mutation again. Trigger reads retry 429, 502, 503 and 504 for you; retry other reads yourself if you want to.
The one exception is a well-formed but unrecognised code at HTTP 429, which raises
`PolyesterApiError`: known and not 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`,
`AUTH_RECV_WINDOW_INVALID` (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;
`is_recv_window_invalid(err)` reports `AUTH_RECV_WINDOW_INVALID` (REST 401
`RECV_WINDOW_INVALID`), an `X-API-RECV-WINDOW` header that is not one whole
number from 1 to 60000. `AUTH_REPLAY_CAPACITY_EXHAUSTED` (REST 429
`REPLAY_CAPACITY_EXHAUSTED`, ConnectRPC `resource_exhausted`) is not a
credentials refusal: the server remembers every signed request until its
receive window ends, so it cannot be replayed, and the key or its account
holds its full share of that record. The request was not processed and the
share frees as earlier windows end, so it raises `PolyesterRateLimitError`
with that `code`; `is_retryable_error(err)` and
`is_replay_capacity_exhausted(err)` report true. It carries no `Retry-After`:
back off briefly and send again, which signs again. A shorter
`recv_window_ms` makes room sooner. `AUTH_WALLET_ALREADY_LINKED`
(ConnectRPC `already_exists`, REST 409) refuses a wallet login whose wallet is
already linked to another account; it is terminal, and
`is_wallet_already_linked(err)` reports it. Log in with the account address the
wallet already uses.

```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, ...) 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`, or
`ERROR_CODE_SAME_SOURCE_DESTINATION` for a transfer), as the typed ConnectRPC
detail of any service reads, not a retryable server error.

A request whose deadline expires raises `PolyesterTimeoutError`, a
`PolyesterTransportError`: non-retryable with outcome unknown. Read back first
(`mutation_outcome_unknown(error)` is true); retry reads explicitly.



The retry flag is always false when the mutation outcome is unknown. This includes
bare Connect `unavailable`, `internal` and `deadline_exceeded`, gateway 502/503
bodies, bare or HTML 429 responses and client timeouts. Read back after an unknown outcome; retry reads explicitly. Automatic
trigger reads, status waits and snapshot refresh use read context and can retry these
failures within their existing budgets. Creates are never automatically resent.

A typed `orders.v1.ErrorDetail` carrying `UNAVAILABLE` proves a known, retryable
orders refusal. A bare REST `UNAVAILABLE` or a Connect message with only that
name does not establish its source: outcome unknown and non-retryable. Specific
refusals such as `FEE_SCHEDULE_UNAVAILABLE`, `POLICY_UNAVAILABLE` and
`RATE_LIMIT_POLICY_UNAVAILABLE` are known and retryable on typed Connect,
code-only Connect messages and recognized REST 503 problems. A REST 502 cannot
establish these refusals. `ACCOUNT_ROUTING_INVALID` means the trigger was not
admitted; contact support if it persists.

`RATE_LIMIT_EXCEEDED`, `PRICE_LEVELS_EXHAUSTED` and retry-later capacity codes
are known, retryable admission refusals; preserve and respect any server delay.
The additional retry-later codes are:

- `CLIENT_ORDER_IDS_EXHAUSTED`
- `CLIENT_ORDER_ID_CAPACITY_REACHED`
- `MARKET_LADDER_LIMIT_EXCEEDED`
- `MARKET_TRIGGER_LIMIT_EXCEEDED`
- `MARKET_TWAP_LIMIT_EXCEEDED`
- `NEW_ACCOUNT_CAPACITY_REACHED`
- `REQUEST_RECEIPTS_EXHAUSTED`
- `RESERVATION_CAPACITY_REACHED`
- `TRIGGER_CAPACITY_REACHED`
- `TRIGGER_CONTROL_SHARE_EXHAUSTED`
- `TWAP_CAPACITY_REACHED`

These behave the same on REST, typed Connect and code-only Connect messages.
The resource codes without retry guidance are `ORDER_SEQUENCE_EXHAUSTED`,
`TRIGGER_FIRE_LIMIT_REACHED`, `QUICKSWAP_CAPACITY_REACHED`,
`ACCOUNT_RESERVATION_LIMIT_REACHED` and `TRIGGER_LIMIT_EXCEEDED`: outcome known,
non-retryable, including message-only responses.

A plain REST gateway 504 raises `PolyesterServerError` with `status_code == 504`:
`is_retryable_error` is false and `mutation_outcome_unknown` is true. The
classifier has no operation context. Reads may retry a 504 explicitly; writes
read back first. A client order ID helps reconcile a create but does not make it
an exact replay. Without one, read open orders before deciding whether to submit
again. The SDK never automatically resends a create; bounded trigger/status reads
and snapshot recovery keep their own safe read retries.

`OVERLOADED` and `SYSTEM_RATE_LIMITED` are retryable admission refusals on
Connect and REST: nothing was admitted. Respect `retry_after` when supplied;
branch on `PolyesterRateLimitError.code`: `ERROR_CODE_OVERLOADED` means wait for an
outstanding request to complete; `ERROR_CODE_SYSTEM_RATE_LIMITED` names a venue-wide limit.
`ORDER_SEQUENCE_EXHAUSTED` is permanent; contact support instead of retrying.
