polyester-sdk 0.1.0-alpha.27

Official Rust SDK for Polyester APIs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
# Polyester Rust SDK

Official Rust SDK for Polyester APIs, built for trading bots, backend services,
and automation. Parity with `polyester-sdk-go` and `polyester-sdk-python`, built
on [Connect for Rust](https://github.com/connectrpc/connect-rust) (Buffa + Connect
**0.8.x**) and the checked-in `src/gen/` protobuf bundle.

**Status:** Alpha (`0.1.0-alpha.27`, git tag `v0.1.0a27`). Proprietary license
(not open source). API-key only; no browser login or JWT flows.

**MSRV:** Rust 1.88+

## 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/subscribe) | Yes |
| Policies (realtime subscribe) | Yes |
| Guard signer | Yes |
| Balances, holds, 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 |
| Polychart / layout / whiteboard | 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.ai/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, whiteboard/social-verification and some
layout/polychart routes may require a JWT session or may not be mounted.
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

crates.io: https://crates.io/crates/polyester-sdk

```toml
[dependencies]
polyester-sdk = "0.1.0-alpha.27"
```

Realtime (Centrifugo) and on-chain Funding helpers are always included. The optional
`realtime` / `chain` Cargo features are empty compatibility flags.

For a private git checkout instead of crates.io:

```toml
[dependencies]
polyester-sdk = { git = "https://github.com/Fabric-Labs/polyester-sdk-rust", tag = "v0.1.0a27" }
```

The repository is currently private, so GitHub access and authenticated Git credentials are
required for the git pin. If Cargo cannot use your credential helper, run it with
`CARGO_NET_GIT_FETCH_WITH_CLI=true`.

For development from a git checkout:

```bash
git clone https://github.com/Fabric-Labs/polyester-sdk-rust.git
cd polyester-sdk-rust
cargo test --lib
```

Pin the Connect runtime: this crate depends on `connectrpc` / `buffa` **0.8.x**.
Review upstream notes before upgrading.

## Quick start

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 balance requests and private balance streams. Add the appropriate
trading permissions for create, cancel, modify, and other order mutations.
This API key policy is separate from the **subaccount policy**: the subaccount
policy constrains the subaccount, but does not grant permissions to the key.

```rust,no_run
use polyester::{Client, Config, Result};
use polyester::services::ListMarketOverviewOptions;

#[tokio::main]
async fn main() -> Result<()> {
    let client = Client::new(Config {
        api_key_id: Some("ak_...".into()),
        api_private_key: Some("...".into()), // 64-char hex from key creation
        default_account_id: Some("RLxqJGUDg92".into()), // Profile → Account ID
        ..Default::default()
    })?;

    let overview = client
        .market_overview
        .list(ListMarketOverviewOptions {
            limit: Some(5),
            ..Default::default()
        })
        .await?;
    for market in overview.markets {
        let ticks = market
            .last_price
            .as_ref()
            .map(|p| p.as_ticks());
        println!("{} {:?}", market.symbol, ticks);
    }

    let open_orders = client.orders.list_open(None).await?;
    println!("{} open orders", open_orders.orders.len());
    Ok(())
}
```

## Credentials

| Value | Where to find it | Config field |
| --- | --- | --- |
| 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** (e.g. `RLxqJGUDg92`) | `default_account_id` |

Pass credentials on `Config` / `Client::new`. The SDK does not implicitly read
environment variables unless you call `Client::from_env`.

`api_private_key` accepts the 64-character hex Ed25519 secret from key creation.

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

`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. Cloned
and independently constructed credentials with the same key id share a process-wide allocator.
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;
larger async bursts wait through Tokio-aware backpressure instead of blocking executor threads,
reusing a signature, or drifting outside the API's 10-second freshness window. If the bounded
capacity wait is exhausted, the call returns retryable `Error::RateLimit`. Direct low-level
`Credentials::sign_request` calls never sleep; use `sign_request_async` in async applications.

## Authentication patterns

**Recommended: explicit config**

```rust,no_run
use polyester::{Client, Config};

let client = Client::new(Config {
    api_key_id: Some("ak_...".into()),
    api_private_key: Some("...".into()),
    default_account_id: Some("RLxqJGUDg92".into()),
    ..Default::default()
})?;
```

**If your deployment stores secrets in environment variables**, read them in your
application and pass them to `Client::new`:

```rust,no_run
use std::env;
use polyester::{Client, Config};

let client = Client::new(Config {
    api_key_id: env::var("POLYESTER_API_KEY_ID").ok(),
    api_private_key: env::var("POLYESTER_API_PRIVATE_KEY").ok(),
    default_account_id: env::var("POLYESTER_ACCOUNT_ID").ok(),
    ..Default::default()
})?;
```

`Client::new` never implicitly reads the process environment.

**Scripts and local tests only:** `Client::from_env()` loads
`POLYESTER_API_KEY_ID`, `POLYESTER_API_PRIVATE_KEY`, and optionally
`POLYESTER_ACCOUNT_ID` / `POLYESTER_API_URL`. This is a convenience helper, not
the primary integration pattern.

```rust,no_run
let client = polyester::Client::from_env()?;
```

## Catalog readiness

`Config::hydrate_catalogs` defaults to `true`. When constructed inside a Tokio
runtime, the client starts spot and zipper catalog hydration in the background.
Await readiness before decimal writes that depend on catalog scales:

```rust,no_run
let client = polyester::Client::from_env()?;
client.wait_for_catalogs().await?;
```

If a client is constructed before entering a Tokio runtime,
`wait_for_catalogs()` starts hydration on the current runtime. Scaled bot inputs
must carry their source scale. `AssetAmount::from_scaled(..., None, ...)` is
accepted for composition only and fails closed on transfer/withdraw encoding
unless the request's `amount_scale` / `quantity_scale` is explicit. Prefer
`from_decimal_str` / `from_decimal`, or pass `Some(scale)` to `from_scaled`.

## Create and cancel orders

```rust,no_run
use polyester::models::{CreateOrderType, CreateSide, CreateTimeInForce};
use polyester::services::OrdersService;
use polyester::types::{Price, Quantity};

client.wait_for_catalogs().await?;

let mut params = OrdersService::create_params(
    "BNB-USDT",
    CreateSide::Buy,
    CreateOrderType::Limit,
    Quantity::from_decimal_str("0.01", 8, None, None)?,
    Some(Price::from_decimal_str("100", None)?),
    Some("my-bot-001"), // recommended whenever you may retry
);
params.time_in_force = Some(CreateTimeInForce::Gtc);
params.post_only = Some(true);

let result = client.orders.create(params).await?;
println!("{} {}", result.status, result.order_id);

client
    .orders
    .cancel_by_client_order_id("my-bot-001", Some("BNB-USDT"), None)
    .await?;
```

`client_order_id` is **optional** (matches the API). Pass `None` for one-shot
creates. **Set a stable non-empty value when you may retry** after an ambiguous
transport/server failure, and reuse that same id on retry / reconciliation -
without it you cannot safely tell whether the first attempt admitted the order.
Client order ids accept 1 to 36 ASCII letters, digits, `.`, `_`, `:`, `/`, and
`-`. Batch create accepts at most 20 orders. Treat a cancel response as an
admission acknowledgement and reconcile with `list_open` before releasing local
state.

Create sizing is explicit: set exactly one of base `quantity` or
`max_quote_debit_scaled` (a typed hard all-in quote-debit budget). Construct the
latter with `Quantity::from_quote_decimal_str` / `from_quote_decimal` /
`from_quote_scaled`; the SDK validates its `OrderQuote` domain and scale against
`Catalogs::quote_quantity_scale_for_symbol`.

```rust,no_run
let quote_scale = client
    .catalogs
    .quote_quantity_scale_for_symbol("BNB-USDT")
    .expect("catalog hydrated with quote scale");
params.quantity = None;
params.max_quote_debit_scaled = Some(Quantity::from_quote_decimal_str(
    "25.00",
    quote_scale,
    Some("BNB-USDT".into()),
    client.catalogs.symbol_id_for_symbol("BNB-USDT"),
)?);
```

Use `OrdersService::preview(PreviewOrderParams { ... })` to check whether an
order intent is currently admissible before submitting. Preview sends the same
`OrderIntent` contract as create (sizing, execution, fee asset, STP, optional
attached risk). The host runs an admissibility check only: no hold is placed,
and `client_order_id` is accepted but not claimed. The result reports
`admissible`, optional typed `rejection` (`OrderErrorDetail`), optional
`resolved_base_qty`, optional `protected_price_bound`, and `evaluated_at_ms`.
Known rejection codes use stable labels such as `BAD_QTY`.
Preview no longer returns fee/quote estimates. 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. Fee selection is `FeeAsset::Quote` or, for
BUYs only, `FeeAsset::Base`; the former `received` fee mode no longer exists.

Standalone trailing-stop triggers remain SELL market-IOC and now encode wire
`side` explicitly. Attached trailing-stop risk may use either side (opposite
the parent); trigger reads project `trigger_type`, `side`, and
`parent_order_id` from the host response.

Market orders are IOC and enforce a slippage-derived execution boundary. See
[Market Order Price Protection](https://polyester.ai/developer-docs/shared-concepts/market-order-price-protection)
before overriding `market_max_slippage`.

Use **decimal strings** or `Decimal` for human-facing `qty` / `price` inputs.
Do **not** pass floats. Price ticks are Polyester protocol price units (fixed
1e6), not market tick-size alignment (server validates tick size).

### For bots (scaled integers)

Stay in integer space; no string round-trip:

```rust,no_run
use polyester::models::{CreateOrderType, CreateSide};
use polyester::services::OrdersService;
use polyester::types::{Price, Quantity};

let params = OrdersService::create_params(
    "BNB-USDT",
    CreateSide::Buy,
    CreateOrderType::Limit,
    Quantity::from_scaled(1_000_000, Some(8), Default::default(), None, None)?,
    Some(Price::from_ticks(100_000_000, None)?), // 100.000000 at 1e6
    Some("my-scaled-bot-001"),
);
let _ = client.orders.create(params).await?;
// Reads expose the same types: order.price.as_ticks(), order.orig_qty.as_scaled()
```

Compatible values from fills/books can be passed back into writes when the
instrument/domain matches. Transfers and trading withdraws use `AssetAmount`
(not order `Quantity`).

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

## Triggers

`triggers.list_with(ListTriggersOpts { status: ... })` filters by lifecycle
status. Valid values:

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

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

`orders.get_with(GetOrderOpts { key: OrderKey::OrderId(id), include_attached_risk: true, subaccount_id: None, include_attached_risk_state: false })`
returns policy data on `Order.attached_risk`. `Order` also exposes `post_only`.
Identify orders with `OrderKey::OrderId` or `OrderKey::ClientOrderId` (exclusive oneOf).

## Qty / price rules

Public order/trigger write paths take **`Price` / `Quantity` wrappers only**:

| Audience | Constructor |
|---|---|
| Humans / demos | `Price::from_decimal_str` / `Quantity::from_decimal_str` (or `from_decimal`) |
| Bots / MMs | `Price::from_ticks` / `Quantity::from_scaled` |

- **Reject floats** (`f32`/`f64`); they are not accepted on these APIs.
- **Reject bare integers** on public order APIs; use the named constructors.
- **Reject excess fractional digits** on decimal→scaled conversion (no silent floor).
- Price ticks are fixed **1e6**; qty scale comes from pair `base_quantity_scale` (catalog).
- Quote budgets use pair `quote_quantity_scale`; retrieve it with
  `Catalogs::quote_quantity_scale_for_symbol[_id]` and construct an
  `OrderQuote` `Quantity`.

## 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 via `PolyesterSmartAccount` with a
  caller-supplied owner EOA key (SDK derives the Polyester Safe; no UI-exported
  owner key).
- **Funding → external:** on-chain `FundingAccount.withdrawToChain` (same
  `polyester::chain`); quote fees with `quote_zipper_fee` first.
- **Whitelist:** FundingAccount allowlist + GuardRegistry signer encoders under
  `polyester::chain` (`encode_add_allowed_external_destinations`, …).
- **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:** `client.withdraw.create_to_funding(...)` with a signed
  intent payload.
- **Trading → trading (another account):** `client.internal_transfers.create(...)`.

```rust,no_run
use alloy_primitives::U256;
use polyester::chain::{
    POLYESTER_TESTNET_ENVIRONMENT, PolyesterSmartAccount, encode_trading_gateway_deposit,
};
use std::time::Duration;

let owner_private_key = "0x...";
let u_asset_id = "0x...";
let account = PolyesterSmartAccount::new(owner_private_key, None, 0, Duration::from_secs(60))?;
let call = encode_trading_gateway_deposit(
    POLYESTER_TESTNET_ENVIRONMENT.contracts.trading_gateway_address,
    u_asset_id,
    U256::from(10u64).pow(U256::from(18u64)),
)?;
let result = account.send_calls(&[call], true, Duration::from_secs(60)).await?;
```

Realtime queues fail with `Error::QueueOverflow` instead of silently dropping;
managed streams rebuild snapshots after reconnect and expose
`on_reconnect` / `on_snapshot_refresh` hooks. Subscribe methods return only
after the initial websocket handshake succeeds; managed create methods also
wait for the initial snapshot. After connection, use
`recv_result().await` or `set_on_error(...)` so delivery failures cannot look
like a clean end of stream. Reconnects use capped exponential backoff with
per-subscription jitter. Snapshot-recovery buffers also fail closed on overflow.

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

Balance fields are ledger u128 wire decimal strings (18-scale). Print them
directly, or format smaller integers with `polyester::codecs::format_ledger_u64`
when you already have a `u64` quantity.

```rust,no_run
use polyester::proto::ledger::read::v1::GetBalancesRequest;

let balances = client.balances.list(GetBalancesRequest::default()).await?;
for bal in balances.balances {
    println!("{} funding={} trading={}", bal.asset_id, bal.funding, bal.trading);
}
```

## Errors, retries, and withdrawal identity

Classify failures with `Error::is_retryable()` and respect `Error::retry_after()`. For mutations,
`Error::mutation_outcome_unknown()` means the first request may have committed: reconcile state
before retrying and reuse the same logical request identity.

Order mutations that take a `request_id` (`modify`, `batch_create`, `batch_cancel`,
`batch_replace`, `cancel_all` / `cancel_all_with`, `cancel_all_after`) **generate one when
omitted**, matching TypeScript/Go/Python. That is fine for one-shot calls. **Do not blind-retry
after an ambiguous failure while omitting `request_id`**: each omitted call mints a *new* id, so
the retry is a second logical mutation rather than an idempotent replay. Generate or choose a
stable `request_id` once, persist it with the attempt, and pass the same value on retry (same
rule as `client_order_id` on create). Convenience helpers that do not accept `request_id`
(for example `cancel_all`) are one-shot oriented - use the `*_with` / explicit-argument form when
you need retry-safe identity.

Batch replace returns admission, not execution finality. After admission,
predecessor IDs may be stale: use `replacement_order_id` from each result.
Reuse the same `request_id` when retrying a logical batch, then poll
`get_batch_replace_status`. Its item phases are `admitted`, `working`,
`rejected`, and `terminal`; `is_batch_replace_settled()` means every item has
left admission processing (`working` is live, not terminal execution).

```rust,no_run
use polyester::models::{CreateOrderParams, ModifyOrderParams};
use polyester::{Client, Result};

async fn create_with_reconciliation(client: &Client, params: CreateOrderParams) -> Result<()> {
    match client.orders.create(params.clone()).await {
        Ok(order) => println!("{}", order.order_id),
        Err(error) if error.is_retryable() => {
            if error.mutation_outcome_unknown() {
                // Reconcile by client_order_id before deciding whether to retry.
                // This only works if params.client_order_id was set on the first attempt.
            }
            if let Some(seconds) = error.retry_after() {
                tokio::time::sleep(std::time::Duration::from_secs_f64(seconds)).await;
            }
            // If reconciliation permits a retry, reuse the same params.client_order_id.
        }
        Err(error) => return Err(error),
    }
    Ok(())
}

async fn modify_with_stable_request_id(client: &Client, mut params: ModifyOrderParams) -> Result<()> {
    // Choose once per logical modify; reuse on every retry of that attempt.
    params.request_id = Some("mod-bot-attempt-42".into());
    let _ = client.orders.modify(params).await?;
    Ok(())
}
```

Trading withdrawals require a caller-owned non-empty idempotency key and non-zero nonce. Generate
each once per logical withdrawal, persist both with the signed payload, and reuse the entire
request after an ambiguous timeout:

```rust,no_run
use polyester::{new_trading_withdraw_idempotency_key, new_trading_withdraw_nonce};

let idempotency_key = new_trading_withdraw_idempotency_key()?;
let nonce = new_trading_withdraw_nonce()?;
// Persist both before signing CreateTradingWithdrawParams.
```

Never generate either value inside a retry loop.

## Public market data

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

```rust,no_run
let candles = client
    .market_data
    .get_candles("BTC-USDT", "1m", Some(50))
    .await?;
let current: Option<_> = client
    .market_data
    .get_current_candle("BTC-USDT", "1m")
    .await?;
let trades = client.market_data.get_trades("BTC-USDT", Some(20)).await?;
let _ = (candles, current, trades);

client.wait_for_catalogs().await?;
let mut sub = client.market_data.subscribe_trades("BNB-USDT").await?;
sub.set_on_error(|error| eprintln!("realtime interruption: {error}"));
if let Some(trade) = sub.recv_result().await? {
    println!(
        "{:?} {:?}",
        trade.price.as_ref().map(|p| p.as_ticks()),
        trade.qty.as_ref().map(|q| q.as_scaled())
    );
}
```

`get_candles` and `get_candles_with` return rows newest-first by `ts_sec`.
When incomplete rows are included, the current open candle is prepended.

Merged market overview stream (snapshot + live updates):

```rust,no_run
use polyester::services::MarketOverviewCreateSubscriptionOptions;

let mut sub = client
    .market_overview
    .create_subscription(MarketOverviewCreateSubscriptionOptions {
        limit: Some(50),
        ..Default::default()
    })
    .await?;
sub.set_on_error(|error| eprintln!("managed overview failed: {error}"));
if let Some(markets) = sub.updates().recv().await {
    println!("{} rows", markets.len());
}
```

## Realtime

Realtime is binary-only. The client negotiates the `centrifuge-protobuf`
WebSocket subprotocol, sends binary length-delimited Centrifugo commands, and
decodes protobuf publication payloads from `:proto` channels. ConnectRPC's
optional JSON wire mode does not apply to realtime. Incoming WebSocket messages,
frames, and protobuf record lengths are capped at 8 MiB.

Realtime subscription handles stop their background tasks when explicitly
closed or dropped. Call `close()` when prompt shutdown matters; `Drop` provides
the cleanup safety net.

```rust,no_run
use polyester::services::CreateSubscriptionOptions;

let mut orders = client.orders.subscribe(None).await?;
if let Some(order) = orders.recv_result().await? {
    println!("{} {}", order.order_id, order.status);
}

let mut api_policies = client.policies.subscribe_api_policies(None).await?;
if let Some(policy) = api_policies.recv_result().await? {
    println!("{} {}", policy.policy_id, policy.revision);
}


let mut book = client
    .orderbook
    .create_subscription(CreateSubscriptionOptions {
        symbol: "ETH-USDT".into(),
        depth: Some(50),
        ..Default::default()
    })
    .await?;
book.set_on_error(|error| eprintln!("managed order book failed: {error}"));
if let Some(snapshot) = book.updates().recv().await {
    println!("{} {}", snapshot.book_seq, snapshot.bids.len());
}
```

Private order protobufs carry scaled quantity integers but may not carry the
base quantity scale, so `Quantity::scale()` can return `None`. A bot must
resolve the scale from the hydrated catalog using `Quantity::symbol_id()` (or
`Quantity::symbol()`), then pass that catalog scale to `Quantity::format`.
Never trust, guess, or invent a stream quantity scale.

## Development

```bash
source "$HOME/.cargo/env"   # if cargo is not on PATH yet
cargo check
cargo test --lib
cargo test --test integration -- --test-threads=1
cargo clippy --all-targets -- -D warnings
```

CI requires every public Connect RPC in gen to be wrapped or listed in
`sdk-coverage.toml`. Contributors:

```bash
python3 scripts/check_sdk_coverage.py
python3 scripts/check_sdk_coverage.py --write-capabilities  # refresh JSON + README table
```

CI refreshes `sdk-capabilities.json` and the README capability table on pushes to
`main` when they drift (not on pull-request CI).

CI runs unit/lib and compile-fail UI tests only (`cargo test --lib --test ui`).
Live integration tests under `tests/integration/` (and `a7_strict_live`, which shells out to
them) are **not** part of CI and are **excluded from the crates.io package**. Run them from a
git checkout of this repository. They need `POLYESTER_API_KEY_ID` /
`POLYESTER_API_PRIVATE_KEY` (and usually `POLYESTER_ACCOUNT_ID`). Without those env vars they
soft-skip unless `POLYESTER_TEST_STRICT_LIVE=1` is set.

Optional tiers (same gates as Go/Python):

| Env | Enables |
|---|---|
| `POLYESTER_TEST_MUTATION=1` | State-changing tests, including funded mutations |
| `POLYESTER_TEST_FUNDED=1` | Balance-changing transfers / fills (also requires the mutation gate) |
| `POLYESTER_TEST_ACCOUNT_WIDE_CLEANUP=1` | Legacy `cancel_all` stress tests; dedicated test accounts only |
| `POLYESTER_TEST_TRADE_E2E=1` + `POLYESTER_TEST_MAKER_*` | Maker+taker fill e2e |
| `POLYESTER_TEST_INTERNAL_TRANSFER_DEST` | Internal / unified→user transfers |
| `POLYESTER_TEST_STRICT_LIVE=1` | Fail the selected live run if any test takes a `skip:` path |

With a local `.env`, `dotenvy` loads it automatically (`.env` is gitignored).
Run live mutation/funded tests serially so order lifecycle and balance checks do
not race each other. Enable strict live mode for release certification after
configuring every gate and credential required by the selected tests.

CI rejects private `ledger.write` symbols in public gen (same gate as Go/Python).

## Layout

| Path | Role |
|---|---|
| `src/gen/buffa`, `src/gen/connect` | Checked-in Buffa + Connect codegen |
| `src/proto`, `src/connect_gen` | Module tree mounting gen as `crate::proto` / `crate::connect` |
| `src/auth`, `src/transport` | Ed25519 API-key signing + Connect `HttpClient` |
| `src/types`, `src/codecs` | `Price` / `Quantity` / scalars |
| `src/services`, `src/client` | Ergonomic async `Client` surface |
| `src/realtime` | WebSocket subscriptions (always included; `realtime`/`chain` are empty compat flags) |
| `src/catalogs`, `src/orderbook` | Catalog cache + local book helpers |

Proto stubs are updated when a new `src/gen/` bundle is landed. Day-to-day SDK
work does not require local `buf` generation. After replacing gen files, run:

```bash
python3 scripts/gen_module_tree.py
```

## Auth signing

Authenticated Connect calls sign the **exact configured wire body bytes**
(protobuf or JSON) with:

```text
timestamp_ms
METHOD
pathname
canonical_query
hex(sha256(body))
```

Headers: `X-API-KEY-ID`, `X-API-TIMESTAMP`, `X-API-SIGNATURE`.

Use the high-level `Client` service methods for authenticated calls. Generated
clients under `polyester::connect` are low-level protocol bindings and do not
apply Polyester API-key signatures by themselves.

## Examples

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

## Changelog

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

## License

Proprietary. See [LICENSE](LICENSE).