Skip to main content

rustrade_execution/client/binance/
margin.rs

1//! Binance Margin execution client (cross and isolated).
2//!
3//! [`BinanceMargin`] is a margin counterpart to [`super::spot::BinanceSpot`]. It shares the
4//! exchange-agnostic infrastructure in [`super::shared`] (rate-limit tracking, reconnect/backoff,
5//! event deduplication, error parsing) and implements the same `ExecutionClient` trait so callers
6//! do not branch on spot-vs-margin transport.
7//!
8//! ## Scope
9//! This module provides the client's identity and configuration ([`BinanceMargin`],
10//! [`BinanceMarginConfig`], [`MarginSideEffect`]) and a full [`ExecutionClient`] implementation:
11//! order submission/cancel and account snapshot / balance / open-order / trade queries over REST,
12//! plus a live account event stream ([`ExecutionClient::account_stream`]) over the hand-rolled
13//! `userListenToken` user-data WebSocket (the SDK's retired listen-key path is not used). Both
14//! **cross** (`isIsolated = "FALSE"`, account-wide collateral) and **isolated** (`isIsolated =
15//! "TRUE"`, per-pair sub-accounts) margin are supported, selected by
16//! [`BinanceMarginConfig::is_isolated`].
17//!
18//! ## Cross vs. isolated
19//! Cross is account-wide: a single `userListenToken`, one WS-API subscription, and per-asset
20//! balances surfaced in the top-level `balances`. Isolated is per-pair: the caller declares the
21//! [`BinanceMarginConfig::isolated_symbols`] universe, each symbol gets its own `userListenToken`,
22//! and all N tokens are **multiplexed onto one WS-API socket**. Isolated per-pair balances and risk
23//! are attached per-instrument (never folded into the asset-keyed model, which cannot represent
24//! `(pair, asset)` pools). See [`BinanceMargin`] for the per-method contract.
25//!
26//! ## Borrow/repay
27//! Margin orders carry a `sideEffectType` controlling whether the venue borrows on entry and
28//! repays on close. This is configured once per client via [`MarginSideEffect`] (see its docs for
29//! the silent-borrow footgun under the default [`MarginSideEffect::AutoBorrowRepay`]).
30//!
31//! ## No testnet
32//! Binance margin/SAPI exists on **no** testnet — the SDK exposes only
33//! `MarginTradingRestApi::production`. A `testnet: true` config is therefore inert for margin and
34//! always resolves to production endpoints; the constructor logs a warning so this is observable
35//! rather than silent. See [`BinanceMarginConfig::testnet`].
36//!
37//! ## Known limitations
38//! - `balanceUpdate` events (deposits/withdrawals) are not forwarded. Callers reconcile balances
39//!   after external transfers via [`ExecutionClient::fetch_balances`] or
40//!   [`ExecutionClient::account_snapshot`].
41//! - `userLiabilityChange` is surfaced at INFO but never folded into balance state: borrow/repay
42//!   is a delta, and accumulating it would be the position tracking this library leaves to its
43//!   consumers.
44//! - A `TRADE` report whose order status (`X`) is neither `PARTIALLY_FILLED` nor `FILLED` emits
45//!   the execution but no order snapshot, so a fill arriving after its order's terminal report
46//!   cannot resurrect a retired order as a resting one. That order's filled quantity is settled
47//!   instead by the terminal report's own `z`, or by
48//!   [`ExecutionClient::fetch_open_orders`].
49
50use super::shared::{
51    AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
52    CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
53    MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing, RateLimitTracker, RequestKind,
54    SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, UnrecoveredFills, UserDataFrame, WeightPool,
55    classify_order_kind_tif, classify_rest_order_error, classify_rest_query_error,
56    convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
57    dedup_key_from_event, drop_after, gap_failed, gap_time, is_duplicate, log_unrecognised_frame,
58    new_dedup_cache, parse_user_data_frame, recovered_order_totals, response_decode_error,
59    rest_call_with_retry,
60};
61use crate::{
62    AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, InstrumentBalanceUpdate,
63    IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent, UnindexedAccountSnapshot,
64    balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
65    client::ExecutionClient,
66    emit_stream_terminated,
67    error::{
68        ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
69    },
70    order::{
71        Order, OrderKey, OrderKind, TimeInForce,
72        id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
73        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
74        state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
75    },
76    position::PositionReport,
77    trade::{AssetFees, Trade, TradeId},
78};
79use binance_sdk::{
80    common::{
81        config::{ConfigurationRestApi, ConfigurationWebsocketApi},
82        constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
83        models::WebsocketEvent,
84        websocket::{
85            SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
86            WebsocketMessageSendOptions,
87        },
88    },
89    margin_trading::{
90        MarginTradingRestApi,
91        rest_api::{
92            MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
93            MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
94            MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
95            MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
96            MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
97            QueryCrossMarginAccountDetailsResponseUserAssetsInner,
98            QueryIsolatedMarginAccountInfoParams,
99            QueryIsolatedMarginAccountInfoResponseAssetsInner,
100            QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
101            QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
102            QueryMarginAccountsTradeListResponseInner, RestApi,
103        },
104        websocket_streams::{
105            ExecutionReport, MarginLevelStatusChange, OutboundAccountPosition, UserLiabilityChange,
106        },
107    },
108};
109use chrono::{DateTime, TimeZone, Utc};
110use futures::stream::BoxStream;
111use rust_decimal::Decimal;
112use rustrade_instrument::{
113    Side,
114    asset::name::AssetNameExchange,
115    exchange::ExchangeId,
116    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
117};
118use serde::{Deserialize, Serialize};
119use smol_str::{SmolStr, format_smolstr};
120use std::{
121    collections::{BTreeMap, HashMap},
122    str::FromStr,
123    sync::{
124        Arc, Mutex,
125        atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
126    },
127    time::Duration,
128};
129use tokio::sync::{mpsc, oneshot};
130use tracing::{debug, error, info, trace, warn};
131
132// ---------------------------------------------------------------------------
133// Margin sideEffectType
134// ---------------------------------------------------------------------------
135
136/// Margin `sideEffectType` borrow/repay policy, fixed once per [`BinanceMargin`] client.
137///
138/// Only the two *intent-agnostic* modes are exposed as client-level variants. The borrow-vs-repay
139/// decision derives from open/close direction — position state the library deliberately does not
140/// track — so the venue is left to infer it from the account's loan state. The per-order modes
141/// (`MARGIN_BUY` / `AUTO_REPAY`) are intentionally **not** modelled here; they belong to a future
142/// generic per-order `RequestOpen::margin_effect` field that all margin adapters would map, added
143/// only when a venue with genuine per-order borrow intent (e.g. another CEX margin) appears.
144/// Mixed borrow appetite on a single venue today means running two clients (one per mode).
145///
146/// The `Serialize`/`Deserialize` wire form is the config-file value (`"auto_borrow_repay"` /
147/// `"no_borrow"`); this is distinct from the Binance API value returned by
148/// [`as_binance_str`](Self::as_binance_str) (`"AUTO_BORROW_REPAY"` / `"NO_SIDE_EFFECT"`).
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum MarginSideEffect {
152    /// `AUTO_BORROW_REPAY` — the venue borrows on entry and repays on close as needed.
153    ///
154    /// # Warning
155    /// This is the default because it makes shorting work out of the box, but it means a
156    /// **mis-sized order silently takes on debt**: an order larger than the free balance borrows
157    /// the shortfall without any further opt-in (same footgun class as `RequestOpen::reduce_only`).
158    /// Use [`MarginSideEffect::NoBorrow`] for a client that must never silently borrow.
159    #[default]
160    AutoBorrowRepay,
161    /// `NO_SIDE_EFFECT` — never borrow or repay; orders that would require borrowing are rejected
162    /// by the venue. The conservative opt-out from the [`AutoBorrowRepay`](Self::AutoBorrowRepay)
163    /// silent-borrow behaviour.
164    NoBorrow,
165}
166
167impl MarginSideEffect {
168    /// The Binance `sideEffectType` wire string for this policy.
169    pub fn as_binance_str(self) -> &'static str {
170        match self {
171            MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
172            MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
173        }
174    }
175
176    /// This policy as the margin SDK's `sideEffectType` parameter enum.
177    ///
178    /// The SDK also exposes `MARGIN_BUY` and `AUTO_REPAY` (the pre-`AUTO_BORROW_REPAY` split
179    /// primitives); rustrade deliberately models only the two composite policies, so those
180    /// variants are unreachable from here.
181    fn as_sdk_enum(self) -> MarginAccountNewOrderSideEffectTypeEnum {
182        match self {
183            MarginSideEffect::AutoBorrowRepay => {
184                MarginAccountNewOrderSideEffectTypeEnum::AutoBorrowRepay
185            }
186            MarginSideEffect::NoBorrow => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
187        }
188    }
189}
190
191// ---------------------------------------------------------------------------
192// Configuration
193// ---------------------------------------------------------------------------
194
195/// Configuration for the [`BinanceMargin`] execution client.
196///
197/// Mirrors [`BinanceSpotConfig`](super::spot::BinanceSpotConfig)'s credential handling (private
198/// keys, secret-redacting [`Debug`], `Deserialize`-only) and adds margin-specific knobs.
199// Serialize intentionally omitted — would expose secret_key in plaintext
200#[derive(Clone, Deserialize)]
201pub struct BinanceMarginConfig {
202    // not pub — prevents accidental credential exposure via struct access.
203    // Use BinanceMarginConfig::new() to construct, or deserialize from config file.
204    api_key: String,
205    secret_key: String,
206    /// Use testnet endpoints instead of production.
207    ///
208    /// **Inert for margin:** Binance margin/SAPI has no testnet, so this field is always treated
209    /// as production. Retained to mirror the spot config shape; [`BinanceMargin::new`] warns if it
210    /// is set to `true`.
211    pub testnet: bool,
212    /// Isolated margin (per-pair sub-accounts) when `true`; cross margin (account-wide) when
213    /// `false`.
214    ///
215    /// Defaults to `false` (cross). When `true`, [`isolated_symbols`](Self::isolated_symbols) must
216    /// be non-empty — it declares the pairs to snapshot/stream; [`BinanceMargin::new`] panics
217    /// otherwise.
218    #[serde(default)]
219    pub is_isolated: bool,
220    /// The isolated-margin pairs this client snapshots and streams when
221    /// [`is_isolated`](Self::is_isolated) is `true`.
222    ///
223    /// This is the authoritative symbol universe for the isolated per-symbol `userListenToken`
224    /// token/subscription fan-out: tokens are per-symbol and must be known at stream start, so the
225    /// set is fixed for the stream's lifetime. A pair activated *after* `account_stream` is called
226    /// is **not** auto-subscribed — reconfigure and restart the stream to pick it up.
227    ///
228    /// Ignored for cross margin (`is_isolated = false`). Empty by default; a `true` +
229    /// empty-set combination is a misconfiguration that panics in [`BinanceMargin::new`].
230    #[serde(default)]
231    pub isolated_symbols: Vec<InstrumentNameExchange>,
232    /// Borrow/repay policy applied to every order placed by this client.
233    ///
234    /// Defaults to [`MarginSideEffect::AutoBorrowRepay`] — see its `# Warning` on silent borrowing.
235    #[serde(default)]
236    pub side_effect: MarginSideEffect,
237}
238
239// custom Debug to avoid leaking credentials in logs
240impl std::fmt::Debug for BinanceMarginConfig {
241    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242        f.debug_struct("BinanceMarginConfig")
243            .field("api_key", &"***")
244            .field("secret_key", &"***")
245            .field("testnet", &self.testnet)
246            .field("is_isolated", &self.is_isolated)
247            .field("isolated_symbols", &self.isolated_symbols)
248            .field("side_effect", &self.side_effect)
249            .finish()
250    }
251}
252
253impl BinanceMarginConfig {
254    /// Construct a [`BinanceMarginConfig`] with explicit control over every field.
255    ///
256    /// Prefer [`BinanceMarginConfig::cross_margin`] / [`BinanceMarginConfig::isolated`] for the
257    /// common cases, or deserialize from a config file to keep credentials out of source. The
258    /// positional args expose every field for full control; defaultable knobs (`is_isolated`,
259    /// `isolated_symbols`, `side_effect`) also default via `#[serde(default)]` on the file path.
260    ///
261    /// Note: this does not itself validate `is_isolated` against `isolated_symbols`; that gate
262    /// lives in [`BinanceMargin::new`] (it must also cover the `Deserialize`-only path).
263    pub fn new(
264        api_key: String,
265        secret_key: String,
266        testnet: bool,
267        is_isolated: bool,
268        isolated_symbols: Vec<InstrumentNameExchange>,
269        side_effect: MarginSideEffect,
270    ) -> Self {
271        Self {
272            api_key,
273            secret_key,
274            testnet,
275            is_isolated,
276            isolated_symbols,
277            side_effect,
278        }
279    }
280
281    /// Convenience constructor for the common case: cross margin, production endpoints, and the
282    /// default [`MarginSideEffect::AutoBorrowRepay`] borrow/repay policy.
283    ///
284    /// Equivalent to [`new`](Self::new) with `testnet = false`, `is_isolated = false`, no
285    /// `isolated_symbols`, and the default `side_effect`. Use [`new`](Self::new) when any of those
286    /// need to differ.
287    pub fn cross_margin(api_key: String, secret_key: String) -> Self {
288        Self::new(
289            api_key,
290            secret_key,
291            false,
292            false,
293            Vec::new(),
294            MarginSideEffect::default(),
295        )
296    }
297
298    /// Convenience constructor for isolated margin: production endpoints, the default
299    /// [`MarginSideEffect::AutoBorrowRepay`] borrow/repay policy, and the given per-pair symbol
300    /// universe (see [`isolated_symbols`](Self::isolated_symbols)).
301    ///
302    /// `symbols` should be non-empty: an isolated client with no symbols has nothing to
303    /// snapshot or stream and [`BinanceMargin::new`] panics on it. Use [`new`](Self::new) to
304    /// override `side_effect`.
305    pub fn isolated(
306        api_key: String,
307        secret_key: String,
308        symbols: Vec<InstrumentNameExchange>,
309    ) -> Self {
310        Self::new(
311            api_key,
312            secret_key,
313            false,
314            true,
315            symbols,
316            MarginSideEffect::default(),
317        )
318    }
319
320    /// Read-only access to the API key (e.g. for logging or header construction).
321    pub fn api_key(&self) -> &str {
322        &self.api_key
323    }
324}
325
326// ---------------------------------------------------------------------------
327// BinanceMargin client
328// ---------------------------------------------------------------------------
329
330/// Binance Cross Margin execution client using the official binance-sdk.
331///
332/// Places orders and queries account state over the margin REST API
333/// (`margin_trading::rest_api`), and streams live account events via a hand-rolled
334/// `userListenToken` flow over the WS API, not the SDK's retired listen-key path.
335///
336/// See [`BinanceMarginConfig::testnet`] for the no-testnet caveat. The behaviour a caller most needs to know
337/// is summarised below, with links to the authoritative detail.
338///
339/// # Borrow/repay (`sideEffectType`)
340/// Fixed once per client via [`BinanceMarginConfig::side_effect`] / [`MarginSideEffect`]. The
341/// default [`MarginSideEffect::AutoBorrowRepay`] makes shorting work out of the box but lets a
342/// **mis-sized order silently borrow** — use [`MarginSideEffect::NoBorrow`] to opt out. Per-order
343/// borrow intent is intentionally not modelled (it would require position tracking); see
344/// [`MarginSideEffect`] for the rationale and upgrade path.
345///
346/// # Cross vs. isolated margin
347/// The mode is fixed per client via [`BinanceMarginConfig::is_isolated`]:
348/// - **Cross** (`is_isolated = false`, `isIsolated = "FALSE"`, account-wide collateral): per-asset
349///   balances (incl. debt) are surfaced account-wide in the top-level `balances`.
350/// - **Isolated** (`is_isolated = true`): per-pair sub-accounts. Balances + risk are attached
351///   **per-instrument** via [`InstrumentAccountSnapshot::isolated`] (the asset-keyed top-level
352///   `balances` is left empty, since `(pair, asset)` slots would collide), and per-symbol queries
353///   are scoped to the configured [`BinanceMarginConfig::isolated_symbols`]. See
354///   [`account_snapshot`](Self::account_snapshot) for the full per-method semantics.
355///
356/// # Trailing stops unsupported
357/// `TrailingStop` / `TrailingStopLimit` return [`OrderError::UnsupportedOrderType`]: the binance-sdk
358/// margin new-order binding omits `trailingDelta`. See [`open_order`](Self::open_order).
359///
360/// # User-data stream (`userListenToken`)
361/// [`account_stream`](Self::account_stream) is hand-rolled over the `userListenToken` model — the
362/// legacy margin listen-key user-data API was retired by Binance on 2026-02-20 and the SDK binds
363/// only the dead endpoint. There is **no keepalive ping** (the retired listen-key `PUT` mechanism):
364/// instead the token (~24h validity) is re-acquired and re-subscribed before its `expirationTime`,
365/// transparently across reconnects.
366///
367/// # Margin balances & debt-freshness
368/// Balances carry per-asset margin debt: [`Balance::net_asset`](crate::balance::Balance::net_asset)
369/// returns `total - borrowed`, with `borrowed`/`interest` exposed via
370/// [`MarginDetails`](crate::balance::MarginDetails). Authoritative debt totals come from the REST
371/// [`account_snapshot`](Self::account_snapshot) (`BalanceSnapshot`); the WS stream keeps
372/// `free`/`locked` live via `BalanceStreamUpdate` but **never** clobbers or re-establishes debt, and
373/// `userLiabilityChange` is surfaced as an observable log only, never accumulated into state.
374/// Consequently `net_asset` reflects debt only as fresh as the last `account_snapshot` for that
375/// asset — call it at startup and refresh on demand (see [`account_stream`](Self::account_stream)'s
376/// cold-start note).
377///
378/// # Rate limits
379/// Once a `/sapi` response reports at least 90% of the documented per-IP weight limit (12000 per
380/// minute) used, queries wait for the next minute, so the rest is left for orders and cancels,
381/// which never wait for it. A query, including a [`fetch_open_orders`](Self::fetch_open_orders)
382/// after a reconnect, can therefore take up to about a minute longer. A reconnect's fill recovery
383/// and its `userListenToken` request do not wait for it either, since a pause could outlast the
384/// recovery's 30 s and delay its fills to a retry. The per-UID `/sapi` limit is not tracked.
385/// After Binance answers with a rate-limit error, every REST call waits out a cooldown.
386///
387/// # One client per engine (`ExchangeId`)
388/// All emitted events — cross and isolated alike — are stamped [`ExchangeId::BinanceMargin`], and
389/// the engine routes `AssetStates` / `ConnectivityStates` by `ExchangeId`. Running **two**
390/// `BinanceMargin` clients in a single engine (e.g. one cross, one isolated) therefore collides
391/// their exchange identity. A single engine should run **at most one** `BinanceMargin` client; a
392/// consumer needing cross and isolated concurrently runs them in separate engines.
393#[derive(Clone)]
394pub struct BinanceMargin {
395    config: Arc<BinanceMarginConfig>,
396    // REST client for orders/queries (order submission/cancel, account snapshots, balances, trades).
397    rest: Arc<RestApi>,
398    // Production-configured REST config (credentials + base path) reused by the hand-rolled
399    // `userListenToken` POST, which goes through the SDK's `common::utils::send_request` (the SDK
400    // binds no endpoint for it) rather than the typed `RestApi` surface.
401    rest_config: Arc<ConfigurationRestApi>,
402    // WS-API configuration (credentials + ws_url) for the user-data stream. Consumed by
403    // `account_stream`, which constructs a `common::websocket::WebsocketApi` directly (the SDK's
404    // spot ws-api wrapper is unusable for margin — private base, no generic send/receive surface).
405    ws_config: ConfigurationWebsocketApi,
406    // shared rate-limit tracker across all REST calls
407    rate_limiter: Arc<RateLimitTracker>,
408}
409
410impl std::fmt::Debug for BinanceMargin {
411    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412        f.debug_struct("BinanceMargin")
413            .field("testnet", &self.config.testnet)
414            .field("is_isolated", &self.config.is_isolated)
415            .field("side_effect", &self.config.side_effect)
416            .finish_non_exhaustive()
417    }
418}
419
420impl BinanceMargin {
421    /// Build the production-configured margin REST configuration (credentials + base path).
422    ///
423    /// The base path is pinned to production (`https://api.binance.com`): margin/SAPI has no
424    /// testnet, and the config is reused both for the SDK `RestApi` and the hand-rolled
425    /// `userListenToken` POST (which joins `/sapi/v1/userListenToken` onto this base).
426    ///
427    /// # Panics
428    /// Panics if the binance-sdk configuration builder fails (invalid credentials format).
429    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
430    fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
431        ConfigurationRestApi::builder()
432            .api_key(config.api_key.clone())
433            .api_secret(config.secret_key.clone())
434            .base_path(MARGIN_TRADING_REST_API_PROD_URL)
435            .build()
436            .expect("failed to build Binance margin REST configuration")
437    }
438
439    /// Build the WS-API configuration for the `userListenToken` user-data stream.
440    ///
441    /// Captures the credentials and pins the WS-API endpoint (the same `wss://ws-api.binance.com`
442    /// endpoint spot uses). The connection itself is established by
443    /// [`account_stream`](crate::client::ExecutionClient::account_stream) later (it
444    /// constructs a `common::websocket::WebsocketApi` directly); this only prepares the config.
445    ///
446    /// # Panics
447    /// Panics if the binance-sdk configuration builder fails (invalid credentials format).
448    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
449    fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
450        ConfigurationWebsocketApi::builder()
451            .api_key(config.api_key.clone())
452            .api_secret(config.secret_key.clone())
453            // Margin user-data streams use the same `wss://ws-api.binance.com` endpoint as spot — the
454            // WS API is unified; only the REST base (SAPI) differs. The constant name reflects origin,
455            // not an accidental reuse of a spot-specific URL.
456            .ws_url(SPOT_WS_API_PROD_URL)
457            .build()
458            .expect("failed to build Binance margin WebSocket configuration")
459    }
460
461    /// Resolve the per-call effective isolated symbol set (isolated mode only).
462    ///
463    /// Isolated margin queries are per-symbol on the venue (there is no account-wide "no symbol =
464    /// all" affordance), so every isolated snapshot/query iterates an explicit symbol set:
465    /// - empty `instruments` (the "return all" sentinel) → the full configured
466    ///   [`isolated_symbols`](BinanceMarginConfig::isolated_symbols);
467    /// - non-empty → `instruments ∩ isolated_symbols`; any requested instrument NOT in
468    ///   `isolated_symbols` is skipped with a `warn!` (there is no configured isolated
469    ///   token/stream/snapshot for it — returning it would be a silent mismatch).
470    ///
471    /// Shared by `account_snapshot`, `fetch_open_orders`, and `fetch_trades` so their instrument,
472    /// open-order, and trade sets cannot drift apart.
473    fn effective_isolated_set(
474        &self,
475        instruments: &[InstrumentNameExchange],
476    ) -> Vec<InstrumentNameExchange> {
477        if instruments.is_empty() {
478            return self.config.isolated_symbols.clone();
479        }
480        instruments
481            .iter()
482            .filter(|inst| {
483                let in_set = self.config.isolated_symbols.contains(*inst);
484                if !in_set {
485                    warn!(
486                        instrument = %inst.name(),
487                        "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
488                    );
489                }
490                in_set
491            })
492            .cloned()
493            .collect()
494    }
495
496    /// Isolated-margin `account_snapshot`: per-pair balances + risk attached per-instrument, with
497    /// the asset-keyed top-level `balances` left empty. Open orders and
498    /// isolated balances cover the same effective isolated set so they cannot diverge.
499    async fn isolated_account_snapshot(
500        &self,
501        instruments: &[InstrumentNameExchange],
502    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
503        use futures::{StreamExt as _, TryStreamExt as _};
504
505        let effective = self.effective_isolated_set(instruments);
506        if effective.is_empty() {
507            // No configured isolated pair matched: a no-symbol isolated call is invalid, so there is
508            // nothing to query — return an empty snapshot rather than an account-wide one.
509            return Ok(AccountSnapshot::new(
510                ExchangeId::BinanceMargin,
511                Vec::new(),
512                Vec::new(),
513            ));
514        }
515
516        // Per-pair isolated balances + risk (chunked ≤5 symbols/request), keyed by instrument.
517        let mut balances_by_symbol = convert_isolated_margin_assets(
518            fetch_isolated_margin_account_info(
519                self.rest.clone(),
520                self.rate_limiter.clone(),
521                effective.clone(),
522            )
523            .await?,
524        );
525
526        // Open orders per instrument over the same effective set, concurrently (bounded to avoid
527        // bursting request-weight limits).
528        let instrument_snapshots: Vec<_> =
529            futures::stream::iter(effective.into_iter().map(|instrument| {
530                fetch_margin_open_orders_for_instrument(
531                    self.rest.clone(),
532                    self.rate_limiter.clone(),
533                    instrument,
534                    true,
535                )
536            }))
537            .buffer_unordered(8)
538            .map(|result| {
539                let (inst, listing) = result?;
540                let wrapped = listing
541                    .orders
542                    .into_iter()
543                    .map(active_order_snapshot)
544                    .collect();
545                let isolated = balances_by_symbol.remove(&inst);
546                if isolated.is_none() {
547                    // The same effective set drives both balances and open orders, so a miss here
548                    // means the venue's isolated-account response omitted (or mis-keyed) a requested
549                    // pair. Surface it rather than silently emitting `isolated: None`.
550                    warn!(
551                        instrument = %inst.name(),
552                        "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
553                    );
554                }
555                Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
556                    inst,
557                    wrapped,
558                    listing.complete,
559                    PositionReport::Unreported,
560                    isolated,
561                ))
562            })
563            .try_collect()
564            .await?;
565
566        // Isolated balances live on the instrument snapshots; the asset-keyed top-level vec stays
567        // empty so the engine's `update_from_account` never writes colliding per-asset slots.
568        Ok(AccountSnapshot::new(
569            ExchangeId::BinanceMargin,
570            Vec::new(),
571            instrument_snapshots,
572        ))
573    }
574}
575
576impl ExecutionClient for BinanceMargin {
577    const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
578
579    // Margin is leverage applied to spot pairs, not a distinct instrument kind: a cross or
580    // isolated margin order is still an order on a `Spot` symbol.
581    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
582        &[InstrumentKindDiscriminant::Spot];
583    type Config = BinanceMarginConfig;
584    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
585
586    /// Construct a `BinanceMargin` client from its configuration.
587    ///
588    /// # Panics
589    /// Panics if:
590    /// - the binance-sdk configuration builder fails (e.g. empty or malformed API key/secret),
591    ///   matching [`BinanceSpot`](super::spot::BinanceSpot)'s startup contract; or
592    /// - `is_isolated = true` but [`isolated_symbols`](BinanceMarginConfig::isolated_symbols) is
593    ///   empty — an isolated client with no configured pairs has nothing to snapshot or stream.
594    ///   This gate lives here (not only in [`BinanceMarginConfig::isolated`]) because the config is
595    ///   `Deserialize`-only and a deserialized config bypasses the named constructor. The
596    ///   `ExecutionClient::new` signature returns `Self`, not `Result`, so an unusable config is a
597    ///   fail-fast panic (consistent with the credential `expect`s above), not a recoverable error.
598    fn new(config: Self::Config) -> Self {
599        if config.testnet {
600            warn!(
601                "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
602                 using production endpoints"
603            );
604        }
605        assert!(
606            !(config.is_isolated && config.isolated_symbols.is_empty()),
607            "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
608        );
609        // Built once and shared: one clone is consumed by the SDK `RestApi`, the original is retained
610        // for the hand-rolled `userListenToken` POST via `send_request`. `ConfigurationRestApi: Clone`
611        // and is cheap to copy (its `reqwest::Client` is `Arc`-backed), avoiding redundant credential
612        // clones from building it twice.
613        let rest_config = Self::build_rest_config(&config);
614        let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
615        let rest_config = Arc::new(rest_config);
616        let ws_config = Self::build_ws_config(&config);
617        Self {
618            config: Arc::new(config),
619            rest,
620            rest_config,
621            ws_config,
622            rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
623        }
624    }
625
626    /// Submit a margin order over the SAPI `POST /sapi/v1/margin/order` endpoint.
627    ///
628    /// Mirrors [`BinanceSpot::open_order`](super::spot::BinanceSpot)'s contract: never returns
629    /// `None` (every failure is folded into the returned [`Order`]'s state as
630    /// [`OrderState::inactive`]), so the engine always sees a definitive outcome.
631    ///
632    /// Margin specifics:
633    /// - `sideEffectType` is the client-level [`MarginSideEffect`] (borrow/repay policy).
634    /// - `isIsolated` is config-driven (`"TRUE"` for isolated, `"FALSE"` for cross).
635    /// - `autoRepayAtCancel` is set only under [`MarginSideEffect::AutoBorrowRepay`]: a `NoBorrow`
636    ///   client takes no loan, so requesting repay-on-cancel would be incoherent.
637    /// - Trailing-stop kinds return [`OrderError::UnsupportedOrderType`] (the SDK omits
638    ///   `trailingDelta` on the margin binding).
639    async fn open_order(
640        &self,
641        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
642    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
643        let instrument = request.key.instrument.clone();
644        let side = request.state.side;
645        let price = request.state.price;
646        let quantity = request.state.quantity;
647        let kind = request.state.kind;
648        let time_in_force = request.state.time_in_force;
649        let cid = request.key.cid.clone();
650
651        let order_key = OrderKey::new(
652            ExchangeId::BinanceMargin,
653            instrument.clone(),
654            request.key.strategy.clone(),
655            cid.clone(),
656        );
657
658        // Build the returned Order with a given inactive (failure) state, preserving the request
659        // fields — keeps the many early-return error paths to one line each.
660        let inactive = |state: UnindexedOrderState| {
661            Some(Order {
662                key: order_key.clone(),
663                side,
664                price,
665                quantity,
666                kind,
667                time_in_force,
668                state,
669            })
670        };
671
672        let params = match build_new_order_params(
673            instrument.name().to_string(),
674            side,
675            price,
676            quantity,
677            kind,
678            time_in_force,
679            cid.0.to_string(),
680            self.config.side_effect,
681            self.config.is_isolated,
682        ) {
683            Ok(params) => params,
684            Err(BuildOrderError::Unsupported) => {
685                return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
686                    format!(
687                        "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
688                    ),
689                )));
690            }
691            Err(BuildOrderError::Build(msg)) => {
692                error!(%msg, "BinanceMargin failed to build new order params");
693                return inactive(OrderState::inactive(OrderError::Rejected(
694                    ApiError::OrderRejected(msg),
695                )));
696            }
697        };
698
699        let response =
700            match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
701                let params = params.clone();
702                Box::pin(async move { rest.margin_account_new_order(params).await })
703            })
704            .await
705            {
706                Ok(response) => response,
707                Err(e) => {
708                    return inactive(OrderState::inactive(classify_rest_order_error(
709                        &e,
710                        &instrument,
711                    )));
712                }
713            };
714
715        let data = match response.data().await {
716            Ok(data) => data,
717            Err(e) => {
718                // Deserialization failure on a 2xx response — surface as a rejection, not a
719                // transport error (the order did reach the venue).
720                return inactive(OrderState::inactive(OrderError::Rejected(
721                    ApiError::OrderRejected(e.to_string()),
722                )));
723            }
724        };
725
726        let time_exchange = data
727            .transact_time
728            .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
729            .unwrap_or_else(Utc::now);
730
731        let exchange_order_id = match data.order_id {
732            Some(id) => OrderId(format_smolstr!("{id}")),
733            None => {
734                error!("BinanceMargin open_order response missing orderId");
735                return inactive(OrderState::inactive(OrderError::Rejected(
736                    ApiError::OrderRejected("open_order response missing orderId".into()),
737                )));
738            }
739        };
740
741        let filled_qty = match data.executed_qty.as_deref() {
742            Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
743                // Present-but-unparseable executedQty is corrupt data, not an expected
744                // absence: silently defaulting to zero could misreport a filled order as
745                // Open. Surface it (mirrors margin_avg_price).
746                warn!(
747                    executed_qty = q,
748                    "BinanceMargin: failed to parse executedQty; treating as zero"
749                );
750                Decimal::ZERO
751            }),
752            None => {
753                // executedQty is expected on margin's REST order/cancel responses; its absence is
754                // anomalous (not the expected-empty case), so surface it rather than silently zero.
755                warn!("BinanceMargin: executedQty missing in response; treating as zero");
756                Decimal::ZERO
757            }
758        };
759
760        let state = if filled_qty >= quantity {
761            // Fully filled on placement — derive avg price from cumulative quote qty (margin's
762            // REST response exposes this; spot's WS response does not, hence spot passes None).
763            let avg_price = margin_avg_price(data.cummulative_quote_qty.as_deref(), filled_qty);
764            OrderState::fully_filled(Filled::new(
765                exchange_order_id,
766                time_exchange,
767                filled_qty,
768                avg_price,
769            ))
770        } else {
771            OrderState::active(Open::new(
772                VenueOrderId::Assigned(exchange_order_id),
773                time_exchange,
774                filled_qty,
775            ))
776        };
777
778        Some(Order {
779            key: order_key,
780            side,
781            price,
782            quantity,
783            kind,
784            time_in_force,
785            state,
786        })
787    }
788
789    /// Cancel a resting margin order via `DELETE /sapi/v1/margin/order`.
790    ///
791    /// Cancels by exchange `orderId` when present and parseable, otherwise by the originating
792    /// client order id. `isIsolated` is config-driven. Mirrors
793    /// [`BinanceSpot::cancel_order`](super::spot::BinanceSpot): every failure is folded into the
794    /// returned response's `state` as an `Err`.
795    ///
796    /// The margin cancel response carries no `transactTime`, so the cancellation timestamp is the
797    /// local receive time.
798    async fn cancel_order(
799        &self,
800        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
801    ) -> Option<UnindexedOrderResponseCancel> {
802        let instrument = request.key.instrument.clone();
803        let key = OrderKey {
804            exchange: request.key.exchange,
805            instrument: instrument.clone(),
806            strategy: request.key.strategy.clone(),
807            cid: request.key.cid.clone(),
808        };
809
810        let params = match build_cancel_order_params(
811            instrument.name().to_string(),
812            request.state.id.as_ref().and_then(VenueOrderId::assigned),
813            &request.key.cid,
814            self.config.is_isolated,
815        ) {
816            Ok(p) => p,
817            Err(e) => {
818                error!(%e, "BinanceMargin failed to build cancel order params");
819                return Some(UnindexedOrderResponseCancel {
820                    key,
821                    state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
822                });
823            }
824        };
825
826        let response =
827            match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
828                let params = params.clone();
829                Box::pin(async move { rest.margin_account_cancel_order(params).await })
830            })
831            .await
832            {
833                Ok(response) => response,
834                Err(e) => {
835                    return Some(UnindexedOrderResponseCancel {
836                        key,
837                        state: Err(classify_rest_order_error(&e, &instrument)),
838                    });
839                }
840            };
841
842        let data = match response.data().await {
843            Ok(data) => data,
844            Err(e) => {
845                // Deserialization failure on a 2xx response — surface as a rejection, not a
846                // transport error (the cancel request did reach the venue), mirroring open_order.
847                return Some(UnindexedOrderResponseCancel {
848                    key,
849                    state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
850                });
851            }
852        };
853
854        // Margin cancel response has no transactTime field — use local receive time.
855        let time_exchange = Utc::now();
856
857        let exchange_order_id = match data.order_id {
858            // NB: the margin cancel response types orderId as a String (the new-order response
859            // types it as i64) — use it verbatim.
860            Some(id) => OrderId(SmolStr::new(id)),
861            None => {
862                error!("BinanceMargin cancel response missing orderId");
863                return Some(UnindexedOrderResponseCancel {
864                    key,
865                    state: Err(OrderError::Rejected(ApiError::OrderRejected(
866                        "cancel response missing orderId".into(),
867                    ))),
868                });
869            }
870        };
871
872        let filled_qty = match data.executed_qty.as_deref() {
873            Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
874                // Present-but-unparseable executedQty is corrupt data, not an expected
875                // absence: silently defaulting to zero could misreport a filled order as
876                // Open. Surface it (mirrors margin_avg_price).
877                warn!(
878                    executed_qty = q,
879                    "BinanceMargin: failed to parse executedQty; treating as zero"
880                );
881                Decimal::ZERO
882            }),
883            None => {
884                // executedQty is expected on margin's REST order/cancel responses; its absence is
885                // anomalous (not the expected-empty case), so surface it rather than silently zero.
886                warn!("BinanceMargin: executedQty missing in response; treating as zero");
887                Decimal::ZERO
888            }
889        };
890
891        Some(UnindexedOrderResponseCancel {
892            key,
893            state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
894        })
895    }
896
897    /// Fetch a full margin account snapshot: balances plus open orders per instrument.
898    ///
899    /// **Cross** (`is_isolated = false`): account-wide per-asset balances from
900    /// `query_cross_margin_account_details` (carrying `borrowed`/`interest` → [`Balance::new_margin`])
901    /// in the top-level `balances`, plus open orders per requested instrument — mirroring
902    /// [`BinanceSpot::account_snapshot`](super::spot::BinanceSpot).
903    ///
904    /// **Isolated** (`is_isolated = true`): per-pair balances + risk from
905    /// `query_isolated_margin_account_info` (chunked ≤5 symbols/request), attached **per-instrument**
906    /// via [`InstrumentAccountSnapshot::isolated`] rather than folded into the asset-keyed
907    /// top-level `balances` (which is left **empty**) — isolated sub-accounts are per-`(pair, asset)`
908    /// and would collide in the asset-keyed model. The instrument set is the effective isolated set
909    /// (`isolated_symbols`, or `instruments ∩ isolated_symbols` with out-of-set instruments skipped);
910    /// each instrument's open orders and isolated balances are fetched over that same set.
911    async fn account_snapshot(
912        &self,
913        assets: &[AssetNameExchange],
914        instruments: &[InstrumentNameExchange],
915    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
916        if self.config.is_isolated {
917            return self.isolated_account_snapshot(instruments).await;
918        }
919        let response =
920            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
921                Box::pin(async move {
922                    let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
923                    rest.query_cross_margin_account_details(params).await
924                })
925            })
926            .await
927            .map_err(|e| classify_rest_query_error(&e, None))?;
928
929        let account = response.data().await.map_err(response_decode_error)?;
930
931        let balances =
932            filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
933
934        // Fetch open orders for all instruments concurrently (with retry), limiting concurrency
935        // to avoid bursting Binance's request weight limits. account_snapshot wraps Open orders
936        // in OrderState::active(); fetch_open_orders returns them without the wrapper — both use
937        // fetch_margin_open_orders_for_instrument.
938        use futures::{StreamExt as _, TryStreamExt as _};
939        let instrument_snapshots: Vec<_> =
940            futures::stream::iter(instruments.iter().cloned().map(|instrument| {
941                fetch_margin_open_orders_for_instrument(
942                    self.rest.clone(),
943                    self.rate_limiter.clone(),
944                    instrument,
945                    // Cross path only: isolated returns early via `isolated_account_snapshot`.
946                    false,
947                )
948            }))
949            .buffer_unordered(8)
950            .map(|result| {
951                let (inst, listing) = result?;
952                let wrapped = listing
953                    .orders
954                    .into_iter()
955                    .map(active_order_snapshot)
956                    .collect();
957                Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
958                    inst,
959                    wrapped,
960                    listing.complete,
961                    PositionReport::Unreported,
962                    None,
963                ))
964            })
965            .try_collect()
966            .await?;
967
968        Ok(AccountSnapshot::new(
969            ExchangeId::BinanceMargin,
970            balances,
971            instrument_snapshots,
972        ))
973    }
974
975    /// Fetch current margin balances (incl. `borrowed`/`interest` debt) for the requested assets.
976    ///
977    /// **Cross**: account-wide per-asset balances; an empty `assets` slice is the "return all"
978    /// sentinel.
979    ///
980    /// **Isolated**: returns an empty `Vec`. Isolated balances are per-`(pair, asset)` and the
981    /// asset-keyed return type cannot carry them without collision — they are surfaced per-instrument
982    /// via [`account_snapshot`](Self::account_snapshot)'s [`InstrumentAccountSnapshot::isolated`]
983    /// instead.
984    async fn fetch_balances(
985        &self,
986        assets: &[AssetNameExchange],
987    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
988        if self.config.is_isolated {
989            return Ok(Vec::new());
990        }
991        let response =
992            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
993                Box::pin(async move {
994                    let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
995                    rest.query_cross_margin_account_details(params).await
996                })
997            })
998            .await
999            .map_err(|e| classify_rest_query_error(&e, None))?;
1000
1001        let account = response.data().await.map_err(response_decode_error)?;
1002
1003        Ok(filter_and_convert_margin_balances(
1004            account.user_assets.unwrap_or_default(),
1005            assets,
1006        ))
1007    }
1008
1009    /// Fetch currently open margin orders, optionally filtered by instrument.
1010    ///
1011    /// **Cross**: honours the `ExecutionClient::fetch_open_orders` "return all" sentinel — an empty
1012    /// `instruments` slice is served by a single no-symbol `query_margin_accounts_open_orders` call
1013    /// (each order's instrument taken from its own `symbol`); a non-empty slice fetches the listed
1014    /// instruments concurrently, per-symbol.
1015    ///
1016    /// **Isolated**: per-symbol on the venue — always iterates the effective isolated set (empty
1017    /// `instruments` → configured `isolated_symbols`; out-of-set instruments skipped with a warning);
1018    /// never issues a no-symbol isolated call.
1019    async fn fetch_open_orders(
1020        &self,
1021        instruments: &[InstrumentNameExchange],
1022    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1023        use futures::{StreamExt as _, TryStreamExt as _};
1024
1025        // Isolated: per-symbol on the venue — always iterate the effective set (never a no-symbol
1026        // isolated call, which would error). The empty-`instruments` sentinel resolves to the
1027        // configured isolated_symbols.
1028        if self.config.is_isolated {
1029            let effective = self.effective_isolated_set(instruments);
1030            let capacity = effective.len();
1031            return futures::stream::iter(effective.into_iter().map(|instrument| {
1032                fetch_margin_open_orders_for_instrument(
1033                    self.rest.clone(),
1034                    self.rate_limiter.clone(),
1035                    instrument,
1036                    true,
1037                )
1038            }))
1039            .buffer_unordered(8)
1040            .try_fold(
1041                Vec::with_capacity(capacity),
1042                |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1043                 (_, listing)| async move {
1044                    acc.extend(listing.orders);
1045                    Ok(acc)
1046                },
1047            )
1048            .await;
1049        }
1050
1051        // Cross: empty slice = "return all" sentinel — a single no-symbol query is both correct
1052        // (the contract requires all instruments) and far cheaper than enumerating every symbol.
1053        if instruments.is_empty() {
1054            return fetch_margin_all_open_orders(
1055                self.rest.clone(),
1056                self.rate_limiter.clone(),
1057                false,
1058            )
1059            .await;
1060        }
1061        futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1062            fetch_margin_open_orders_for_instrument(
1063                self.rest.clone(),
1064                self.rate_limiter.clone(),
1065                instrument,
1066                false,
1067            )
1068        }))
1069        .buffer_unordered(8)
1070        .try_fold(
1071            Vec::with_capacity(instruments.len()),
1072            |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1073                acc.extend(listing.orders);
1074                Ok(acc)
1075            },
1076        )
1077        .await
1078    }
1079
1080    /// Fetch margin trades (fills) since `time_since`, optionally filtered by instrument.
1081    ///
1082    /// **Documented deviation from the `ExecutionClient::fetch_trades` "return all" contract:**
1083    /// Binance's margin trade-list endpoint (`myTrades`) requires a symbol — there is no no-symbol
1084    /// "all trades" query (unlike open orders).
1085    ///
1086    /// **Cross**: an empty `instruments` slice has nothing to query and returns an empty `Vec`;
1087    /// callers wanting all trades must enumerate instruments explicitly.
1088    ///
1089    /// **Isolated**: the empty sentinel resolves to the configured `isolated_symbols` (the effective
1090    /// isolated set; out-of-set instruments skipped with a warning), iterated per-symbol (Design
1091    /// decision #4).
1092    ///
1093    /// **Lookback cost:** Binance serves margin trades by time only in spans under 24 hours, so
1094    /// each instrument is read in 23-hour windows from `time_since` until its first trade, one
1095    /// request (weight 10) per window, then by trade id. The cost grows with the lookback and
1096    /// has no cap: a `time_since` 30 days back with no trades costs about 32 requests per
1097    /// instrument, and one at the Unix epoch about 21,000. Pass the most recent `time_since` that
1098    /// covers what you need.
1099    ///
1100    /// **Upper bound:** trades are read up to the local clock at the call. A trade stamped later,
1101    /// including one inside any skew between the local clock and Binance's, is left to the next
1102    /// read, unless a window before the last one held a trade (or the last one read a full page)
1103    /// and the walk went on to page by id, which has no time bound.
1104    async fn fetch_trades(
1105        &self,
1106        time_since: DateTime<Utc>,
1107        instruments: &[InstrumentNameExchange],
1108    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1109        use futures::StreamExt as _;
1110
1111        // Resolve the effective instrument set. Isolated resolves the empty sentinel to the
1112        // configured isolated_symbols; cross keeps the slice verbatim (no no-symbol "all" form).
1113        let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1114            self.effective_isolated_set(instruments)
1115        } else {
1116            instruments.to_vec()
1117        };
1118        if effective.is_empty() {
1119            // Distinguish the two empty causes: cross with an empty `instruments` slice (the
1120            // documented no-op sentinel) vs. isolated with no configured `isolated_symbols` matched.
1121            debug!(
1122                is_isolated = self.config.is_isolated,
1123                "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1124            );
1125            return Ok(Vec::new());
1126        }
1127        let start_time_ms = time_since.timestamp_millis();
1128        let is_isolated = self.config.is_isolated;
1129        let mut all_trades = Vec::new();
1130
1131        // Binance requires per-symbol queries for trade history. Limit concurrency to avoid
1132        // bursting Binance's request weight limits.
1133        let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1134            let rest = self.rest.clone();
1135            let rate_limiter = self.rate_limiter.clone();
1136            async move {
1137                let pages = paginate_margin_my_trades(
1138                    &rest,
1139                    &rate_limiter,
1140                    &inst,
1141                    MyTradesFrom::Time(start_time_ms),
1142                    is_isolated,
1143                    RequestKind::Query,
1144                )
1145                .await?;
1146                Ok::<_, UnindexedClientError>((inst, pages))
1147            }
1148        }))
1149        .buffer_unordered(8);
1150        while let Some(result) = stream.next().await {
1151            let (instrument, trades_data) = result?;
1152            for t in trades_data {
1153                if let Some(trade) = convert_margin_trade(&t, &instrument) {
1154                    all_trades.push(trade);
1155                }
1156            }
1157        }
1158
1159        Ok(all_trades)
1160    }
1161
1162    /// Live stream of account events (fills, order updates, balance changes) over the hand-rolled
1163    /// `userListenToken` user-data stream.
1164    ///
1165    /// Acquires a `userListenToken` (signed `POST /sapi/v1/userListenToken`, cross = no params),
1166    /// subscribes over the WS API (`userDataStream.subscribe.listenToken`), and keeps the stream
1167    /// live with auto-reconnect, exponential backoff, heartbeat monitoring, and fill recovery
1168    /// (mirroring [`BinanceSpot`](super::spot::BinanceSpot)). The token is re-acquired and
1169    /// re-subscribed before its ~24h expiry — there is **no** listen-key keepalive (that API is
1170    /// retired).
1171    ///
1172    /// As on spot, each instrument's gap after a reconnect is kept until its fills are forwarded:
1173    /// a gap whose read failed or timed out is retried after 1, 2, 4, 8 and 16 minutes, connected
1174    /// or not in between, reading only the gap. After five failed retries it is given up, logged
1175    /// at `error`; [`ExecutionClient::fetch_trades`] can still read its fills.
1176    ///
1177    /// # Debt cold-start
1178    /// This method does **not** seed balances. Margin debt (`borrowed`/`interest`) is correct only
1179    /// if the caller invokes [`ExecutionClient::account_snapshot`] at startup (the `BalanceSnapshot`
1180    /// that populates `margin`). WS thereafter keeps `free`/`locked` live via `BalanceStreamUpdate`
1181    /// but never re-establishes debt; `userLiabilityChange` is logged observably, not applied to
1182    /// balance state.
1183    ///
1184    /// # Startup race window
1185    /// Like spot, fills arriving between subscribe and the listener being registered may be missed;
1186    /// callers requiring startup fill completeness must call [`ExecutionClient::fetch_trades`] with
1187    /// a ~1s lookback after this returns. Callers must also call
1188    /// [`ExecutionClient::fetch_open_orders`] after each reconnect to reconcile order state — only
1189    /// TRADE fills are recovered, not order-lifecycle events.
1190    ///
1191    /// A recovered fill advances the order too. A live fill carries the order's cumulative filled
1192    /// quantity in [`Trade::order_filled_quantity`] (`executionReport`'s `z`); REST `myTrades`
1193    /// carries none, so recovery rebuilds the same figure by reading each recovered order's
1194    /// executions from its first: one extra request per order (another per further 1,000
1195    /// executions), up to four orders at a time per instrument. Those lookups have their own
1196    /// time budget inside the recovery timeout, so they can never cost a fill: a fill whose order
1197    /// was not looked up in time, or whose lookup failed, goes out with `order_filled_quantity:
1198    /// None`, logged at `warn`, and advances the position but not the order.
1199    ///
1200    /// # Isolated mode (multiplexed `userListenToken`)
1201    /// Under `is_isolated = true` a **separate** manager drives the stream (the cross path above is
1202    /// left untouched). It acquires one per-symbol `userListenToken` for each
1203    /// [`BinanceMarginConfig::isolated_symbols`] entry and multiplexes **all N subscriptions onto a
1204    /// single WS-API socket**. Every token is acquired, the socket connected, and all subscriptions
1205    /// confirmed **before this method returns** — if the connect or **any** subscribe fails, it
1206    /// returns `Err` with nothing spawned (preserving the "can't-start vs started-then-dropped"
1207    /// distinction). The `instruments` argument does **not** drive the isolated token set: the stream
1208    /// always covers exactly `isolated_symbols`.
1209    ///
1210    /// ## Live per-pair balances — [`InstrumentBalanceUpdate`](crate::AccountEventKind::InstrumentBalanceUpdate)
1211    /// The isolated stream delivers live fills and order updates (routed by the inner `symbol`) **and**
1212    /// live per-pair `free`/`locked` balances, emitted as
1213    /// [`AccountEventKind::InstrumentBalanceUpdate`]
1214    /// (base + quote per pair). Debt totals (`borrowed`/`interest`) stay REST-`BalanceSnapshot`-fresh
1215    /// per the debt-freshness contract; the stream keeps only `free`/`locked` live.
1216    ///
1217    /// **Consumption contract (important):** the engine deliberately does **not** store
1218    /// `InstrumentBalanceUpdate` — it falls through `update_from_account`'s `_ => None` wildcard,
1219    /// mirroring the snapshot's
1220    /// [`InstrumentAccountSnapshot::isolated`](crate::InstrumentAccountSnapshot::isolated). It is
1221    /// therefore **never in `EngineState`**, and a `StateReplicaManager` replica replays the same
1222    /// wildcard so it is **not** in replica state either. A consumer obtains live per-pair balances
1223    /// **only** by inspecting the raw account event off the stream (`AccountStreamEvent::Item(..)` /
1224    /// `audit_tick.event`) and matching the variant itself — a consumer reading solely replica
1225    /// `EngineState` will see nothing and wrongly conclude the feature is broken. A wrapper wanting
1226    /// engine-queryable per-instrument balances uses the `InstrumentData` extension point instead.
1227    ///
1228    /// ## First-prod-run check — `subscriptionId → symbol` routing
1229    /// `outboundAccountPosition` frames carry no symbol inline; on the multiplexed socket their pair
1230    /// is recovered via a `subscriptionId → symbol` map. This correlation is the one assumption no
1231    /// offline source could validate — **verify it on the first production run**. Fills and order
1232    /// updates are unaffected (they self-identify by `symbol`); if the correlation does not hold,
1233    /// per-pair balance frames are dropped with a `warn!` (never mis-applied) and balances degrade to
1234    /// snapshot-polling. The documented fallback is one socket per isolated symbol (symbol implied by
1235    /// the connection).
1236    async fn account_stream(
1237        &self,
1238        // _assets is intentionally ignored — Binance pushes outboundAccountPosition for all account
1239        // assets regardless of any filter (mirrors spot). See account_snapshot for filtering.
1240        _assets: &[AssetNameExchange],
1241        instruments: &[InstrumentNameExchange],
1242    ) -> Result<Self::AccountStream, UnindexedClientError> {
1243        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1244        let dedup = new_dedup_cache();
1245        let rest = self.rest.clone();
1246        let rest_config = self.rest_config.clone();
1247        let ws_config = self.ws_config.clone();
1248        let rate_limiter = self.rate_limiter.clone();
1249
1250        let cm_handle = if self.config.is_isolated {
1251            // --- Isolated: separate multiplexed manager over the configured isolated_symbols ---
1252            // The stream covers exactly `isolated_symbols` (the per-symbol token universe — Design
1253            // decision #1); the trait's `instruments` argument does NOT drive the isolated token set.
1254            let symbols = self.config.isolated_symbols.clone();
1255            // All current Binance margin symbols are ≤22 bytes (within SmolStr's 23-byte inline
1256            // limit); guard the implicit invariant so an over-long symbol is caught early.
1257            debug_assert!(
1258                symbols.iter().all(|i| i.name().len() <= 23),
1259                "instrument name exceeds SmolStr inline capacity: {:?}",
1260                symbols.iter().find(|i| i.name().len() > 23)
1261            );
1262
1263            // (0) Build the invariant `instrument → (base, quote)` map from the isolated account
1264            // info. Authoritative base/quote split for routing the symbol-less
1265            // `outboundAccountPosition` frames; built once, reused across
1266            // reconnects (a pair's base/quote never change). A REST failure here is a hard Err
1267            // before anything spawns.
1268            let assets = fetch_isolated_margin_account_info(
1269                rest.clone(),
1270                rate_limiter.clone(),
1271                symbols.clone(),
1272            )
1273            .await?;
1274            let base_quote = Arc::new(build_base_quote_map(&assets));
1275            for sym in &symbols {
1276                if !base_quote.contains_key(sym) {
1277                    warn!(
1278                        symbol = %sym.name(),
1279                        "BinanceMargin isolated: account info returned no base/quote for configured \
1280                         symbol — its live per-pair balance frames will be dropped (fills/orders \
1281                         unaffected; balances available via account_snapshot)"
1282                    );
1283                }
1284            }
1285
1286            // (1) Acquire all N per-symbol tokens, (2) connect one socket, (3) subscribe all N —
1287            // all before returning so connect-or-any-subscribe failure → Err (nothing spawned),
1288            // preserving cross's "can't-start vs started-then-dropped" distinction.
1289            let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1290            let live =
1291                isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
1292                    .await
1293                    .map_err(|e| {
1294                        UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1295                    })?;
1296
1297            tokio::spawn(isolated_connection_manager(
1298                tx,
1299                dedup,
1300                ws_config,
1301                rest_config,
1302                rest,
1303                rate_limiter,
1304                symbols,
1305                base_quote,
1306                Some(live),
1307            ))
1308        } else {
1309            // --- Cross: the live-validated account-wide manager (unchanged by isolated) ---
1310            let instruments = instruments.to_vec();
1311            // All current Binance margin symbols are ≤22 bytes (within SmolStr's 23-byte inline
1312            // limit), making clone() a stack memcpy with no heap allocation. Guard this implicit
1313            // invariant so future symbols that exceed the limit are caught early.
1314            debug_assert!(
1315                instruments.iter().all(|i| i.name().len() <= 23),
1316                "instrument name exceeds SmolStr inline capacity: {:?}",
1317                instruments.iter().find(|i| i.name().len() > 23)
1318            );
1319
1320            // Validate credentials + connectivity before returning the stream so the caller can
1321            // distinguish "can't start at all" from "started then disconnected" (auto-reconnect
1322            // handles the latter). The token POST is a signed REST round-trip; the WS connect proves
1323            // the socket. Subscription happens inside the manager (after the event listener
1324            // attaches), so early pushed events are not missed.
1325            let initial_token =
1326                acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1327            let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1328                UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1329            })?;
1330
1331            tokio::spawn(margin_connection_manager(
1332                tx,
1333                dedup,
1334                ws_config,
1335                rest_config,
1336                rest,
1337                rate_limiter,
1338                instruments,
1339                Some((initial_ws, initial_token)),
1340            ))
1341        };
1342
1343        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1344        let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1345        Ok(futures::StreamExt::boxed(guarded_stream))
1346    }
1347}
1348
1349// ---------------------------------------------------------------------------
1350// User-data stream (hand-rolled userListenToken)
1351// ---------------------------------------------------------------------------
1352
1353/// Safety margin subtracted from a token's `expirationTime` so renewal happens before it expires.
1354const TOKEN_RENEW_MARGIN_SECS: i64 = 300; // 5 min
1355/// Floor for the post-acquire renewal wait, so a near-expired/odd token still yields a sane wait.
1356const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1357
1358/// A `userListenToken` and its expiry (epoch ms), from `POST /sapi/v1/userListenToken`.
1359struct UserListenToken {
1360    token: String,
1361    expiration_time_ms: i64,
1362}
1363
1364/// Build the query params for the `userListenToken` POST: empty for **cross**, or
1365/// `isIsolated=TRUE&symbol=SYM` for **isolated** (a per-symbol token). Factored out so the
1366/// cross-vs-isolated param shape is unit-testable without a network round-trip.
1367fn build_listen_token_query(
1368    symbol: Option<&InstrumentNameExchange>,
1369) -> BTreeMap<String, serde_json::Value> {
1370    let mut query = BTreeMap::new();
1371    if let Some(sym) = symbol {
1372        query.insert(
1373            "isIsolated".to_string(),
1374            serde_json::Value::String("TRUE".to_string()),
1375        );
1376        query.insert(
1377            "symbol".to_string(),
1378            serde_json::Value::String(sym.name().to_string()),
1379        );
1380    }
1381    query
1382}
1383
1384/// Wire shape of the `POST /sapi/v1/userListenToken` response.
1385#[derive(Deserialize)]
1386struct UserListenTokenResponse {
1387    // The live endpoint was observed returning `token`; accept `listenToken` defensively too.
1388    #[serde(alias = "listenToken")]
1389    token: String,
1390    #[serde(rename = "expirationTime")]
1391    expiration_time: i64,
1392}
1393
1394/// Acquire a `userListenToken` via the SDK's generic signed sender.
1395///
1396/// The endpoint is unbound in the SDK, so this reuses `common::utils::send_request` (signing,
1397/// timestamp, `X-MBX-APIKEY`, and the SDK's reqwest client) against the production REST config.
1398/// `symbol` selects the scope:
1399/// - `None` → **cross** margin (account-wide), no `isIsolated`/`symbol` params;
1400/// - `Some(sym)` → **isolated** margin, scoped to that pair (`isIsolated=TRUE&symbol=SYM`). Each
1401///   isolated symbol gets its own per-symbol token (the isolated stream multiplexes N of them).
1402///
1403/// Routed through [`rest_call_with_retry`] so a transient rate-limit during a *planned* token
1404/// renewal retries in place rather than collapsing the stream into the full reconnect+backoff
1405/// path. `ConfigurationRestApi` stands in as the retry helper's `R` (it only ever hands back an
1406/// `Arc` clone to the per-attempt closure). It is [`RequestKind::Essential`]: the stream cannot
1407/// resume without a token, and every second it waits widens the gap fill recovery must cover.
1408async fn acquire_user_listen_token(
1409    rest_config: &Arc<ConfigurationRestApi>,
1410    rate_limiter: &RateLimitTracker,
1411    symbol: Option<&InstrumentNameExchange>,
1412) -> Result<UserListenToken, UnindexedClientError> {
1413    // Cross sends no params; isolated scopes the token to one symbol. Built once and cloned per
1414    // retry attempt (the closure runs per attempt; mirrors `fetch_isolated_margin_account_info`).
1415    let query = build_listen_token_query(symbol);
1416    let response = rest_call_with_retry(rest_config, rate_limiter, RequestKind::Essential, |cfg| {
1417        let query = query.clone();
1418        Box::pin(async move {
1419            binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1420                &cfg,
1421                "/sapi/v1/userListenToken",
1422                reqwest::Method::POST,
1423                // `send_request` takes owned query + body maps; params (if any) ride the query
1424                // string the signature is computed over, the body stays empty.
1425                query,
1426                BTreeMap::new(),
1427                None,
1428                true, // is_signed — token is auth-bearing; HMAC/timestamp/api-key by send_request
1429            )
1430            .await
1431        })
1432    })
1433    .await
1434    .map_err(|e| classify_rest_query_error(&e, symbol))?;
1435
1436    let data = response.data().await.map_err(response_decode_error)?;
1437    Ok(UserListenToken {
1438        token: data.token,
1439        expiration_time_ms: data.expiration_time,
1440    })
1441}
1442
1443/// How long to wait before renewing, from a token's `expirationTime` (epoch ms), with a safety
1444/// margin and a floor so a near-expired token still yields a sane (non-zero) wait.
1445fn token_renew_after(expiration_time_ms: i64) -> Duration {
1446    let now_ms = Utc::now().timestamp_millis();
1447    // Saturating arithmetic so a malformed/huge `expirationTime` can't overflow i64 (debug panic /
1448    // release wrap); the `/ 1_000` is a truncating ms→s conversion. try_from then maps any negative
1449    // (already-expired/odd) result to 0.
1450    let remaining_secs = expiration_time_ms
1451        .saturating_sub(now_ms)
1452        .saturating_div(1_000)
1453        .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1454    let secs = u64::try_from(remaining_secs).unwrap_or(0);
1455    // The floor guarantees a sane, non-zero wait; warn when it kicks in so a degraded renewal cadence
1456    // (near-expired or odd token) is observable rather than silent.
1457    if secs < TOKEN_MIN_LIFETIME_SECS {
1458        warn!(
1459            computed_secs = secs,
1460            floor_secs = TOKEN_MIN_LIFETIME_SECS,
1461            "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1462        );
1463    }
1464    Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1465}
1466
1467/// Connect a directly-constructed common-layer WS-API connection (no subscription yet).
1468///
1469/// The SDK's spot ws-api wrapper can't be reused for margin (private base, no generic
1470/// send/receive surface), so margin drives `common::websocket::WebsocketApi` directly. An empty
1471/// pool is auto-populated by the SDK. The handshake is bounded by `CONNECT_TIMEOUT_SECS` explicitly
1472/// rather than relying on the SDK's internal timeout (an unstable, version-pinned detail).
1473async fn connect_margin_ws(
1474    ws_config: &ConfigurationWebsocketApi,
1475) -> anyhow::Result<Arc<WsApiBase>> {
1476    let ws = WsApiBase::new(ws_config.clone(), vec![]);
1477    // Mirrors BinanceSpot's reconnect path: a stalled connect must not block the connection
1478    // manager for an OS-length TCP timeout if the SDK's internal bound ever changes.
1479    tokio::time::timeout(
1480        Duration::from_secs(CONNECT_TIMEOUT_SECS),
1481        ws.clone().connect(),
1482    )
1483    .await
1484    .map_err(|_| {
1485        anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1486    })??;
1487    Ok(ws)
1488}
1489
1490/// Subscribe to the user-data stream over an existing WS-API connection.
1491///
1492/// `userDataStream.subscribe.listenToken` is unbound in the SDK, so it is sent via the generic
1493/// `send_message` with the literal method string. The frame is **unsigned with no API key** — the
1494/// token is the sole auth (verified live); `WebsocketMessageSendOptions::new()` yields
1495/// exactly that (no `.signed()`/`.with_api_key()`, no session logon).
1496async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1497    // The SDK's `send_message` takes an owned `BTreeMap<String, Value>`, so the single-entry map and
1498    // its key/value allocations are unavoidable here. Runs once per (re)connection, not per event.
1499    let mut payload = BTreeMap::new();
1500    payload.insert(
1501        "listenToken".to_string(),
1502        serde_json::Value::String(token.to_string()),
1503    );
1504    ws.send_message::<serde_json::Value>(
1505        "userDataStream.subscribe.listenToken",
1506        payload,
1507        WebsocketMessageSendOptions::new(),
1508    )
1509    .await
1510    .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1511    Ok(())
1512}
1513
1514/// Subscribe over an existing WS-API connection and return the ack's `subscriptionId` (isolated).
1515///
1516/// Identical wire frame to [`subscribe_listen_token`] (unsigned, token-as-auth) but deserializes the
1517/// ack's `result.subscriptionId` so the isolated manager can build the `subscriptionId → symbol`
1518/// routing map (the symbol-less `outboundAccountPosition` frames carry only this id — Design
1519/// decision #5). Returns:
1520/// - `Ok(Some(id))` — subscribed; the id correlates this symbol's pushed balance frames;
1521/// - `Ok(None)` — subscribed, but the ack carried no `subscriptionId` (per-instrument balance
1522///   routing for this pair is then unavailable: its `outboundAccountPosition` frames drop with a
1523///   `warn!` and the consumer falls back to snapshot-polled balances — fills/orders are unaffected,
1524///   they self-identify by inner `s`). This is the documented first-prod-run degradation path.
1525/// - `Err(..)` — the subscribe itself failed (a startup/reconnect error, surfaced by the caller).
1526async fn subscribe_listen_token_capture(
1527    ws: &Arc<WsApiBase>,
1528    token: &str,
1529) -> anyhow::Result<Option<i64>> {
1530    /// Ack `result` shape — `subscriptionId` is read defensively as an `Option` (its presence on the
1531    /// `.listenToken` ack is the one assumption no offline source closes; absent → `Ok(None)`).
1532    #[derive(Deserialize)]
1533    struct SubscribeAck {
1534        #[serde(rename = "subscriptionId", default)]
1535        subscription_id: Option<i64>,
1536    }
1537
1538    let mut payload = BTreeMap::new();
1539    payload.insert(
1540        "listenToken".to_string(),
1541        serde_json::Value::String(token.to_string()),
1542    );
1543    let result = ws
1544        .send_message::<SubscribeAck>(
1545            "userDataStream.subscribe.listenToken",
1546            payload,
1547            WebsocketMessageSendOptions::new(),
1548        )
1549        .await
1550        .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1551    let ack = match result {
1552        SendWebsocketMessageResult::Single(resp) => resp.data()?,
1553        // The subscribe is a single request; defensively take the first if the SDK ever batches.
1554        SendWebsocketMessageResult::Multiple(responses) => responses
1555            .into_iter()
1556            .next()
1557            .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
1558            .data()?,
1559    };
1560    Ok(ack.subscription_id)
1561}
1562
1563/// Cross-margin balance-arm handler: convert an `outboundAccountPosition` into asset-keyed
1564/// `BalanceStreamUpdate`s (the `subscriptionId` is unused — cross is account-wide). This is the
1565/// `handle_position` passed to [`convert_margin_user_data_events_with`] / the cross manager.
1566fn cross_account_position_handler(
1567    position: OutboundAccountPosition,
1568    _subscription_id: Option<i64>,
1569    buf: &mut Vec<UnindexedAccountEvent>,
1570) {
1571    convert_margin_account_position(position, buf);
1572}
1573
1574/// Cross-margin convenience wrapper over [`convert_margin_user_data_events_with`] using
1575/// [`cross_account_position_handler`] (account-wide `BalanceStreamUpdate`s). Test-only: the cross
1576/// manager calls the listener helper with the handler directly, but the converter unit tests exercise
1577/// frame discrimination through this two-arg form (they do not route balances per-instrument).
1578#[cfg(test)]
1579fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1580    convert_margin_user_data_events_with(frame, buf, &mut cross_account_position_handler)
1581}
1582
1583/// Frame discrimination for the WS-API user-data delivery, as observed live.
1584///
1585/// Each text frame is either an RPC response (`{ "id", "status", "result", … }` — e.g. the
1586/// subscribe ack) or a pushed user-data event wrapped as `{ "subscriptionId", "event": { "e", … } }`.
1587/// Returns `true` if the exchange signalled stream termination (a reconnect trigger). Unrecognised
1588/// frames are ignored and logged by [`log_unrecognised_frame`] (throttled `warn`); unknown event
1589/// types are ignored at `trace`. Deserialization of a known event type is defensive: a mismatch is logged and the
1590/// event dropped (observable), never silently mis-parsed.
1591///
1592/// The `outboundAccountPosition` (balance) arm is delegated to `handle_position` — the **only** arm
1593/// that differs between cross (account-wide `BalanceStreamUpdate`) and isolated (per-instrument
1594/// `InstrumentBalanceUpdate`, routed via the frame's `subscriptionId`). Everything else
1595/// (`executionReport` routing by inner `s`, the observable-only margin events, termination
1596/// signalling) is single-sourced here.
1597fn convert_margin_user_data_events_with(
1598    frame: &str,
1599    buf: &mut Vec<UnindexedAccountEvent>,
1600    handle_position: &mut impl FnMut(
1601        OutboundAccountPosition,
1602        Option<i64>,
1603        &mut Vec<UnindexedAccountEvent>,
1604    ),
1605) -> bool {
1606    let (subscription_id, event_type, event_raw) = match parse_user_data_frame(frame) {
1607        // RPC responses (subscribe ack, errors): not a user-data event.
1608        UserDataFrame::Response => return false,
1609        UserDataFrame::Event {
1610            subscription_id,
1611            event_type,
1612            event,
1613        } => (subscription_id, event_type, event),
1614        UserDataFrame::Unrecognised => {
1615            static SEEN: AtomicU64 = AtomicU64::new(0);
1616            log_unrecognised_frame("BinanceMargin", &SEEN, frame);
1617            return false;
1618        }
1619    };
1620    match event_type {
1621        "executionReport" => {
1622            // Single typed pass straight from the raw inner slice — no intermediate DOM, and only
1623            // the matched branch deserializes its payload.
1624            match serde_json::from_str::<ExecutionReport>(event_raw) {
1625                Ok(report) => convert_execution_report(&report, ExchangeId::BinanceMargin, buf),
1626                Err(e) => {
1627                    warn!(error = %e, "BinanceMargin: undeserializable executionReport, dropping")
1628                }
1629            }
1630            false
1631        }
1632        "outboundAccountPosition" => {
1633            match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
1634                // Balance arm is the one cross/isolated divergence — delegate to the handler.
1635                Ok(position) => handle_position(position, subscription_id, buf),
1636                Err(e) => {
1637                    warn!(error = %e, "BinanceMargin: undeserializable outboundAccountPosition, dropping")
1638                }
1639            }
1640            false
1641        }
1642        "balanceUpdate" => {
1643            // Deposit/withdrawal delta; outboundAccountPosition covers trade-driven balance changes.
1644            // Mirrors spot — not forwarded. Consumers reconcile transfers via account_snapshot.
1645            // No log here: this is the per-frame receive hot path (fires on every balance change).
1646            false
1647        }
1648        "userLiabilityChange" => {
1649            // Observable, NOT accumulated into balance state: borrow/repay is a
1650            // delta, and folding it in would be the position tracking the library refuses. Surfaced
1651            // via logs; consumers needing exact live debt refresh via account_snapshot. Always
1652            // deserialized (rare borrow/repay path, not per-frame) so a parse failure — exchange
1653            // schema drift — is surfaced at WARN rather than silently dropped, even when INFO is
1654            // filtered out (observable failures over silent ones). The success log itself is INFO-gated.
1655            match serde_json::from_str::<UserLiabilityChange>(event_raw) {
1656                Ok(c) => {
1657                    if tracing::enabled!(tracing::Level::INFO) {
1658                        info!(
1659                            asset = c.a.as_deref().unwrap_or("?"),
1660                            kind = c.t.as_deref().unwrap_or("?"),
1661                            principal = c.p.as_deref().unwrap_or("?"),
1662                            interest = c.i.as_deref().unwrap_or("?"),
1663                            "BinanceMargin userLiabilityChange (observable; not applied to balance state)"
1664                        );
1665                    }
1666                }
1667                Err(e) => warn!(
1668                    error = %e,
1669                    "BinanceMargin: undeserializable userLiabilityChange, dropping"
1670                ),
1671            }
1672            false
1673        }
1674        "marginLevelStatusChange" => {
1675            // Liquidation-risk signal — observable, no policy (the library takes no defensive action).
1676            // The level guard skips the deserialize allocation when WARN is filtered out, mirroring
1677            // userLiabilityChange above. A parse failure on a liquidation-risk frame is surfaced
1678            // rather than dropped silently (observable failures over silent ones).
1679            if tracing::enabled!(tracing::Level::WARN) {
1680                match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
1681                    Ok(c) => warn!(
1682                        margin_level = c.l.as_deref().unwrap_or("?"),
1683                        status = c.s.as_deref().unwrap_or("?"),
1684                        "BinanceMargin marginLevelStatusChange (liquidation risk; observable, no policy)"
1685                    ),
1686                    Err(e) => warn!(
1687                        error = %e,
1688                        "BinanceMargin: undeserializable marginLevelStatusChange, dropping"
1689                    ),
1690                }
1691            }
1692            false
1693        }
1694        "eventStreamTerminated" => {
1695            warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
1696            true
1697        }
1698        other => {
1699            trace!(
1700                event_type = other,
1701                "BinanceMargin ignoring unhandled user data event"
1702            );
1703            false
1704        }
1705    }
1706}
1707
1708/// Convert a margin `outboundAccountPosition` to `BalanceStreamUpdate` events (one per asset).
1709///
1710/// The WS message is a `free`/`locked` partial (no debt), so it emits `BalanceStreamUpdate` — which
1711/// structurally cannot clobber the `margin` debt established by a REST `BalanceSnapshot`.
1712fn convert_margin_account_position(
1713    position: OutboundAccountPosition,
1714    buf: &mut Vec<UnindexedAccountEvent>,
1715) {
1716    let time_exchange = position
1717        .u
1718        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1719        .unwrap_or_else(Utc::now);
1720
1721    for b in position.b_uppercase.unwrap_or_default() {
1722        let asset = match b.a {
1723            Some(a) => AssetNameExchange::new(a),
1724            None => {
1725                warn!("BinanceMargin account position entry missing asset name");
1726                continue;
1727            }
1728        };
1729        let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1730            Some(v) => v,
1731            None => {
1732                warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
1733                continue;
1734            }
1735        };
1736        let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1737            Some(v) => v,
1738            None => {
1739                warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
1740                continue;
1741            }
1742        };
1743        let update =
1744            AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
1745        buf.push(UnindexedAccountEvent::new(
1746            ExchangeId::BinanceMargin,
1747            AccountEventKind::BalanceStreamUpdate(
1748                rustrade_integration::collection::snapshot::Snapshot::new(update),
1749            ),
1750        ));
1751    }
1752}
1753
1754/// Route an isolated `outboundAccountPosition` frame to an [`InstrumentBalanceUpdate`] (Tier B).
1755///
1756/// The frame carries no symbol inline, so the pair is recovered from `subscription_id` via
1757/// `sub_map` (populated from the per-symbol subscribe acks). The flat `b[]` asset list is then
1758/// split into `base`/`quote` by **asset-name equality** against the pair's known `(base, quote)`
1759/// from `base_quote` (the authoritative startup map — never string-matched against the symbol).
1760/// Emits one `InstrumentBalanceUpdate` carrying both sides' free/locked. Dropped with a `warn!`
1761/// (observable, never mis-applied) when: the `subscriptionId` is missing/unmapped; the pair's
1762/// base/quote is unknown; or either side is absent/unparseable in the frame. The engine does not
1763/// store this variant (`_ => None` wildcard); the wrapper consumes it off the account-event feed.
1764fn route_isolated_account_position(
1765    position: OutboundAccountPosition,
1766    subscription_id: Option<i64>,
1767    sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
1768    base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
1769    buf: &mut Vec<UnindexedAccountEvent>,
1770) {
1771    let Some(sub_id) = subscription_id else {
1772        warn!(
1773            "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
1774             (per-instrument balance routing requires it)"
1775        );
1776        return;
1777    };
1778    let instrument = {
1779        // Brief lock: the map is read-only after startup and only written by the subscribe loop on
1780        // (re)connect. Recover from poisoning rather than killing balance routing.
1781        let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
1782        match map.get(&sub_id) {
1783            Some(inst) => inst.clone(),
1784            None => {
1785                warn!(
1786                    subscription_id = sub_id,
1787                    "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
1788                );
1789                return;
1790            }
1791        }
1792    };
1793    let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
1794        warn!(
1795            instrument = %instrument.name(),
1796            "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
1797        );
1798        return;
1799    };
1800
1801    let time_exchange = position
1802        .u
1803        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1804        .unwrap_or_else(Utc::now);
1805
1806    let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1807    let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1808    for b in position.b_uppercase.unwrap_or_default() {
1809        let Some(asset) = b.a.as_deref() else {
1810            warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
1811            continue;
1812        };
1813        // Only the pair's own base/quote are relevant; ignore any stray asset on the frame.
1814        let side = if asset == base_asset.name().as_str() {
1815            &mut base_update
1816        } else if asset == quote_asset.name().as_str() {
1817            &mut quote_update
1818        } else {
1819            continue;
1820        };
1821        let (Some(free), Some(locked)) = (
1822            b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1823            b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1824        ) else {
1825            warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
1826            continue;
1827        };
1828        *side = Some(AssetBalanceUpdate::new(
1829            AssetNameExchange::new(asset),
1830            BalanceUpdate::new(free, locked),
1831            time_exchange,
1832        ));
1833    }
1834
1835    // InstrumentBalanceUpdate carries BOTH sides; a frame missing either is dropped rather than
1836    // fabricating a zero side (which would silently report a wrong free/locked). A trade-driven
1837    // outboundAccountPosition changes both base and quote together, so both are normally present.
1838    let (Some(base), Some(quote)) = (base_update, quote_update) else {
1839        warn!(
1840            instrument = %instrument.name(),
1841            "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
1842        );
1843        return;
1844    };
1845    buf.push(UnindexedAccountEvent::new(
1846        ExchangeId::BinanceMargin,
1847        AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
1848            instrument, base, quote,
1849        )),
1850    ));
1851}
1852
1853/// Register the WS-API event-dispatch callback shared by both margin managers.
1854///
1855/// Single-sources the demux/dedup/dispatch + heartbeat + terminal-signal logic the cross and
1856/// isolated managers both need; only the `outboundAccountPosition` (balance) arm differs and is
1857/// supplied as `handle_position` (cross → account-wide `BalanceStreamUpdate` via
1858/// [`cross_account_position_handler`]; isolated → per-instrument `InstrumentBalanceUpdate` via
1859/// [`route_isolated_account_position`]). Returns the [`Subscription`] handle; the caller must
1860/// `unsubscribe()` it on disconnect. `heartbeat_flag` is shared with the caller's monitor loop
1861/// (set on every inbound frame/ping); `signal_tx` fires once on a terminal condition
1862/// (consumer-drop, socket error/close, or exchange `eventStreamTerminated`).
1863fn register_user_data_listener(
1864    ws: &Arc<WsApiBase>,
1865    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1866    dedup: SharedDedupCache,
1867    heartbeat_flag: Arc<AtomicBool>,
1868    signal_tx: oneshot::Sender<()>,
1869    mut handle_position: impl FnMut(
1870        OutboundAccountPosition,
1871        Option<i64>,
1872        &mut Vec<UnindexedAccountEvent>,
1873    ) + Send
1874    + 'static,
1875) -> Subscription {
1876    let mut signal_tx_opt = Some(signal_tx);
1877    let mut event_tx = Some(tx);
1878    // 32 comfortably covers a typical account's outboundAccountPosition (one entry per asset) plus
1879    // the execution events in a single message; an account with >32 assets reallocates once, after
1880    // which the buffer is reused across messages via drain(..).
1881    let mut event_buf = Vec::with_capacity(32);
1882
1883    // Safety — non-atomic Option::take() in the callback is safe: binance-sdk drives one spawned
1884    // task per subscription, invoking the FnMut sequentially (verified =70.2.0; re-verify on SDK
1885    // upgrade). Same contract spot relies on.
1886    ws.common.events.subscribe(move |event| {
1887        let Some(ref sender) = event_tx else { return };
1888        match event {
1889            WebsocketEvent::Message(json_str) => {
1890                heartbeat_flag.store(true, Ordering::Release);
1891                // Frame parsing (including skipping non-JSON frames) happens inside the converter,
1892                // which parses borrowed slices rather than a full DOM (hot path).
1893                let terminated = convert_margin_user_data_events_with(
1894                    &json_str,
1895                    &mut event_buf,
1896                    &mut handle_position,
1897                );
1898                for ev in event_buf.drain(..) {
1899                    if let Some(key) = dedup_key_from_event(&ev)
1900                        && is_duplicate(&dedup, key)
1901                    {
1902                        trace!("BinanceMargin dedup: skipping duplicate event");
1903                        continue;
1904                    }
1905                    if sender.send(ev).is_err() {
1906                        warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
1907                        event_tx.take();
1908                        if let Some(s) = signal_tx_opt.take() {
1909                            let _ = s.send(());
1910                        }
1911                        return;
1912                    }
1913                }
1914                if terminated {
1915                    event_tx.take();
1916                    if let Some(s) = signal_tx_opt.take() {
1917                        let _ = s.send(());
1918                    }
1919                }
1920            }
1921            WebsocketEvent::Ping | WebsocketEvent::Pong => {
1922                heartbeat_flag.store(true, Ordering::Release);
1923            }
1924            WebsocketEvent::Error(e) => {
1925                warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
1926                event_tx.take();
1927                if let Some(s) = signal_tx_opt.take() {
1928                    let _ = s.send(());
1929                }
1930            }
1931            WebsocketEvent::Close(code, reason) => {
1932                warn!(code, %reason, "BinanceMargin WebSocket closed");
1933                event_tx.take();
1934                if let Some(s) = signal_tx_opt.take() {
1935                    let _ = s.send(());
1936                }
1937            }
1938            _ => {
1939                trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
1940            }
1941        }
1942    })
1943}
1944
1945/// Recover the fills of every gap in `unrecovered` that is due, via REST `myTrades`, routed
1946/// through the dedup cache. Mirrors `BinanceSpot::recover_fills`, which documents how gaps are
1947/// settled and retried.
1948///
1949/// Only TRADE fills are recovered — order-lifecycle events (NEW/CANCELED) require a
1950/// `fetch_open_orders` reconciliation by the caller.
1951///
1952/// `myTrades` reports executions only, with no cumulative, so each recovered trade's
1953/// `order_filled_quantity` is rebuilt from its order's executions by
1954/// [`recovered_order_totals`]. A trade whose order could not be looked up keeps `None`.
1955///
1956/// Margin `myTrades` is read by time in windows under 24 hours (see
1957/// [`paginate_margin_my_trades`]), so each day of a gap before its first fill costs one sequential
1958/// request. A gap of weeks can therefore run into [`FILL_RECOVERY_TIMEOUT_SECS`], which bounds the
1959/// whole recovery; it is then retried, and given up once it has failed every retry.
1960///
1961/// Fills forwarded before the deadline stay delivered. Its reads are [`RequestKind::Essential`]:
1962/// they wait out a rate-limit cooldown but not the pause near the weight limit, which could
1963/// outlast the budget.
1964async fn recover_margin_fills(
1965    rest: &Arc<RestApi>,
1966    rate_limiter: &Arc<RateLimitTracker>,
1967    unrecovered: &mut UnrecoveredFills,
1968    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1969    dedup: &SharedDedupCache,
1970    is_isolated: bool,
1971) {
1972    use futures::StreamExt as _;
1973
1974    let due = unrecovered.due(tokio::time::Instant::now());
1975    let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
1976        return;
1977    };
1978    info!(
1979        gaps = due.len(),
1980        oldest = %gap_time(oldest),
1981        is_isolated,
1982        "BinanceMargin recovering fills missed while disconnected"
1983    );
1984
1985    // Which gaps of `due` have been settled, recovered or failed, and how many have started: the
1986    // stream starts them in order, eight at a time, so those from `started` on were never read.
1987    let mut settled = vec![false; due.len()];
1988    let started = AtomicUsize::new(0);
1989    let recovery = async {
1990        let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
1991        let mut recovered = 0u32;
1992        let mut duplicates = 0u32;
1993
1994        let mut stream =
1995            futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
1996                started.store(index + 1, Ordering::Relaxed);
1997                let rest = rest.clone();
1998                let rl = rate_limiter.clone();
1999                async move {
2000                    let span = MyTradesFrom::Span {
2001                        start: gap.start_ms,
2002                        end: gap.end_ms,
2003                    };
2004                    let raw = match paginate_margin_my_trades(
2005                        &rest,
2006                        &rl,
2007                        &inst,
2008                        span,
2009                        is_isolated,
2010                        RequestKind::Essential,
2011                    )
2012                    .await
2013                    {
2014                        Ok(pages) => pages,
2015                        Err(e) => return (index, Err(e)),
2016                    };
2017                    // `myTrades` carries no cumulative, so each recovered fill's is rebuilt from
2018                    // its order's executions; without it the fill advances the position but not
2019                    // the order.
2020                    let totals = recovered_order_totals(
2021                        ExchangeId::BinanceMargin,
2022                        &inst,
2023                        &raw,
2024                        order_executions_deadline,
2025                        |order_id| {
2026                            paginate_margin_my_trades(
2027                                &rest,
2028                                &rl,
2029                                &inst,
2030                                MyTradesFrom::Order(order_id),
2031                                is_isolated,
2032                                RequestKind::Essential,
2033                            )
2034                        },
2035                    )
2036                    .await;
2037                    let trades = raw
2038                        .iter()
2039                        .filter_map(|t| {
2040                            let mut trade = convert_margin_trade(t, &inst)?;
2041                            trade.order_filled_quantity =
2042                                t.id.and_then(|id| totals.get(&id).copied());
2043                            Some(trade)
2044                        })
2045                        .collect::<Vec<_>>();
2046                    (index, Ok(trades))
2047                }
2048            }))
2049            .buffer_unordered(8);
2050        while let Some((index, result)) = stream.next().await {
2051            let (inst, gap) = &due[index];
2052            settled[index] = true;
2053            let trades = match result {
2054                Ok(trades) => trades,
2055                Err(e) => {
2056                    gap_failed(
2057                        unrecovered,
2058                        ExchangeId::BinanceMargin,
2059                        inst,
2060                        gap,
2061                        &e.to_string(),
2062                    );
2063                    continue;
2064                }
2065            };
2066            for trade in trades {
2067                let event = UnindexedAccountEvent::new(
2068                    ExchangeId::BinanceMargin,
2069                    AccountEventKind::Trade(trade),
2070                );
2071                if let Some(key) = dedup_key_from_event(&event)
2072                    && is_duplicate(dedup, key)
2073                {
2074                    duplicates += 1;
2075                    continue;
2076                }
2077                if tx.send(event).is_err() {
2078                    debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2079                    return;
2080                }
2081                recovered += 1;
2082            }
2083            unrecovered.recovered(inst, gap);
2084        }
2085        info!(
2086            recovered,
2087            duplicates, is_isolated, "BinanceMargin fill recovery complete"
2088        );
2089    };
2090    // A timeout drops `recovery` at an await, between gaps: one gap's fills are sent without
2091    // awaiting, so each is either fully forwarded and settled, or not forwarded at all. A gap
2092    // whose read started and did not finish has failed; one never started stays due as it was.
2093    if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2094        .await
2095        .is_err()
2096    {
2097        let started = started.load(Ordering::Relaxed);
2098        for ((inst, gap), _) in due[..started]
2099            .iter()
2100            .zip(&settled)
2101            .filter(|(_, settled)| !**settled)
2102        {
2103            gap_failed(
2104                unrecovered,
2105                ExchangeId::BinanceMargin,
2106                inst,
2107                gap,
2108                "fill recovery timed out",
2109            );
2110        }
2111    }
2112}
2113
2114/// Long-running task driving the `account_stream` WebSocket lifecycle.
2115///
2116/// Reconnect loop: acquire token → connect → register listener → subscribe → stream events → on
2117/// disconnect/heartbeat-timeout/token-expiry → backoff → fill recovery → reconnect. The `tx`
2118/// channel persists across reconnections so the consumer sees a seamless stream. Terminates when
2119/// the consumer drops the stream or max reconnect attempts are exhausted.
2120#[allow(
2121    clippy::cognitive_complexity,
2122    reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2123              + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2124)]
2125async fn margin_connection_manager(
2126    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2127    dedup: SharedDedupCache,
2128    ws_config: ConfigurationWebsocketApi,
2129    rest_config: Arc<ConfigurationRestApi>,
2130    rest: Arc<RestApi>,
2131    rate_limiter: Arc<RateLimitTracker>,
2132    instruments: Vec<InstrumentNameExchange>,
2133    initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2134) {
2135    enum DisconnectReason {
2136        Signal,
2137        HeartbeatTimeout,
2138        TokenRefresh,
2139        ConsumerDropped,
2140    }
2141
2142    let mut backoff = ExponentialBackoff::new();
2143    let mut disconnect_time: Option<DateTime<Utc>> = None;
2144    // Gaps not yet recovered: read at reconnect, and retried on a timer while connected, once due.
2145    let mut unrecovered = UnrecoveredFills::default();
2146    let (mut current_ws, mut current_token) = match initial {
2147        Some((ws, token)) => (Some(ws), Some(token)),
2148        None => (None, None),
2149    };
2150
2151    loop {
2152        // --- Token: reuse the verified/previous token, else acquire a fresh one ---
2153        let token = match current_token.take() {
2154            Some(t) => t,
2155            None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2156                Ok(t) => t,
2157                Err(e) => {
2158                    error!(%e, "BinanceMargin userListenToken acquisition failed");
2159                    if !backoff.wait().await {
2160                        error!("BinanceMargin max reconnect attempts exhausted");
2161                        emit_stream_terminated(
2162                            &tx,
2163                            ExchangeId::BinanceMargin,
2164                            StreamTerminationReason::ReconnectBudgetExhausted {
2165                                attempts: backoff.attempts(),
2166                                last_error: e.to_string(),
2167                            },
2168                        );
2169                        break;
2170                    }
2171                    continue;
2172                }
2173            },
2174        };
2175
2176        // --- Connect (skip if holding a verified initial connection) ---
2177        let ws = match current_ws.take() {
2178            Some(ws) => ws,
2179            None => match connect_margin_ws(&ws_config).await {
2180                Ok(ws) => ws,
2181                Err(e) => {
2182                    error!(%e, "BinanceMargin WS connect failed");
2183                    if !backoff.wait().await {
2184                        error!("BinanceMargin max reconnect attempts exhausted");
2185                        emit_stream_terminated(
2186                            &tx,
2187                            ExchangeId::BinanceMargin,
2188                            StreamTerminationReason::ReconnectBudgetExhausted {
2189                                attempts: backoff.attempts(),
2190                                last_error: e.to_string(),
2191                            },
2192                        );
2193                        break;
2194                    }
2195                    continue;
2196                }
2197            },
2198        };
2199
2200        // --- Register the event listener BEFORE subscribing so no pushed event is missed ---
2201        let (signal_tx, signal_rx) = oneshot::channel::<()>();
2202        // start true — grant one full heartbeat window before requiring activity.
2203        let heartbeat_flag = Arc::new(AtomicBool::new(true));
2204        // Cross is account-wide: balance frames become asset-keyed BalanceStreamUpdates (no
2205        // per-instrument routing). The shared dispatch/dedup/heartbeat logic lives in the helper.
2206        let subscription = register_user_data_listener(
2207            &ws,
2208            tx.clone(),
2209            dedup.clone(),
2210            heartbeat_flag.clone(),
2211            signal_tx,
2212            cross_account_position_handler,
2213        );
2214
2215        // --- Subscribe (token is the sole auth) ---
2216        if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2217            warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2218            subscription.unsubscribe();
2219            if let Err(de) = ws.disconnect().await {
2220                warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2221            }
2222            // token left consumed → next iteration acquires a fresh one (auth/expiry-safe).
2223            if !backoff.wait().await {
2224                error!("BinanceMargin max reconnect attempts exhausted");
2225                emit_stream_terminated(
2226                    &tx,
2227                    ExchangeId::BinanceMargin,
2228                    StreamTerminationReason::ReconnectBudgetExhausted {
2229                        attempts: backoff.attempts(),
2230                        last_error: e.to_string(),
2231                    },
2232                );
2233                break;
2234            }
2235            continue;
2236        }
2237        info!("BinanceMargin account_stream connected and subscribed");
2238        // Reaching a live, subscribed connection clears any prior failure count so only *consecutive*
2239        // failures exhaust the reconnect budget — a clean rotation or a transient blip that recovers
2240        // shouldn't leave the backoff counter inflated for the next genuine failure.
2241        backoff.reset();
2242
2243        // Absolute deadline (not a per-iteration relative sleep) so heartbeat ticks that `continue`
2244        // the monitor loop don't keep restarting the renewal timer.
2245        let token_deadline =
2246            tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2247
2248        // --- Fill recovery after a reconnect (bounded) ---
2249        // The gap is opened before it is read, so a recovery that fails or times out keeps it.
2250        if let Some(dt) = disconnect_time.take() {
2251            unrecovered.open(&instruments, dt, Utc::now());
2252        }
2253        // This is the cross manager (account-wide); fill recovery is always cross-scoped.
2254        // The isolated manager (a separate path) passes `true`.
2255        recover_margin_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, false).await;
2256
2257        // --- Monitor: disconnect signal, heartbeat timeout, token refresh, or consumer drop ---
2258        let reason = {
2259            let mut signal_rx = signal_rx;
2260            // Gaps a recovery did not read are retried as they fall due, alongside the monitor, so
2261            // a disconnect is still seen at once. It never completes; dropping it when the monitor
2262            // ends loses nothing, since each gap is settled as soon as its read ends.
2263            let retry_gaps = async {
2264                loop {
2265                    match unrecovered.next_due() {
2266                        Some(due) => tokio::time::sleep_until(due).await,
2267                        None => std::future::pending::<()>().await,
2268                    }
2269                    recover_margin_fills(
2270                        &rest,
2271                        &rate_limiter,
2272                        &mut unrecovered,
2273                        &tx,
2274                        &dedup,
2275                        false,
2276                    )
2277                    .await;
2278                }
2279            };
2280            tokio::pin!(retry_gaps);
2281            loop {
2282                tokio::select! {
2283                    // Biased: a consumer drop is terminal and must win over a simultaneously-ready
2284                    // disconnect signal, which would otherwise enqueue one pointless reconnect.
2285                    biased;
2286                    _ = tx.closed() => {
2287                        debug!("BinanceMargin account_stream consumer dropped, terminating");
2288                        break DisconnectReason::ConsumerDropped;
2289                    }
2290                    _ = &mut signal_rx => {
2291                        warn!("BinanceMargin WS disconnected, will attempt reconnect");
2292                        break DisconnectReason::Signal;
2293                    }
2294                    () = tokio::time::sleep_until(token_deadline) => {
2295                        info!("BinanceMargin userListenToken nearing expiry, renewing");
2296                        break DisconnectReason::TokenRefresh;
2297                    }
2298                    () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2299                        if heartbeat_flag.swap(false, Ordering::AcqRel) {
2300                            // Backoff is already cleared post-subscribe (see backoff.reset() above);
2301                            // the monitor loop is only entered on a live connection, so a heartbeat
2302                            // tick never sees a non-zero count. Just consume the flag and keep waiting.
2303                            continue;
2304                        }
2305                        warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2306                        break DisconnectReason::HeartbeatTimeout;
2307                    }
2308                    // Last: the arms above end the monitor and win when ready together.
2309                    () = &mut retry_gaps => {}
2310                }
2311            }
2312        };
2313        let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2314
2315        if should_reconnect {
2316            disconnect_time = Some(match reason {
2317                DisconnectReason::HeartbeatTimeout => {
2318                    Utc::now()
2319                        - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2320                        - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2321                }
2322                DisconnectReason::TokenRefresh => {
2323                    // A rotation fully disconnects + reconnects (token POST + WS connect + subscribe).
2324                    // Look back far enough to bound that whole window — the WS handshake alone is
2325                    // capped at CONNECT_TIMEOUT_SECS — so a fill landing during the gap isn't missed.
2326                    // The dedup cache absorbs the redundant re-query of fills delivered pre-rotation.
2327                    Utc::now()
2328                        - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2329                        - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2330                }
2331                _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2332            });
2333        }
2334
2335        // --- Cleanup ---
2336        subscription.unsubscribe();
2337        if let Err(e) = ws.disconnect().await {
2338            warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2339        }
2340
2341        if !should_reconnect || tx.is_closed() {
2342            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed).
2343            debug!("BinanceMargin connection manager exiting");
2344            break;
2345        }
2346
2347        // A planned token refresh is not a failure — reconnect immediately (no backoff). Both
2348        // `current_token` and `current_ws` are already None, forcing a fresh token + connection.
2349        if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2350            error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
2351            // reason here is Signal or HeartbeatTimeout (TokenRefresh is guarded above,
2352            // ConsumerDropped already broke out earlier).
2353            let last_error = match reason {
2354                DisconnectReason::HeartbeatTimeout => {
2355                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2356                }
2357                _ => "WebSocket disconnected (server close/error)".to_string(),
2358            };
2359            emit_stream_terminated(
2360                &tx,
2361                ExchangeId::BinanceMargin,
2362                StreamTerminationReason::ReconnectBudgetExhausted {
2363                    attempts: backoff.attempts(),
2364                    last_error,
2365                },
2366            );
2367            break;
2368        }
2369    }
2370}
2371
2372// ---------------------------------------------------------------------------
2373// Isolated user-data stream (separate multiplexed manager; cross untouched)
2374// ---------------------------------------------------------------------------
2375
2376/// Extract the invariant `instrument → (base_asset, quote_asset)` map from an isolated
2377/// account-info response.
2378///
2379/// The authoritative base/quote split for routing symbol-less `outboundAccountPosition` frames
2380/// ([`route_isolated_account_position`]) — base/quote never change for a pair,
2381/// so this is built once at stream start and reused across reconnects. Entries missing a symbol or
2382/// either asset name are skipped (their balance frames then drop with a `warn!` at routing time).
2383fn build_base_quote_map(
2384    assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2385) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2386    let mut map = HashMap::with_capacity(assets.len());
2387    for entry in assets {
2388        let (Some(symbol), Some(base), Some(quote)) = (
2389            entry.symbol.as_deref(),
2390            entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2391            entry
2392                .quote_asset
2393                .as_deref()
2394                .and_then(|q| q.asset.as_deref()),
2395        ) else {
2396            warn!(
2397                "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2398                 skipping base/quote map entry"
2399            );
2400            continue;
2401        };
2402        map.insert(
2403            InstrumentNameExchange::new(symbol),
2404            (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2405        );
2406    }
2407    map
2408}
2409
2410/// Acquire one per-symbol isolated `userListenToken` for every configured symbol, bounded-concurrent.
2411///
2412/// Fans out at 8-wide (mirroring `recover_margin_fills`) through the shared rate-limit/backoff
2413/// machinery — ~1s at the v1 N≤100 ceiling vs ~5–10s sequential. Each token is
2414/// a weight-1 signed POST; the bound stays well inside SAPI weight limits. Errors if any acquisition
2415/// fails (the caller treats a partial set as a startup/reconnect failure).
2416///
2417/// Takes `&Arc<_>` rather than the file-wide `&RateLimitTracker` convention because each per-symbol
2418/// `async move` future needs an owned `Arc` clone to share the tracker without borrowing from this
2419/// frame across the concurrent `buffer_unordered` fan-out. Cloning the `Arc` (a refcount bump) is
2420/// correct; cloning `RateLimitTracker` itself would be semantically wrong — each clone would get its
2421/// own `blocked_until`, splitting the shared rate-limit state the fan-out is meant to respect.
2422async fn acquire_all_isolated_tokens(
2423    rest_config: &Arc<ConfigurationRestApi>,
2424    rate_limiter: &Arc<RateLimitTracker>,
2425    symbols: &[InstrumentNameExchange],
2426) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
2427    use futures::{StreamExt as _, TryStreamExt as _};
2428
2429    futures::stream::iter(symbols.iter().cloned().map(|sym| {
2430        let rest_config = rest_config.clone();
2431        let rate_limiter = rate_limiter.clone();
2432        async move {
2433            let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
2434            Ok::<_, UnindexedClientError>((sym, token))
2435        }
2436    }))
2437    .buffer_unordered(8)
2438    .try_collect()
2439    .await
2440}
2441
2442/// Earliest `expirationTime` (epoch ms) across the per-symbol tokens — drives the planned-renewal
2443/// deadline. The N tokens are acquired in a tight startup loop so their 24h expiries cluster;
2444/// renewing on the earliest refreshes the whole set in one reconnect. Empty → `i64::MAX` (no token
2445/// to expire; never happens for isolated, which always has ≥1 configured symbol).
2446fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
2447    tokens
2448        .iter()
2449        .map(|(_, t)| t.expiration_time_ms)
2450        .min()
2451        .unwrap_or(i64::MAX)
2452}
2453
2454/// A live, subscribed isolated connection: the single multiplexed socket plus the per-connection
2455/// lifecycle handles the manager's monitor loop needs.
2456struct IsolatedLiveConn {
2457    ws: Arc<WsApiBase>,
2458    subscription: Subscription,
2459    signal_rx: oneshot::Receiver<()>,
2460    heartbeat_flag: Arc<AtomicBool>,
2461    /// Earliest `expirationTime` across the N tokens — drives the planned-renewal deadline (the
2462    /// tokens cluster, so renewing on the earliest refreshes the whole set in one reconnect).
2463    earliest_expiry_ms: i64,
2464}
2465
2466/// Connect one WS-API socket and subscribe all N per-symbol `userListenToken`s on it (multiplex).
2467///
2468/// Registers the shared dispatch listener (with the isolated per-instrument balance handler) BEFORE
2469/// the first subscribe so no pushed event between the N acks is missed, then issues all N subscribes,
2470/// capturing each ack's `subscriptionId` into the shared `subscriptionId → symbol` routing map. If
2471/// **any** subscribe fails, the partially-subscribed socket is cleaned up and the error returned
2472/// (startup → `Err` with nothing spawned; reconnect → retry with backoff). Reused by both the
2473/// initial `account_stream` startup and the manager's reconnect path so connect+subscribe is
2474/// single-sourced.
2475async fn isolated_connect_and_subscribe(
2476    ws_config: &ConfigurationWebsocketApi,
2477    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2478    dedup: &SharedDedupCache,
2479    base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2480    tokens: &[(InstrumentNameExchange, UserListenToken)],
2481) -> anyhow::Result<IsolatedLiveConn> {
2482    let ws = connect_margin_ws(ws_config).await?;
2483    // subscriptionId → symbol routing map: written here as each subscribe ack resolves, read by the
2484    // dispatch callback on every `outboundAccountPosition`. Shared (Mutex) because the two run on
2485    // different tasks (manager vs the SDK's per-subscription callback task); contention is negligible
2486    // (writes only at (re)connect, reads only on balance frames).
2487    // Sized to the token count up front — exactly one entry is inserted per subscribe ack below.
2488    let sub_map = Arc::new(Mutex::new(
2489        HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
2490    ));
2491    let (signal_tx, signal_rx) = oneshot::channel::<()>();
2492    // start true — grant one full heartbeat window before requiring activity.
2493    let heartbeat_flag = Arc::new(AtomicBool::new(true));
2494
2495    let handle_position = {
2496        let sub_map = sub_map.clone();
2497        let base_quote = base_quote.clone();
2498        move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
2499            route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
2500        }
2501    };
2502    // Register BEFORE subscribing so a pushed event arriving mid-fan-out is not missed.
2503    let subscription = register_user_data_listener(
2504        &ws,
2505        tx.clone(),
2506        dedup.clone(),
2507        heartbeat_flag.clone(),
2508        signal_tx,
2509        handle_position,
2510    );
2511
2512    let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
2513    for (sym, token) in tokens {
2514        match subscribe_listen_token_capture(&ws, &token.token).await {
2515            Ok(Some(id)) => {
2516                sub_map
2517                    .lock()
2518                    .unwrap_or_else(|p| p.into_inner())
2519                    .insert(id, sym.clone());
2520            }
2521            Ok(None) => warn!(
2522                symbol = %sym.name(),
2523                "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
2524                 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
2525            ),
2526            Err(e) => {
2527                // Any subscribe failing voids the whole stream — clean up the partially-subscribed
2528                // socket rather than leaking it, and surface the error to the caller.
2529                warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
2530                subscription.unsubscribe();
2531                if let Err(de) = ws.disconnect().await {
2532                    warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
2533                }
2534                return Err(e);
2535            }
2536        }
2537    }
2538
2539    Ok(IsolatedLiveConn {
2540        ws,
2541        subscription,
2542        signal_rx,
2543        heartbeat_flag,
2544        earliest_expiry_ms,
2545    })
2546}
2547
2548/// Long-running task driving the isolated `account_stream` lifecycle over one multiplexed socket.
2549///
2550/// Mirrors [`margin_connection_manager`] (the cross manager, left untouched) but for the N-symbol
2551/// multiplex: reconnect re-acquires all N tokens + re-subscribes all N from the configured symbol
2552/// set; renewal is a planned reconnect on the earliest token deadline (`DisconnectReason::TokenRefresh`,
2553/// reusing cross's `token_renew_after`/`sleep_until` discipline — no make-before-break). One socket
2554/// ⇒ one heartbeat, one reconnect loop, one backoff. The `base_quote` map is invariant and reused
2555/// across reconnects (never rebuilt).
2556#[allow(
2557    clippy::cognitive_complexity,
2558    reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
2559              + backoff); mirrors the cross manager, not worth splitting further"
2560)]
2561async fn isolated_connection_manager(
2562    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2563    dedup: SharedDedupCache,
2564    ws_config: ConfigurationWebsocketApi,
2565    rest_config: Arc<ConfigurationRestApi>,
2566    rest: Arc<RestApi>,
2567    rate_limiter: Arc<RateLimitTracker>,
2568    symbols: Vec<InstrumentNameExchange>,
2569    base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2570    initial: Option<IsolatedLiveConn>,
2571) {
2572    enum DisconnectReason {
2573        Signal,
2574        HeartbeatTimeout,
2575        TokenRefresh,
2576        ConsumerDropped,
2577    }
2578
2579    let mut backoff = ExponentialBackoff::new();
2580    let mut disconnect_time: Option<DateTime<Utc>> = None;
2581    // Gaps not yet recovered: read at reconnect, and retried on a timer while connected, once due.
2582    let mut unrecovered = UnrecoveredFills::default();
2583    let mut current = initial;
2584
2585    loop {
2586        // --- (Re)establish the live, subscribed connection ---
2587        let IsolatedLiveConn {
2588            ws,
2589            subscription,
2590            signal_rx,
2591            heartbeat_flag,
2592            earliest_expiry_ms,
2593        } = match current.take() {
2594            // Verified initial connection from account_stream — used as-is on the first pass.
2595            Some(live) => live,
2596            None => {
2597                // Reconnect: re-acquire all N tokens (they cluster; a planned refresh re-acquires the
2598                // whole set) then connect a fresh socket and re-subscribe all N.
2599                let tokens = match acquire_all_isolated_tokens(
2600                    &rest_config,
2601                    &rate_limiter,
2602                    &symbols,
2603                )
2604                .await
2605                {
2606                    Ok(t) => t,
2607                    Err(e) => {
2608                        error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
2609                        if !backoff.wait().await {
2610                            error!("BinanceMargin isolated max reconnect attempts exhausted");
2611                            emit_stream_terminated(
2612                                &tx,
2613                                ExchangeId::BinanceMargin,
2614                                StreamTerminationReason::ReconnectBudgetExhausted {
2615                                    attempts: backoff.attempts(),
2616                                    last_error: e.to_string(),
2617                                },
2618                            );
2619                            break;
2620                        }
2621                        continue;
2622                    }
2623                };
2624                match isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
2625                    .await
2626                {
2627                    Ok(live) => live,
2628                    Err(e) => {
2629                        error!(%e, "BinanceMargin isolated connect/subscribe failed");
2630                        if !backoff.wait().await {
2631                            error!("BinanceMargin isolated max reconnect attempts exhausted");
2632                            emit_stream_terminated(
2633                                &tx,
2634                                ExchangeId::BinanceMargin,
2635                                StreamTerminationReason::ReconnectBudgetExhausted {
2636                                    attempts: backoff.attempts(),
2637                                    last_error: e.to_string(),
2638                                },
2639                            );
2640                            break;
2641                        }
2642                        continue;
2643                    }
2644                }
2645            }
2646        };
2647
2648        info!(
2649            symbols = symbols.len(),
2650            "BinanceMargin isolated account_stream connected and subscribed"
2651        );
2652        // Reaching a live, subscribed connection clears any prior failure count (see the cross
2653        // manager) so only *consecutive* failures exhaust the reconnect budget.
2654        backoff.reset();
2655
2656        // Absolute deadline (not a per-iteration relative sleep) so heartbeat ticks that `continue`
2657        // the monitor loop don't keep restarting the renewal timer. Earliest token expiry drives it.
2658        let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
2659
2660        // --- Fill recovery after a reconnect (isolated-scoped over the full symbol set) ---
2661        // The gap is opened before it is read, so a recovery that fails or times out keeps it.
2662        if let Some(dt) = disconnect_time.take() {
2663            unrecovered.open(&symbols, dt, Utc::now());
2664        }
2665        // is_isolated = true: paginate_margin_my_trades must query isolated trades.
2666        recover_margin_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, true).await;
2667
2668        // --- Monitor: disconnect signal, heartbeat timeout, token refresh, or consumer drop ---
2669        // One socket regardless of N, so the conditions are identical to cross.
2670        let reason = {
2671            let mut signal_rx = signal_rx;
2672            // Gaps a recovery did not read are retried as they fall due, alongside the monitor, so
2673            // a disconnect is still seen at once. It never completes; dropping it when the monitor
2674            // ends loses nothing, since each gap is settled as soon as its read ends.
2675            let retry_gaps = async {
2676                loop {
2677                    match unrecovered.next_due() {
2678                        Some(due) => tokio::time::sleep_until(due).await,
2679                        None => std::future::pending::<()>().await,
2680                    }
2681                    recover_margin_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, true)
2682                        .await;
2683                }
2684            };
2685            tokio::pin!(retry_gaps);
2686            loop {
2687                tokio::select! {
2688                    biased;
2689                    _ = tx.closed() => {
2690                        debug!("BinanceMargin isolated consumer dropped, terminating");
2691                        break DisconnectReason::ConsumerDropped;
2692                    }
2693                    _ = &mut signal_rx => {
2694                        warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
2695                        break DisconnectReason::Signal;
2696                    }
2697                    () = tokio::time::sleep_until(token_deadline) => {
2698                        info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
2699                        break DisconnectReason::TokenRefresh;
2700                    }
2701                    () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2702                        if heartbeat_flag.swap(false, Ordering::AcqRel) {
2703                            continue;
2704                        }
2705                        warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2706                        break DisconnectReason::HeartbeatTimeout;
2707                    }
2708                    // Last: the arms above end the monitor and win when ready together.
2709                    () = &mut retry_gaps => {}
2710                }
2711            }
2712        };
2713        let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2714
2715        if should_reconnect {
2716            disconnect_time = Some(match reason {
2717                DisconnectReason::HeartbeatTimeout => {
2718                    Utc::now()
2719                        - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2720                        - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2721                }
2722                DisconnectReason::TokenRefresh => {
2723                    // A rotation fully disconnects + reconnects (re-acquire N tokens + reconnect +
2724                    // re-subscribe N). Look back far enough to bound that whole window so a fill
2725                    // landing during the sub-second gap isn't missed; dedup absorbs the overlap.
2726                    Utc::now()
2727                        - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2728                        - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2729                }
2730                _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2731            });
2732        }
2733
2734        // --- Cleanup ---
2735        subscription.unsubscribe();
2736        if let Err(e) = ws.disconnect().await {
2737            warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
2738        }
2739
2740        if !should_reconnect || tx.is_closed() {
2741            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed).
2742            debug!("BinanceMargin isolated connection manager exiting");
2743            break;
2744        }
2745
2746        // A planned token refresh is not a failure — reconnect immediately (no backoff); `current`
2747        // is already None, forcing fresh tokens + a fresh connection next iteration.
2748        if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2749            error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
2750            // reason here is Signal or HeartbeatTimeout (TokenRefresh guarded above,
2751            // ConsumerDropped already broke out earlier).
2752            let last_error = match reason {
2753                DisconnectReason::HeartbeatTimeout => {
2754                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2755                }
2756                _ => "WebSocket disconnected (server close/error)".to_string(),
2757            };
2758            emit_stream_terminated(
2759                &tx,
2760                ExchangeId::BinanceMargin,
2761                StreamTerminationReason::ReconnectBudgetExhausted {
2762                    attempts: backoff.attempts(),
2763                    last_error,
2764                },
2765            );
2766            break;
2767        }
2768    }
2769}
2770
2771// ---------------------------------------------------------------------------
2772// Account query helpers (margin-specific)
2773// ---------------------------------------------------------------------------
2774
2775/// Fetch open orders for a single instrument via `query_margin_accounts_open_orders`
2776/// (`isIsolated` config-driven). Mirrors `BinanceSpot::fetch_open_orders_for_instrument`.
2777async fn fetch_margin_open_orders_for_instrument(
2778    rest: Arc<RestApi>,
2779    rate_limiter: Arc<RateLimitTracker>,
2780    instrument: InstrumentNameExchange,
2781    is_isolated: bool,
2782) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
2783    // Convert once before the retry closure to avoid a String allocation on every retry.
2784    let symbol_str = instrument.name().to_string();
2785    let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2786    let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
2787        let sym = symbol_str.clone();
2788        let isolated = isolated.clone();
2789        Box::pin(async move {
2790            let params = QueryMarginAccountsOpenOrdersParams::builder()
2791                .symbol(sym)
2792                .is_isolated(isolated)
2793                .build()?;
2794            rest.query_margin_accounts_open_orders(params).await
2795        })
2796    })
2797    .await
2798    .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
2799
2800    let orders_data = response.data().await.map_err(response_decode_error)?;
2801
2802    let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceMargin, &instrument);
2803
2804    Ok((instrument, listing))
2805}
2806
2807/// Fetch *all* open margin orders in a single no-symbol `query_margin_accounts_open_orders`
2808/// call. Backs the [`fetch_open_orders`](BinanceMargin::fetch_open_orders) "return all" sentinel for
2809/// **cross** (the no-symbol affordance is cross-only): with no symbol the venue returns orders
2810/// across every instrument, so each order's instrument is recovered from its own `symbol` field
2811/// (orders missing it are dropped). `isIsolated` is config-driven, but under isolated the caller
2812/// iterates the configured symbol set per-symbol rather than using this no-symbol path.
2813async fn fetch_margin_all_open_orders(
2814    rest: Arc<RestApi>,
2815    rate_limiter: Arc<RateLimitTracker>,
2816    is_isolated: bool,
2817) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2818    let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2819    let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
2820        let isolated = isolated.clone();
2821        Box::pin(async move {
2822            let params = QueryMarginAccountsOpenOrdersParams::builder()
2823                .is_isolated(isolated)
2824                .build()?;
2825            rest.query_margin_accounts_open_orders(params).await
2826        })
2827    })
2828    .await
2829    .map_err(|e| classify_rest_query_error(&e, None))?;
2830
2831    let orders_data = response.data().await.map_err(response_decode_error)?;
2832
2833    let orders = orders_data
2834        .into_iter()
2835        .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin))
2836        .collect();
2837
2838    Ok(orders)
2839}
2840
2841/// Length of each time window [`paginate_margin_my_trades`] walks before it finds a trade.
2842///
2843/// Binance requires "less than 24 hours between startTime and endTime" on margin `myTrades`,
2844/// and returns only 24 hours of trades to a query without `fromId`. Kept well under that
2845/// limit so no reading of the bound (inclusive ends, clock rounding) can make a window too long.
2846const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
2847
2848/// One margin `myTrades` request in a [`paginate_margin_my_trades`] walk. The walk's state is the
2849/// next request to send.
2850#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2851enum MarginTradesQuery {
2852    /// Trades stamped from `start` to `end`, in epoch milliseconds.
2853    Window { start: i64, end: i64 },
2854    /// Trades from this id onwards, whatever their time.
2855    FromId(i64),
2856    /// One order's executions, from its first.
2857    Order(i64),
2858    /// One order's executions, from this id onwards.
2859    OrderFromId { order_id: i64, from_id: i64 },
2860}
2861
2862impl MarginTradesQuery {
2863    /// The first request of a walk from `from`, or `None` when a walk by time starts after
2864    /// `now_ms` and so has nothing to read. `now_ms` is where a walk by time stops stepping by
2865    /// window: the call's start, or a span's end if earlier.
2866    fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
2867        match from {
2868            MyTradesFrom::Time(start) | MyTradesFrom::Span { start, .. } => {
2869                (start <= now_ms).then(|| Self::window(start, now_ms))
2870            }
2871            MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
2872        }
2873    }
2874
2875    /// The window from `start`, ending [`MARGIN_MY_TRADES_WINDOW_MS`] later or at `now_ms`.
2876    fn window(start: i64, now_ms: i64) -> Self {
2877        Self::Window {
2878            start,
2879            end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
2880        }
2881    }
2882
2883    /// The request after this one returned `page_len` trades, the last with id `last_id`, or
2884    /// `None` when the walk is complete.
2885    fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
2886        let full = page_len >= BINANCE_MAX_TRADES;
2887        let after_last = || last_id?.checked_add(1);
2888        match self {
2889            // An empty window steps on until it reaches `now_ms`. The next window starts at this
2890            // one's end, so a trade stamped exactly on the boundary is read whether Binance treats
2891            // `endTime` as inclusive or not. That cannot duplicate a trade: windows only follow
2892            // one another while every window so far was empty.
2893            Self::Window { end, .. } if page_len == 0 => {
2894                (end < now_ms).then(|| Self::window(end, now_ms))
2895            }
2896            // The final window read short holds everything up to `now_ms`: done.
2897            Self::Window { end, .. } if end >= now_ms && !full => None,
2898            // Any other window holding a trade: later trades may lie beyond its end, so read on
2899            // by id, which Binance serves whatever the time.
2900            Self::Window { .. } => after_last().map(Self::FromId),
2901            Self::FromId(_) if full => after_last().map(Self::FromId),
2902            Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
2903                after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
2904            }
2905            Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
2906        }
2907    }
2908}
2909
2910/// Paginate the margin trade list for a single instrument from `from` (`isIsolated`
2911/// config-driven), returning every trade from `from` to the call's start with no gap.
2912///
2913/// - [`MyTradesFrom::Order`]: first page by `order_id`, then `from_id = last_id + 1` alongside it,
2914///   as `BinanceSpot::paginate_my_trades` does.
2915/// - [`MyTradesFrom::Span`]: as `Time`, stepping by window only up to the span's end, and
2916///   stopping at the first page by id that reaches past it, without the executions after it.
2917/// - [`MyTradesFrom::Time`]: unlike spot, margin `myTrades` does not return every trade since a
2918///   bare `startTime` — without `fromId` it returns only 24 hours of trades, and it rejects a
2919///   `startTime`..`endTime` span of 24 hours or more. So the walk first steps forward in windows
2920///   of [`MARGIN_MY_TRADES_WINDOW_MS`], each bounded by `startTime` and `endTime`, from `from` to
2921///   the call's start by the local clock. At the first window holding a trade it switches to
2922///   `from_id = last_id + 1` and reads on until a short page, unless that window was the last
2923///   and read a short page.
2924///
2925/// **Cost:** each window before the first trade is one request (weight 10), so the cost grows
2926/// with the lookback: about one request per day, and about 21,000 from the Unix epoch. An
2927/// ordinary reconnect gap fits in one window.
2928///
2929/// **Isolated correctness:** this also backs reconnect fill-recovery (`recover_margin_fills`), so a
2930/// missed `isIsolated` flip would silently query *cross* trades on an isolated client — hence the
2931/// value is threaded from config rather than hardcoded.
2932async fn paginate_margin_my_trades(
2933    rest: &Arc<RestApi>,
2934    rate_limiter: &Arc<RateLimitTracker>,
2935    instrument: &InstrumentNameExchange,
2936    from: MyTradesFrom,
2937    is_isolated: bool,
2938    kind: RequestKind,
2939) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
2940    // Convert once before the retry closure to avoid a String allocation on every retry.
2941    let symbol_str = instrument.name().to_string();
2942    let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
2943    // The const_assert! in shared bounds BINANCE_MAX_TRADES <= i32::MAX, which trivially fits in
2944    // i64, so this cast is always lossless. clippy::cast_possible_wrap fires only because usize is
2945    // the same width as i64 on 64-bit targets (a hypothetical >64-bit usize could wrap); the bound
2946    // rules that out.
2947    #[allow(clippy::cast_possible_wrap)]
2948    let limit = BINANCE_MAX_TRADES as i64;
2949    // A walk by time reads up to here. A trade after it is left to the caller's next read (or, on
2950    // reconnect, to the stream), unless the walk is already paging by id.
2951    let now_ms = match from {
2952        MyTradesFrom::Span { end, .. } => end.min(Utc::now().timestamp_millis()),
2953        MyTradesFrom::Time(_) | MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
2954    };
2955    let mut all_pages = Vec::new();
2956    let mut requests = 0u32;
2957    let mut next = MarginTradesQuery::first(from, now_ms);
2958    while let Some(query) = next {
2959        requests += 1;
2960        let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
2961            let sym = symbol_str.clone();
2962            let isolated = isolated.clone();
2963            Box::pin(async move {
2964                let builder = QueryMarginAccountsTradeListParams::builder(sym)
2965                    .is_isolated(isolated)
2966                    .limit(limit);
2967                let params = match query {
2968                    MarginTradesQuery::Window { start, end } => {
2969                        builder.start_time(start).end_time(end)
2970                    }
2971                    MarginTradesQuery::FromId(id) => builder.from_id(id),
2972                    MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
2973                    MarginTradesQuery::OrderFromId { order_id, from_id } => {
2974                        builder.order_id(order_id).from_id(from_id)
2975                    }
2976                }
2977                .build()?;
2978                rest.query_margin_accounts_trade_list(params).await
2979            })
2980        })
2981        .await
2982        .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2983
2984        let mut page = response.data().await.map_err(response_decode_error)?;
2985
2986        let page_len = page.len();
2987        let last_id = page.last().and_then(|t| t.id);
2988        // A span ends once a page reaches past it; paging by id would otherwise read on.
2989        let past_end = match from {
2990            MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
2991            MyTradesFrom::Time(_) | MyTradesFrom::Order(_) => false,
2992        };
2993        all_pages.extend(page);
2994        if past_end {
2995            break;
2996        }
2997
2998        if page_len > 0 && last_id.is_none() {
2999            warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
3000            break;
3001        }
3002        next = query.next(page_len, last_id, now_ms);
3003    }
3004    debug!(
3005        %instrument,
3006        requests,
3007        trades = all_pages.len(),
3008        "BinanceMargin paginate_my_trades: walk complete"
3009    );
3010    Ok(all_pages)
3011}
3012
3013// ---------------------------------------------------------------------------
3014// Account conversion helpers (margin-specific)
3015// ---------------------------------------------------------------------------
3016
3017/// Convert one cross-margin `userAsset` into a margin [`AssetBalance`], carrying the per-asset
3018/// `borrowed`/`interest` debt via [`Balance::new_margin`]. `total = free + locked`.
3019///
3020/// Distinct from spot's `convert_balance_entry`: spot has no debt fields and calls
3021/// [`Balance::new`]. Returns `None` (with a warning) if a required field is missing/unparseable.
3022fn convert_margin_balance_entry(
3023    b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
3024    now: DateTime<Utc>,
3025) -> Option<AssetBalance<AssetNameExchange>> {
3026    let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
3027    let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3028        Some(v) => v,
3029        None => {
3030            warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
3031            return None;
3032        }
3033    };
3034    let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3035        Some(v) => v,
3036        None => {
3037            warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
3038            return None;
3039        }
3040    };
3041    // Debt fields default to zero when missing/unparseable: a userAsset row always represents a
3042    // real margin position, so absent debt means "no debt", not corrupt data — emit a margin
3043    // Balance (carrying zero debt) rather than dropping the asset. This preserves the
3044    // Design-decision-#4 invariant that a REST snapshot always populates `margin`.
3045    let borrowed = b
3046        .borrowed
3047        .as_deref()
3048        .and_then(|s| Decimal::from_str(s).ok())
3049        .unwrap_or(Decimal::ZERO);
3050    let interest = b
3051        .interest
3052        .as_deref()
3053        .and_then(|s| Decimal::from_str(s).ok())
3054        .unwrap_or(Decimal::ZERO);
3055    Some(AssetBalance::new(
3056        asset_name,
3057        Balance::new_margin(free + locked, free, borrowed, interest),
3058        now,
3059    ))
3060}
3061
3062/// Filter cross-margin `userAssets` to the requested assets and convert to [`AssetBalance`]s.
3063/// An empty `assets` slice returns all. Mirrors spot's `filter_and_convert_balances`.
3064fn filter_and_convert_margin_balances(
3065    user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3066    assets: &[AssetNameExchange],
3067) -> Vec<AssetBalance<AssetNameExchange>> {
3068    let now = Utc::now();
3069    // Empty assets slice means "return all" — skip building the set entirely.
3070    if assets.is_empty() {
3071        return user_assets
3072            .into_iter()
3073            .filter_map(|b| convert_margin_balance_entry(b, now))
3074            .collect();
3075    }
3076    // For small slices (≤16 assets), linear scan avoids allocation and hashing overhead.
3077    if assets.len() <= 16 {
3078        return user_assets
3079            .into_iter()
3080            .filter_map(|b| {
3081                let asset_name_str = b.asset.as_deref()?;
3082                if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3083                    return None;
3084                }
3085                convert_margin_balance_entry(b, now)
3086            })
3087            .collect();
3088    }
3089    use std::collections::HashSet;
3090    let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3091    user_assets
3092        .into_iter()
3093        .filter_map(|b| {
3094            let asset_name_str = b.asset.as_deref()?;
3095            if !asset_set.contains(asset_name_str) {
3096                return None;
3097            }
3098            convert_margin_balance_entry(b, now)
3099        })
3100        .collect()
3101}
3102
3103/// Wrap a fetched `Open` order as an `OrderState::active` snapshot for `account_snapshot`.
3104///
3105/// `account_snapshot` reports open orders wrapped in `OrderState::active(..)`, whereas
3106/// `fetch_open_orders` returns the bare `Open` — both share
3107/// [`fetch_margin_open_orders_for_instrument`], so this is the single wrap used by the cross and
3108/// isolated snapshot paths.
3109fn active_order_snapshot(
3110    o: Order<ExchangeId, InstrumentNameExchange, Open>,
3111) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3112{
3113    Order {
3114        key: o.key,
3115        side: o.side,
3116        price: o.price,
3117        quantity: o.quantity,
3118        kind: o.kind,
3119        time_in_force: o.time_in_force,
3120        state: OrderState::active(o.state),
3121    }
3122}
3123
3124/// Convert one side (base or quote) of an isolated `assets[]` entry into a margin [`AssetBalance`].
3125///
3126/// Mirrors [`convert_margin_balance_entry`]'s field handling: `free`/`locked` are required (missing
3127/// → `None`, drop), while `borrowed`/`interest` default to zero when absent — a real isolated
3128/// sub-account row with absent debt means "no debt", not corrupt data, upholding the
3129/// Design-decision-#4 invariant that a REST snapshot always populates `margin`. `total = free + locked`.
3130///
3131/// Takes the fields rather than the SDK side type because the base and quote sides are distinct
3132/// nominal types (`...BaseAsset` / `...QuoteAsset`) with identical fields.
3133fn convert_isolated_asset_balance(
3134    asset: Option<&str>,
3135    free: Option<&str>,
3136    locked: Option<&str>,
3137    borrowed: Option<&str>,
3138    interest: Option<&str>,
3139    now: DateTime<Utc>,
3140) -> Option<AssetBalance<AssetNameExchange>> {
3141    let asset_name = AssetNameExchange::new(asset?);
3142    let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3143        Some(v) => v,
3144        None => {
3145            warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3146            return None;
3147        }
3148    };
3149    let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3150        Some(v) => v,
3151        None => {
3152            warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3153            return None;
3154        }
3155    };
3156    let borrowed = borrowed
3157        .and_then(|s| Decimal::from_str(s).ok())
3158        .unwrap_or(Decimal::ZERO);
3159    let interest = interest
3160        .and_then(|s| Decimal::from_str(s).ok())
3161        .unwrap_or(Decimal::ZERO);
3162    Some(AssetBalance::new(
3163        asset_name,
3164        Balance::new_margin(free + locked, free, borrowed, interest),
3165        now,
3166    ))
3167}
3168
3169/// Map isolated `query_isolated_margin_account_info` `assets[]` entries to per-instrument
3170/// [`IsolatedInstrumentState`], keyed by instrument (pair symbol).
3171///
3172/// An entry must carry a `symbol` plus a `baseAsset` and `quoteAsset` with parseable `free`/`locked`;
3173/// an entry missing any of these is dropped with a `warn!` (observable, never silently mis-mapped).
3174/// Per-pair risk metrics (`marginLevel`/`marginRatio`/`liquidatePrice`) are best-effort `Option`s — a
3175/// missing/unparseable risk field becomes `None` and does NOT drop the entry.
3176fn convert_isolated_margin_assets(
3177    assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3178) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3179    // Single timestamp for the whole batch: every entry came from the same REST response, so this
3180    // is the response's freshness (mirrors the cross-margin `convert_margin_balance_entry` `now`).
3181    let now = Utc::now();
3182    let mut map = HashMap::with_capacity(assets.len());
3183    for entry in assets {
3184        let Some(symbol) = entry.symbol.as_deref() else {
3185            warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3186            continue;
3187        };
3188        let instrument = InstrumentNameExchange::new(symbol);
3189
3190        let Some(base_raw) = entry.base_asset.as_deref() else {
3191            warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3192            continue;
3193        };
3194        let Some(quote_raw) = entry.quote_asset.as_deref() else {
3195            warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3196            continue;
3197        };
3198
3199        let Some(base) = convert_isolated_asset_balance(
3200            base_raw.asset.as_deref(),
3201            base_raw.free.as_deref(),
3202            base_raw.locked.as_deref(),
3203            base_raw.borrowed.as_deref(),
3204            base_raw.interest.as_deref(),
3205            now,
3206        ) else {
3207            warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3208            continue;
3209        };
3210        let Some(quote) = convert_isolated_asset_balance(
3211            quote_raw.asset.as_deref(),
3212            quote_raw.free.as_deref(),
3213            quote_raw.locked.as_deref(),
3214            quote_raw.borrowed.as_deref(),
3215            quote_raw.interest.as_deref(),
3216            now,
3217        ) else {
3218            warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3219            continue;
3220        };
3221
3222        let risk = IsolatedMarginRisk {
3223            margin_level: entry
3224                .margin_level
3225                .as_deref()
3226                .and_then(|s| Decimal::from_str(s).ok()),
3227            margin_ratio: entry
3228                .margin_ratio
3229                .as_deref()
3230                .and_then(|s| Decimal::from_str(s).ok()),
3231            liquidation_price: entry
3232                .liquidate_price
3233                .as_deref()
3234                .and_then(|s| Decimal::from_str(s).ok()),
3235        };
3236
3237        map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3238    }
3239    map
3240}
3241
3242/// Group symbols into comma-separated request strings, ≤5 symbols each (the venue's per-request
3243/// cap on `query_isolated_margin_account_info`'s `symbols`). An empty input yields no chunks.
3244fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3245    symbols
3246        .chunks(5)
3247        .map(|chunk| {
3248            chunk
3249                .iter()
3250                .map(|s| s.name().as_str())
3251                .collect::<Vec<_>>()
3252                .join(",")
3253        })
3254        .collect()
3255}
3256
3257/// Fetch isolated-margin account info for `symbols`, chunked at the venue's max-5-symbols/request
3258/// cap and flattened to the `assets[]` entries across all chunks.
3259///
3260/// `symbols` must be non-empty (a no-symbol isolated call is invalid). Chunks are fetched
3261/// concurrently (bounded to 8) through the shared rate-limit/backoff machinery.
3262async fn fetch_isolated_margin_account_info(
3263    rest: Arc<RestApi>,
3264    rate_limiter: Arc<RateLimitTracker>,
3265    symbols: Vec<InstrumentNameExchange>,
3266) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
3267    use futures::{StreamExt as _, TryStreamExt as _};
3268
3269    let chunks = chunk_symbols(&symbols);
3270
3271    // One `assets[]` entry per requested symbol, so pre-size the flat accumulator to `symbols.len()`
3272    // and `try_fold` the per-chunk results straight into it — avoiding the intermediate
3273    // `Vec<Vec<_>>` + re-copying `flatten().collect()` (mirrors the `try_fold` in `fetch_open_orders`).
3274    futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3275        let rest = rest.clone();
3276        let rate_limiter = rate_limiter.clone();
3277        async move {
3278            let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3279                let symbols_param = symbols_param.clone();
3280                Box::pin(async move {
3281                    let params = QueryIsolatedMarginAccountInfoParams::builder()
3282                        .symbols(symbols_param)
3283                        .build()?;
3284                    rest.query_isolated_margin_account_info(params).await
3285                })
3286            })
3287            .await
3288            .map_err(|e| classify_rest_query_error(&e, None))?;
3289
3290            let info = response.data().await.map_err(response_decode_error)?;
3291            Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3292        }
3293    }))
3294    .buffer_unordered(8)
3295    .try_fold(
3296        Vec::with_capacity(symbols.len()),
3297        |mut acc, assets| async move {
3298            acc.extend(assets);
3299            Ok(acc)
3300        },
3301    )
3302    .await
3303}
3304
3305/// Convert a margin trade-list response into a rustrade [`Trade`].
3306///
3307/// Field-for-field identical to spot's `convert_my_trade` but typed to the margin SDK response and
3308/// stamped with [`ExchangeId::BinanceMargin`]. Fee asset is taken verbatim from `commissionAsset`
3309/// (may be base, quote, or third-party e.g. BNB); `fees_quote` is left `None` for the indexer to
3310/// resolve.
3311fn convert_margin_trade(
3312    t: &QueryMarginAccountsTradeListResponseInner,
3313    instrument: &InstrumentNameExchange,
3314) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3315    let trade_id_raw = match t.id {
3316        Some(id) => id,
3317        None => {
3318            warn!(%instrument, "BinanceMargin trade missing id");
3319            return None;
3320        }
3321    };
3322    let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3323    let order_id = match t.order_id {
3324        Some(id) => OrderId(format_smolstr!("{id}")),
3325        None => {
3326            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3327            return None;
3328        }
3329    };
3330    let side = match t.is_buyer {
3331        Some(true) => Side::Buy,
3332        Some(false) => Side::Sell,
3333        None => {
3334            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3335            return None;
3336        }
3337    };
3338    let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3339        Some(v) => v,
3340        None => {
3341            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3342            return None;
3343        }
3344    };
3345    let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3346        Some(v) => v,
3347        None => {
3348            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3349            return None;
3350        }
3351    };
3352    let commission = t
3353        .commission
3354        .as_deref()
3355        .and_then(|s| Decimal::from_str(s).ok())
3356        .unwrap_or(Decimal::ZERO);
3357    let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3358        Some(ts) => ts,
3359        None => {
3360            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3361            Utc::now()
3362        }
3363    };
3364
3365    // Use actual commission asset from Binance (e.g., BNB, USDT, BTC). fees_quote is None here;
3366    // the indexer computes it when the fee is in quote or base asset. "UNKNOWN" fallback (rare:
3367    // API omits commissionAsset) will fail indexing rather than silently misattribute the fee.
3368    let fee_asset = t
3369        .commission_asset
3370        .as_deref()
3371        .map(AssetNameExchange::from)
3372        .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3373
3374    Some(Trade::new(
3375        trade_id,
3376        order_id,
3377        instrument.clone(),
3378        StrategyId::unknown(), // Binance doesn't carry strategy IDs
3379        time_exchange,
3380        side,
3381        price,
3382        quantity,
3383        // `myTrades` reports executions only -- no cumulative, no order status.
3384        None,
3385        AssetFees::new(fee_asset, commission, None),
3386    ))
3387}
3388
3389// ---------------------------------------------------------------------------
3390// Order conversion helpers (margin-specific)
3391// ---------------------------------------------------------------------------
3392
3393/// Failure modes when constructing [`MarginAccountNewOrderParams`] from a rustrade request.
3394#[derive(Debug)]
3395enum BuildOrderError {
3396    /// The `OrderKind`/`TimeInForce` combination is not supported on Binance margin.
3397    Unsupported,
3398    /// The SDK params builder rejected the inputs (carries the builder's error message).
3399    Build(String),
3400}
3401
3402/// Construct a margin endpoint's `isIsolated` parameter enum from the configured margin mode.
3403///
3404/// The SDK generates a *separate* two-variant `isIsolated` enum per endpoint, all with identical
3405/// `True`/`False` variants serialising to `"TRUE"`/`"FALSE"`. This trait keeps the one
3406/// `bool` → wire-value mapping defined (and unit-tested) in a single place, as the former
3407/// `isolated_str` helper did, rather than repeating a ternary at each call site.
3408///
3409/// `true` → `True` (`"TRUE"`, isolated, per-pair sub-accounts); `false` → `False` (`"FALSE"`,
3410/// cross, account-wide). Threaded through every margin order/cancel/query call so the mode is
3411/// config-driven from a single source rather than hardcoded per-call.
3412trait IsolatedFlag {
3413    /// The endpoint's `isIsolated` variant for `is_isolated`.
3414    fn from_flag(is_isolated: bool) -> Self;
3415}
3416
3417/// Implement [`IsolatedFlag`] for each endpoint's `isIsolated` enum.
3418///
3419/// Every one of these SDK enums is the same two-variant shape, so a macro keeps the impls honest:
3420/// adding an endpoint is one line here rather than a copy-pasted block that can silently invert.
3421macro_rules! impl_isolated_flag {
3422    ($($t:ty),* $(,)?) => {
3423        $(
3424            impl IsolatedFlag for $t {
3425                fn from_flag(is_isolated: bool) -> Self {
3426                    if is_isolated { Self::True } else { Self::False }
3427                }
3428            }
3429        )*
3430    };
3431}
3432
3433impl_isolated_flag!(
3434    MarginAccountNewOrderIsIsolatedEnum,
3435    MarginAccountCancelOrderIsIsolatedEnum,
3436    QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3437    QueryMarginAccountsTradeListIsIsolatedEnum,
3438);
3439
3440/// Build the margin cancel-order params (pure; no I/O).
3441///
3442/// Cancels by exchange `orderId` when present and parseable as `i64`, otherwise falls back to the
3443/// originating client order id (`origClientOrderId`). `isIsolated` is config-driven. Factored out of
3444/// [`BinanceMargin::cancel_order`] so the id-vs-cid fallback branch and the `isIsolated` value are
3445/// unit-testable without a live REST call. Returns the builder's error message (stringified) on the
3446/// rare build failure (e.g. a malformed symbol).
3447fn build_cancel_order_params(
3448    symbol: String,
3449    id: Option<&OrderId>,
3450    cid: &ClientOrderId,
3451    is_isolated: bool,
3452) -> Result<MarginAccountCancelOrderParams, String> {
3453    let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3454        MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3455    );
3456
3457    match id {
3458        Some(order_id) => match order_id.0.parse::<i64>() {
3459            Ok(id) => builder = builder.order_id(id),
3460            Err(_) => {
3461                // exchange order id exists but isn't a valid i64 — fall back to the cid.
3462                error!(
3463                    order_id = %order_id.0,
3464                    "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3465                );
3466                builder = builder.orig_client_order_id(cid.0.to_string());
3467            }
3468        },
3469        None => builder = builder.orig_client_order_id(cid.0.to_string()),
3470    }
3471
3472    builder.build().map_err(|e| e.to_string())
3473}
3474
3475/// Build the margin new-order params from a rustrade order request (pure; no I/O).
3476///
3477/// Factored out of [`BinanceMargin::open_order`] so the rustrade→Binance mapping (sideEffectType,
3478/// `isIsolated`, conditional `stopPrice`, `autoRepayAtCancel` gating, trailing rejection) is
3479/// unit-testable without a live REST call. `isIsolated` is config-driven via `is_isolated`
3480/// (`true` = isolated, `false` = cross).
3481#[allow(clippy::too_many_arguments)] // mirrors the flat request fields; grouping them into a
3482// struct would just shuffle the same data and obscure the 1:1 mapping to SDK params.
3483fn build_new_order_params(
3484    symbol: String,
3485    side: Side,
3486    price: Option<Decimal>,
3487    quantity: Decimal,
3488    kind: OrderKind,
3489    time_in_force: TimeInForce,
3490    new_client_order_id: String,
3491    side_effect: MarginSideEffect,
3492    is_isolated: bool,
3493) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3494    let binance_side = match side {
3495        Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3496        Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3497    };
3498
3499    let (binance_type, binance_tif) =
3500        convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3501
3502    // isIsolated is config-driven: "TRUE" for isolated, "FALSE" for cross.
3503    let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3504        .quantity(quantity)
3505        .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3506        .side_effect_type(side_effect.as_sdk_enum())
3507        .new_client_order_id(new_client_order_id)
3508        .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3509
3510    // auto_repay_at_cancel only coheres under AutoBorrowRepay: a NoBorrow client never borrows,
3511    // so there is no loan to repay when an order is cancelled.
3512    if side_effect == MarginSideEffect::AutoBorrowRepay {
3513        builder = builder.auto_repay_at_cancel(true);
3514    }
3515
3516    if let Some(tif) = binance_tif {
3517        builder = builder.time_in_force(tif);
3518    }
3519
3520    // Conditional price fields (mirror spot): LIMIT carries price; STOP/TAKE_PROFIT carry a stop
3521    // (trigger) price; the *_LIMIT variants carry both.
3522    match kind {
3523        OrderKind::Limit => {
3524            builder = builder.price(price);
3525        }
3526        OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3527            builder = builder.stop_price(trigger_price);
3528        }
3529        OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3530            builder = builder.price(price).stop_price(trigger_price);
3531        }
3532        // Market carries no price/stop_price; trailing-stop kinds are rejected earlier by
3533        // convert_order_kind_tif_margin. A future OrderKind needing price fields that lands here
3534        // would surface as a BuildOrderError::Build from the SDK builder, not a silent bad order.
3535        _ => {}
3536    }
3537
3538    builder
3539        .build()
3540        .map_err(|e| BuildOrderError::Build(e.to_string()))
3541}
3542
3543/// Map a rustrade [`OrderKind`] + [`TimeInForce`] to the margin SDK's order `type`/`timeInForce`.
3544///
3545/// Reuses the shared [`classify_order_kind_tif`] decision logic, mapping only the venue-neutral
3546/// result onto margin's SDK output types — a [`MarginAccountNewOrderTypeEnum`] and a
3547/// [`MarginAccountNewOrderTimeInForceEnum`]. Mirrors spot's own `convert_order_kind_tif` adapter.
3548///
3549/// Returns `None` for unsupported combinations. Trailing-stop kinds are rejected here (the margin
3550/// SDK has no `trailingDelta` binding), unlike spot which maps them to a `STOP_LOSS` with
3551/// `trailingDelta`.
3552fn convert_order_kind_tif_margin(
3553    kind: OrderKind,
3554    tif: TimeInForce,
3555) -> Option<(
3556    MarginAccountNewOrderTypeEnum,
3557    Option<MarginAccountNewOrderTimeInForceEnum>,
3558)> {
3559    if matches!(
3560        kind,
3561        OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. }
3562    ) {
3563        warn!(
3564            ?kind,
3565            "BinanceMargin does not support trailing-stop orders (SDK trailingDelta binding gap)"
3566        );
3567        return None;
3568    }
3569
3570    let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3571    let margin_type = match binance_type {
3572        BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3573        BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3574        BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3575        BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3576        BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3577        BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3578        BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3579    };
3580    let margin_tif = binance_tif.map(|t| match t {
3581        BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3582        BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3583        BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3584    });
3585    Some((margin_type, margin_tif))
3586}
3587
3588/// Volume-weighted average fill price from a margin order response's cumulative quote quantity.
3589///
3590/// `avg_price = cummulative_quote_qty / executed_qty`. Returns `None` when `filled_qty` is zero
3591/// (no fills, or division would be undefined) or the quote quantity is missing/unparseable.
3592// `cummulative_quote_qty` keeps Binance's own field-name typo (sic, double-m) to mirror the SDK.
3593fn margin_avg_price(cummulative_quote_qty: Option<&str>, filled_qty: Decimal) -> Option<Decimal> {
3594    if filled_qty.is_zero() {
3595        return None;
3596    }
3597    let s = cummulative_quote_qty?;
3598    match Decimal::from_str(s) {
3599        Ok(cumulative) => cumulative.checked_div(filled_qty),
3600        Err(_) => {
3601            // A filled order with an unparseable cumulative quote qty is corrupt data, not an
3602            // expected absence — log it rather than silently returning a price-less Filled.
3603            warn!(
3604                cummulative_quote_qty = s,
3605                "BinanceMargin: failed to parse cummulativeQuoteQty; avg price unavailable"
3606            );
3607            None
3608        }
3609    }
3610}
3611
3612#[cfg(test)]
3613#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
3614mod tests {
3615    use super::*;
3616    use crate::client::binance::shared::convert_open_order;
3617    use crate::order::state::ActiveOrderState;
3618    use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
3619
3620    #[test]
3621    fn margin_side_effect_default_is_auto_borrow_repay() {
3622        assert_eq!(
3623            MarginSideEffect::default(),
3624            MarginSideEffect::AutoBorrowRepay
3625        );
3626    }
3627
3628    #[test]
3629    fn margin_side_effect_wire_strings() {
3630        assert_eq!(
3631            MarginSideEffect::AutoBorrowRepay.as_binance_str(),
3632            "AUTO_BORROW_REPAY"
3633        );
3634        assert_eq!(
3635            MarginSideEffect::NoBorrow.as_binance_str(),
3636            "NO_SIDE_EFFECT"
3637        );
3638    }
3639
3640    #[test]
3641    fn margin_side_effect_serde_round_trip() {
3642        // serde/config-file form is snake_case, distinct from the Binance wire string (as_binance_str).
3643        assert_eq!(
3644            serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
3645            r#""auto_borrow_repay""#
3646        );
3647        assert_eq!(
3648            serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
3649            r#""no_borrow""#
3650        );
3651        assert_eq!(
3652            serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
3653            MarginSideEffect::AutoBorrowRepay
3654        );
3655        assert_eq!(
3656            serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
3657            MarginSideEffect::NoBorrow
3658        );
3659    }
3660
3661    #[test]
3662    fn config_debug_redacts_secrets() {
3663        let config = BinanceMarginConfig::new(
3664            "my_api_key".to_string(),
3665            "my_secret_key".to_string(),
3666            false,
3667            false,
3668            Vec::new(),
3669            MarginSideEffect::default(),
3670        );
3671        let debug = format!("{config:?}");
3672        assert!(!debug.contains("my_api_key"));
3673        assert!(!debug.contains("my_secret_key"));
3674        assert!(debug.contains("***"));
3675    }
3676
3677    #[test]
3678    fn cross_margin_uses_common_case_defaults() {
3679        let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3680        assert!(!config.testnet);
3681        assert!(!config.is_isolated);
3682        assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3683        assert_eq!(config.api_key(), "k");
3684    }
3685
3686    #[test]
3687    fn config_deserializes_with_defaults() {
3688        // is_isolated and side_effect default when omitted; testnet has no default and must be present.
3689        let config: BinanceMarginConfig =
3690            serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3691                .expect("deserialize");
3692        assert!(!config.is_isolated);
3693        assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3694    }
3695
3696    #[test]
3697    fn config_deserializes_explicit_side_effect() {
3698        let config: BinanceMarginConfig = serde_json::from_str(
3699            r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
3700        )
3701        .expect("deserialize");
3702        assert!(config.is_isolated);
3703        assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
3704    }
3705
3706    #[test]
3707    fn isolated_ctor_sets_symbols_and_flag() {
3708        let symbols = vec![
3709            InstrumentNameExchange::new("BTCUSDT"),
3710            InstrumentNameExchange::new("ETHUSDT"),
3711        ];
3712        let config =
3713            BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
3714        assert!(config.is_isolated);
3715        assert_eq!(config.isolated_symbols, symbols);
3716        assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3717    }
3718
3719    #[test]
3720    fn isolated_config_with_symbols_constructs() {
3721        // The construction gate only rejects isolated + *empty* symbols; a populated set is fine.
3722        let config = BinanceMarginConfig::isolated(
3723            "k".to_string(),
3724            "s".to_string(),
3725            vec![InstrumentNameExchange::new("BTCUSDT")],
3726        );
3727        let _client = BinanceMargin::new(config); // must not panic
3728    }
3729
3730    #[test]
3731    #[should_panic(expected = "non-empty isolated_symbols")]
3732    fn isolated_empty_symbols_panics_at_new() {
3733        // is_isolated = true with no isolated_symbols is an unusable config → fail-fast panic.
3734        let config = BinanceMarginConfig::new(
3735            "k".to_string(),
3736            "s".to_string(),
3737            false,
3738            true,
3739            Vec::new(),
3740            MarginSideEffect::default(),
3741        );
3742        let _ = BinanceMargin::new(config);
3743    }
3744
3745    #[test]
3746    #[should_panic(expected = "non-empty isolated_symbols")]
3747    fn isolated_empty_symbols_panics_via_deserialize_path() {
3748        // The gate must also cover the Deserialize-only path (a config file with is_isolated = true
3749        // and no isolated_symbols bypasses the named constructor).
3750        let config: BinanceMarginConfig = serde_json::from_str(
3751            r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
3752        )
3753        .expect("deserialize");
3754        assert!(config.isolated_symbols.is_empty());
3755        let _ = BinanceMargin::new(config);
3756    }
3757
3758    #[test]
3759    fn isolated_flag_maps_mode() {
3760        // One assertion per endpoint enum: the SDK generates a separate `isIsolated` type for
3761        // each, so an inverted arm in any single impl would otherwise go unnoticed.
3762        assert_eq!(
3763            MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
3764            "TRUE"
3765        );
3766        assert_eq!(
3767            MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
3768            "FALSE"
3769        );
3770        assert_eq!(
3771            MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
3772            "TRUE"
3773        );
3774        assert_eq!(
3775            MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
3776            "FALSE"
3777        );
3778        assert_eq!(
3779            QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
3780            "TRUE"
3781        );
3782        assert_eq!(
3783            QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
3784            "FALSE"
3785        );
3786        assert_eq!(
3787            QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
3788            "TRUE"
3789        );
3790        assert_eq!(
3791            QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
3792            "FALSE"
3793        );
3794    }
3795
3796    // -----------------------------------------------------------------------
3797    // Cancel param mapping (orderId-vs-cid fallback + config-driven isIsolated)
3798    // -----------------------------------------------------------------------
3799
3800    #[test]
3801    fn cancel_params_cross_with_parseable_order_id() {
3802        let p = build_cancel_order_params(
3803            "BTCUSDT".to_string(),
3804            Some(&OrderId::new("12345")),
3805            &ClientOrderId::new("cid-1"),
3806            false,
3807        )
3808        .expect("build");
3809        assert_eq!(p.symbol, "BTCUSDT");
3810        assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
3811        assert_eq!(p.order_id, Some(12345));
3812        assert_eq!(p.orig_client_order_id, None);
3813    }
3814
3815    #[test]
3816    fn cancel_params_isolated_is_config_driven() {
3817        let p = build_cancel_order_params(
3818            "BTCUSDT".to_string(),
3819            Some(&OrderId::new("12345")),
3820            &ClientOrderId::new("cid-1"),
3821            true,
3822        )
3823        .expect("build");
3824        assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
3825        assert_eq!(p.order_id, Some(12345));
3826    }
3827
3828    #[test]
3829    fn cancel_params_non_i64_order_id_falls_back_to_cid() {
3830        let p = build_cancel_order_params(
3831            "BTCUSDT".to_string(),
3832            Some(&OrderId::new("not-an-i64")),
3833            &ClientOrderId::new("cid-1"),
3834            false,
3835        )
3836        .expect("build");
3837        assert_eq!(p.order_id, None);
3838        assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3839    }
3840
3841    #[test]
3842    fn cancel_params_absent_order_id_uses_cid() {
3843        let p = build_cancel_order_params(
3844            "BTCUSDT".to_string(),
3845            None,
3846            &ClientOrderId::new("cid-1"),
3847            false,
3848        )
3849        .expect("build");
3850        assert_eq!(p.order_id, None);
3851        assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3852    }
3853
3854    // -----------------------------------------------------------------------
3855    // Order param mapping (17.4.x)
3856    // -----------------------------------------------------------------------
3857
3858    use crate::order::TrailingOffsetType;
3859
3860    /// Build params for the common case (cross), overriding only what a test cares about.
3861    fn params(
3862        side: Side,
3863        price: Option<Decimal>,
3864        kind: OrderKind,
3865        tif: TimeInForce,
3866        side_effect: MarginSideEffect,
3867    ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3868        build_new_order_params(
3869            "BTCUSDT".to_string(),
3870            side,
3871            price,
3872            Decimal::from(2),
3873            kind,
3874            tif,
3875            "cid-1".to_string(),
3876            side_effect,
3877            false, // cross
3878        )
3879    }
3880
3881    fn gtc() -> TimeInForce {
3882        TimeInForce::GoodUntilCancelled { post_only: false }
3883    }
3884
3885    #[test]
3886    fn new_order_limit_maps_core_fields() {
3887        let p = params(
3888            Side::Buy,
3889            Some(Decimal::from(50_000)),
3890            OrderKind::Limit,
3891            gtc(),
3892            MarginSideEffect::AutoBorrowRepay,
3893        )
3894        .expect("build");
3895
3896        assert_eq!(p.symbol, "BTCUSDT");
3897        assert_eq!(p.side.as_str(), "BUY");
3898        assert_eq!(p.r#type.as_str(), "LIMIT");
3899        assert_eq!(p.quantity, Some(Decimal::from(2)));
3900        assert_eq!(p.price, Some(Decimal::from(50_000)));
3901        assert_eq!(p.stop_price, None);
3902        assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
3903        assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
3904        // FULL response so immediate fills come back in the order response.
3905        assert_eq!(
3906            p.new_order_resp_type.as_ref().map(|r| r.as_str()),
3907            Some("FULL")
3908        );
3909    }
3910
3911    #[test]
3912    fn new_order_is_isolated_is_config_driven() {
3913        // Cross (is_isolated = false) → "FALSE".
3914        let cross = build_new_order_params(
3915            "BTCUSDT".to_string(),
3916            Side::Sell,
3917            Some(Decimal::from(10)),
3918            Decimal::from(2),
3919            OrderKind::Limit,
3920            gtc(),
3921            "cid-1".to_string(),
3922            MarginSideEffect::AutoBorrowRepay,
3923            false,
3924        )
3925        .expect("build");
3926        assert_eq!(
3927            cross.is_isolated.as_ref().map(|i| i.as_str()),
3928            Some("FALSE")
3929        );
3930        assert_eq!(cross.side.as_str(), "SELL");
3931
3932        // Isolated (is_isolated = true) → "TRUE".
3933        let isolated = build_new_order_params(
3934            "BTCUSDT".to_string(),
3935            Side::Sell,
3936            Some(Decimal::from(10)),
3937            Decimal::from(2),
3938            OrderKind::Limit,
3939            gtc(),
3940            "cid-1".to_string(),
3941            MarginSideEffect::AutoBorrowRepay,
3942            true,
3943        )
3944        .expect("build");
3945        assert_eq!(
3946            isolated.is_isolated.as_ref().map(|i| i.as_str()),
3947            Some("TRUE")
3948        );
3949    }
3950
3951    #[test]
3952    fn side_effect_and_auto_repay_gating() {
3953        // AutoBorrowRepay → AUTO_BORROW_REPAY + auto_repay_at_cancel(true).
3954        let auto = params(
3955            Side::Buy,
3956            Some(Decimal::from(1)),
3957            OrderKind::Limit,
3958            gtc(),
3959            MarginSideEffect::AutoBorrowRepay,
3960        )
3961        .expect("build");
3962        assert_eq!(
3963            auto.side_effect_type.as_ref().map(|s| s.as_str()),
3964            Some("AUTO_BORROW_REPAY")
3965        );
3966        assert_eq!(auto.auto_repay_at_cancel, Some(true));
3967
3968        // NoBorrow → NO_SIDE_EFFECT and NO auto_repay_at_cancel (no loan to repay).
3969        let no_borrow = params(
3970            Side::Buy,
3971            Some(Decimal::from(1)),
3972            OrderKind::Limit,
3973            gtc(),
3974            MarginSideEffect::NoBorrow,
3975        )
3976        .expect("build");
3977        assert_eq!(
3978            no_borrow.side_effect_type.as_ref().map(|s| s.as_str()),
3979            Some("NO_SIDE_EFFECT")
3980        );
3981        assert_eq!(no_borrow.auto_repay_at_cancel, None);
3982    }
3983
3984    #[test]
3985    fn new_order_market_has_no_price_or_tif() {
3986        let p = params(
3987            Side::Buy,
3988            None,
3989            OrderKind::Market,
3990            gtc(),
3991            MarginSideEffect::AutoBorrowRepay,
3992        )
3993        .expect("build");
3994        assert_eq!(p.r#type.as_str(), "MARKET");
3995        assert_eq!(p.price, None);
3996        assert_eq!(p.stop_price, None);
3997        assert!(p.time_in_force.is_none());
3998    }
3999
4000    #[test]
4001    fn post_only_limit_maps_to_limit_maker() {
4002        let p = params(
4003            Side::Buy,
4004            Some(Decimal::from(10)),
4005            OrderKind::Limit,
4006            TimeInForce::GoodUntilCancelled { post_only: true },
4007            MarginSideEffect::AutoBorrowRepay,
4008        )
4009        .expect("build");
4010        assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
4011        // LIMIT_MAKER carries no timeInForce (post-only is the type, not a TIF).
4012        assert!(p.time_in_force.is_none());
4013    }
4014
4015    #[test]
4016    fn conditional_kinds_set_stop_price() {
4017        let trigger = Decimal::from(48_000);
4018
4019        let stop = params(
4020            Side::Sell,
4021            None,
4022            OrderKind::Stop {
4023                trigger_price: trigger,
4024            },
4025            gtc(),
4026            MarginSideEffect::AutoBorrowRepay,
4027        )
4028        .expect("build");
4029        assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
4030        assert_eq!(stop.stop_price, Some(trigger));
4031        assert_eq!(stop.price, None);
4032
4033        let stop_limit = params(
4034            Side::Sell,
4035            Some(Decimal::from(47_900)),
4036            OrderKind::StopLimit {
4037                trigger_price: trigger,
4038            },
4039            gtc(),
4040            MarginSideEffect::AutoBorrowRepay,
4041        )
4042        .expect("build");
4043        assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4044        assert_eq!(stop_limit.stop_price, Some(trigger));
4045        assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4046        assert_eq!(
4047            stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4048            Some("GTC")
4049        );
4050
4051        let take_profit = params(
4052            Side::Sell,
4053            None,
4054            OrderKind::TakeProfit {
4055                trigger_price: trigger,
4056            },
4057            gtc(),
4058            MarginSideEffect::AutoBorrowRepay,
4059        )
4060        .expect("build");
4061        assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
4062        assert_eq!(take_profit.stop_price, Some(trigger));
4063    }
4064
4065    #[test]
4066    fn trailing_kinds_are_rejected() {
4067        let trailing_stop = params(
4068            Side::Sell,
4069            None,
4070            OrderKind::TrailingStop {
4071                offset: Decimal::from(100),
4072                offset_type: TrailingOffsetType::BasisPoints,
4073            },
4074            gtc(),
4075            MarginSideEffect::AutoBorrowRepay,
4076        );
4077        assert!(matches!(trailing_stop, Err(BuildOrderError::Unsupported)));
4078
4079        let trailing_stop_limit = params(
4080            Side::Sell,
4081            Some(Decimal::from(100)),
4082            OrderKind::TrailingStopLimit {
4083                offset: Decimal::from(100),
4084                offset_type: TrailingOffsetType::BasisPoints,
4085                limit_offset: Decimal::from(10),
4086            },
4087            gtc(),
4088            MarginSideEffect::AutoBorrowRepay,
4089        );
4090        assert!(matches!(
4091            trailing_stop_limit,
4092            Err(BuildOrderError::Unsupported)
4093        ));
4094    }
4095
4096    #[test]
4097    fn tif_variants_map_to_margin_enum() {
4098        for (tif, expected) in [
4099            (TimeInForce::ImmediateOrCancel, "IOC"),
4100            (TimeInForce::FillOrKill, "FOK"),
4101            (gtc(), "GTC"),
4102        ] {
4103            let p = params(
4104                Side::Buy,
4105                Some(Decimal::from(1)),
4106                OrderKind::Limit,
4107                tif,
4108                MarginSideEffect::AutoBorrowRepay,
4109            )
4110            .expect("build");
4111            assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4112        }
4113    }
4114
4115    #[test]
4116    fn avg_price_from_cumulative_quote_qty() {
4117        // 100 quote / 4 base = 25.
4118        assert_eq!(
4119            margin_avg_price(Some("100"), Decimal::from(4)),
4120            Some(Decimal::from(25))
4121        );
4122        // Zero fill → no average (avoids division by zero).
4123        assert_eq!(margin_avg_price(Some("100"), Decimal::ZERO), None);
4124        // Missing / unparseable quote qty → None.
4125        assert_eq!(margin_avg_price(None, Decimal::from(4)), None);
4126        assert_eq!(
4127            margin_avg_price(Some("not-a-number"), Decimal::from(4)),
4128            None
4129        );
4130    }
4131
4132    // -----------------------------------------------------------------------
4133    // Account query converters (17.5.x)
4134    // -----------------------------------------------------------------------
4135
4136    fn user_asset(
4137        asset: &str,
4138        free: &str,
4139        locked: &str,
4140        borrowed: &str,
4141        interest: &str,
4142    ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4143        let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4144        a.asset = Some(asset.to_string());
4145        a.free = Some(free.to_string());
4146        a.locked = Some(locked.to_string());
4147        a.borrowed = Some(borrowed.to_string());
4148        a.interest = Some(interest.to_string());
4149        a
4150    }
4151
4152    #[test]
4153    fn margin_balance_entry_maps_debt() {
4154        // free=10, locked=2 → total=12; borrowed=3, interest=0.5 → net_asset = total - borrowed = 9.
4155        let ab =
4156            convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4157                .expect("convert");
4158        assert_eq!(ab.asset.name().as_str(), "USDT");
4159        assert_eq!(ab.balance.total, Decimal::from(12));
4160        assert_eq!(ab.balance.free, Decimal::from(10));
4161        assert!(
4162            ab.balance.margin.is_some(),
4163            "REST snapshot must populate margin"
4164        );
4165        // net_asset deducts borrowed principal (interest is tracked separately, not deducted here).
4166        assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4167    }
4168
4169    #[test]
4170    fn margin_balance_short_is_negative_net() {
4171        // A short borrows more base than it holds: total=1, borrowed=5 → net_asset = -4.
4172        let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4173            .expect("convert");
4174        assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4175    }
4176
4177    #[test]
4178    fn margin_balance_missing_debt_defaults_to_zero() {
4179        // A userAsset with no borrowed/interest is a real no-debt position, not corrupt data:
4180        // still emit a margin Balance (carrying zero debt) so the REST snapshot always sets margin.
4181        let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4182        a.asset = Some("ETH".to_string());
4183        a.free = Some("4".to_string());
4184        a.locked = Some("0".to_string());
4185        let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4186        assert!(ab.balance.margin.is_some());
4187        assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4188    }
4189
4190    #[test]
4191    fn margin_balance_missing_free_is_dropped() {
4192        // Missing free/locked is corrupt (not no-debt) → drop the asset rather than guess.
4193        let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4194        a.asset = Some("USDT".to_string());
4195        a.locked = Some("0".to_string());
4196        assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4197    }
4198
4199    #[test]
4200    fn margin_balance_filtering() {
4201        let assets = vec![
4202            user_asset("USDT", "10", "0", "0", "0"),
4203            user_asset("BTC", "1", "0", "0", "0"),
4204            user_asset("ETH", "5", "0", "0", "0"),
4205        ];
4206        // Empty filter → all assets.
4207        assert_eq!(
4208            filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4209            3
4210        );
4211        // Subset filter → only requested assets.
4212        let filtered = filter_and_convert_margin_balances(
4213            assets,
4214            &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4215        );
4216        assert_eq!(filtered.len(), 2);
4217        assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4218    }
4219
4220    #[test]
4221    fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4222        // >16 requested assets takes the HashSet path (vs the ≤16 linear scan) — exercise it.
4223        let assets = vec![
4224            user_asset("BTC", "1", "0", "0", "0"),
4225            user_asset("ETH", "5", "0", "0", "0"),
4226            user_asset("DOGE", "9", "0", "0", "0"), // present in account, not requested → dropped
4227        ];
4228        // 17 requested assets (A00..A16) forces the HashSet branch; BTC and ETH also requested.
4229        let mut requested: Vec<AssetNameExchange> = (0..17)
4230            .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4231            .collect();
4232        requested.push(AssetNameExchange::new("BTC"));
4233        requested.push(AssetNameExchange::new("ETH"));
4234        assert!(
4235            requested.len() > 16,
4236            "must exceed the linear-scan threshold"
4237        );
4238
4239        let filtered = filter_and_convert_margin_balances(assets, &requested);
4240        assert_eq!(filtered.len(), 2);
4241        assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4242    }
4243
4244    // -----------------------------------------------------------------------
4245    // Isolated account snapshot converters + query scoping
4246    // -----------------------------------------------------------------------
4247
4248    use binance_sdk::margin_trading::rest_api::{
4249        QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4250        QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4251    };
4252
4253    fn d(s: &str) -> Decimal {
4254        Decimal::from_str(s).unwrap()
4255    }
4256
4257    fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4258        let mut b = IsoBase::new();
4259        b.asset = Some(asset.to_string());
4260        b.free = Some(free.to_string());
4261        b.locked = Some(locked.to_string());
4262        b.borrowed = Some(borrowed.to_string());
4263        b.interest = Some(interest.to_string());
4264        b
4265    }
4266
4267    fn iso_quote(
4268        asset: &str,
4269        free: &str,
4270        locked: &str,
4271        borrowed: &str,
4272        interest: &str,
4273    ) -> IsoQuote {
4274        let mut q = IsoQuote::new();
4275        q.asset = Some(asset.to_string());
4276        q.free = Some(free.to_string());
4277        q.locked = Some(locked.to_string());
4278        q.borrowed = Some(borrowed.to_string());
4279        q.interest = Some(interest.to_string());
4280        q
4281    }
4282
4283    fn iso_entry(
4284        symbol: &str,
4285        base: IsoBase,
4286        quote: IsoQuote,
4287        margin_level: Option<&str>,
4288        margin_ratio: Option<&str>,
4289        liquidate_price: Option<&str>,
4290    ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4291        let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4292        e.symbol = Some(symbol.to_string());
4293        e.base_asset = Some(Box::new(base));
4294        e.quote_asset = Some(Box::new(quote));
4295        e.margin_level = margin_level.map(str::to_string);
4296        e.margin_ratio = margin_ratio.map(str::to_string);
4297        e.liquidate_price = liquidate_price.map(str::to_string);
4298        e
4299    }
4300
4301    fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4302        let config = BinanceMarginConfig::isolated(
4303            "k".to_string(),
4304            "s".to_string(),
4305            symbols
4306                .iter()
4307                .map(|s| InstrumentNameExchange::new(*s))
4308                .collect(),
4309        );
4310        BinanceMargin::new(config)
4311    }
4312
4313    #[test]
4314    fn isolated_assets_map_base_quote_and_risk() {
4315        // base: free 0.5 + locked 0.1 = total 0.6, borrowed 0.2 → net 0.4.
4316        // quote: free 1000 + locked 50 = total 1050, borrowed 300 → net 750.
4317        let entry = iso_entry(
4318            "BTCUSDT",
4319            iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4320            iso_quote("USDT", "1000", "50", "300", "1.5"),
4321            Some("3.5"),
4322            Some("0.12"),
4323            Some("48000"),
4324        );
4325        let map = convert_isolated_margin_assets(vec![entry]);
4326        let state = map
4327            .get(&InstrumentNameExchange::new("BTCUSDT"))
4328            .expect("entry present");
4329
4330        assert_eq!(state.base.asset.name().as_str(), "BTC");
4331        assert_eq!(state.base.balance.total, d("0.6"));
4332        assert_eq!(state.base.balance.free, d("0.5"));
4333        assert_eq!(state.base.balance.net_asset(), d("0.4"));
4334
4335        assert_eq!(state.quote.asset.name().as_str(), "USDT");
4336        assert_eq!(state.quote.balance.total, d("1050"));
4337        assert_eq!(state.quote.balance.net_asset(), d("750"));
4338
4339        assert_eq!(state.risk.margin_level, Some(d("3.5")));
4340        assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4341        assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4342    }
4343
4344    #[test]
4345    fn isolated_base_short_is_negative_net() {
4346        // A short on the base side: total 0, borrowed 1.5 → net_asset = -1.5.
4347        let entry = iso_entry(
4348            "BTCUSDT",
4349            iso_base("BTC", "0", "0", "1.5", "0.001"),
4350            iso_quote("USDT", "100", "0", "0", "0"),
4351            None,
4352            None,
4353            None,
4354        );
4355        let map = convert_isolated_margin_assets(vec![entry]);
4356        let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4357        assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4358    }
4359
4360    #[test]
4361    fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4362        // No borrowed/interest = real no-debt position → margin still populated (zero debt), not
4363        // dropped. Absent risk fields → None, but the snapshot is still produced.
4364        let mut base = IsoBase::new();
4365        base.asset = Some("BTC".to_string());
4366        base.free = Some("0.5".to_string());
4367        base.locked = Some("0".to_string());
4368        let mut quote = IsoQuote::new();
4369        quote.asset = Some("USDT".to_string());
4370        quote.free = Some("100".to_string());
4371        quote.locked = Some("0".to_string());
4372
4373        let map = convert_isolated_margin_assets(vec![iso_entry(
4374            "BTCUSDT", base, quote, None, None, None,
4375        )]);
4376        let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4377        assert!(state.base.balance.margin.is_some());
4378        assert_eq!(state.base.balance.net_asset(), d("0.5"));
4379        assert_eq!(state.risk.margin_level, None);
4380        assert_eq!(state.risk.liquidation_price, None);
4381    }
4382
4383    #[test]
4384    fn isolated_unparseable_risk_is_none_but_entry_kept() {
4385        let entry = iso_entry(
4386            "BTCUSDT",
4387            iso_base("BTC", "1", "0", "0", "0"),
4388            iso_quote("USDT", "1", "0", "0", "0"),
4389            Some("not_a_number"),
4390            None,
4391            None,
4392        );
4393        let map = convert_isolated_margin_assets(vec![entry]);
4394        let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4395        assert_eq!(state.risk.margin_level, None);
4396    }
4397
4398    #[test]
4399    fn isolated_missing_base_free_drops_entry() {
4400        // Missing free/locked is corrupt (not no-debt) → drop the whole pair entry.
4401        let mut base = IsoBase::new();
4402        base.asset = Some("BTC".to_string());
4403        base.locked = Some("0".to_string()); // no free
4404        let entry = iso_entry(
4405            "BTCUSDT",
4406            base,
4407            iso_quote("USDT", "100", "0", "0", "0"),
4408            None,
4409            None,
4410            None,
4411        );
4412        assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4413    }
4414
4415    #[test]
4416    fn isolated_missing_symbol_drops_entry() {
4417        let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4418        e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4419        e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4420        // no symbol set
4421        assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4422    }
4423
4424    #[test]
4425    fn chunk_symbols_batches_by_five() {
4426        let syms: Vec<InstrumentNameExchange> = (0..12)
4427            .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4428            .collect();
4429        let chunks = chunk_symbols(&syms);
4430        assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4431        assert_eq!(chunks[0].split(',').count(), 5);
4432        assert_eq!(chunks[1].split(',').count(), 5);
4433        assert_eq!(chunks[2].split(',').count(), 2);
4434
4435        // ≤5 → a single chunk.
4436        let three: Vec<_> = (0..3)
4437            .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4438            .collect();
4439        assert_eq!(chunk_symbols(&three).len(), 1);
4440
4441        // exactly 5 → a single chunk of 5.
4442        let five: Vec<_> = (0..5)
4443            .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4444            .collect();
4445        let c = chunk_symbols(&five);
4446        assert_eq!(c.len(), 1);
4447        assert_eq!(c[0].split(',').count(), 5);
4448
4449        // empty → no chunks (never a no-symbol call).
4450        assert!(chunk_symbols(&[]).is_empty());
4451    }
4452
4453    #[test]
4454    fn effective_isolated_set_empty_returns_all_configured() {
4455        let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4456        assert_eq!(
4457            client.effective_isolated_set(&[]),
4458            vec![
4459                InstrumentNameExchange::new("BTCUSDT"),
4460                InstrumentNameExchange::new("ETHUSDT"),
4461            ]
4462        );
4463    }
4464
4465    #[test]
4466    fn effective_isolated_set_intersects_requested() {
4467        let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4468        assert_eq!(
4469            client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4470            vec![InstrumentNameExchange::new("ETHUSDT")]
4471        );
4472    }
4473
4474    #[test]
4475    fn effective_isolated_set_skips_out_of_set() {
4476        let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4477        // DOGEUSDT is not configured → skipped (warn), only BTCUSDT survives.
4478        assert_eq!(
4479            client.effective_isolated_set(&[
4480                InstrumentNameExchange::new("BTCUSDT"),
4481                InstrumentNameExchange::new("DOGEUSDT"),
4482            ]),
4483            vec![InstrumentNameExchange::new("BTCUSDT")]
4484        );
4485    }
4486
4487    #[tokio::test]
4488    async fn fetch_balances_isolated_returns_empty() {
4489        // Isolated balances are per-pair (on account_snapshot); the asset-keyed fetch returns empty
4490        // without a network call.
4491        let client = isolated_client(&["BTCUSDT"]);
4492        assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4493    }
4494
4495    fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4496        let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4497        o.order_id = order_id;
4498        o.client_order_id = Some("cid-9".to_string());
4499        o.side = Some(side.to_string());
4500        o.price = Some("50000".to_string());
4501        o.orig_qty = Some("2".to_string());
4502        o.executed_qty = Some("0.5".to_string());
4503        o.r#type = Some("LIMIT".to_string());
4504        o.time_in_force = Some("GTC".to_string());
4505        o.time = Some(1_700_000_000_000);
4506        o.status = Some("PARTIALLY_FILLED".to_string());
4507        o
4508    }
4509
4510    #[test]
4511    fn margin_open_order_converts() {
4512        let inst = InstrumentNameExchange::new("BTCUSDT");
4513        let order = convert_open_order(
4514            &open_order(Some(42), "BUY"),
4515            ExchangeId::BinanceMargin,
4516            &inst,
4517        )
4518        .expect("convert");
4519        assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4520        assert_eq!(
4521            order.state.id.assigned().map(|id| id.0.as_str()),
4522            Some("42")
4523        );
4524        assert_eq!(order.key.cid.0.as_str(), "cid-9");
4525        assert_eq!(order.side, Side::Buy);
4526        assert_eq!(order.price, Some(Decimal::from(50_000)));
4527        assert_eq!(order.quantity, Decimal::from(2));
4528        assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4529        assert_eq!(order.kind, OrderKind::Limit);
4530    }
4531
4532    /// Margin shares the converter, so it shares the guard: a cancelled order that had partly
4533    /// filled must not come back as `Open`.
4534    #[test]
4535    fn margin_open_order_that_is_not_live_is_dropped() {
4536        let inst = InstrumentNameExchange::new("BTCUSDT");
4537        let mut o = open_order(Some(42), "BUY");
4538        o.status = Some("CANCELED".to_string());
4539        assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst).is_none());
4540    }
4541
4542    #[test]
4543    fn margin_open_order_missing_order_id_is_dropped() {
4544        let inst = InstrumentNameExchange::new("BTCUSDT");
4545        assert!(
4546            convert_open_order(&open_order(None, "BUY"), ExchangeId::BinanceMargin, &inst)
4547                .is_none()
4548        );
4549    }
4550
4551    /// A margin REST snapshot must be stamped with when the order last *changed*, not when it was
4552    /// created. The engine orders an order's states by `Open::time_exchange` and discards any
4553    /// snapshot older than the state it already tracks, so a creation stamp -- identical across
4554    /// every snapshot of one order -- is discarded the moment anything advances that order past
4555    /// creation. That is exactly the partially-filled order a reconciliation fetch exists to repair.
4556    #[test]
4557    fn margin_open_order_prefers_update_time_over_creation_time() {
4558        let inst = InstrumentNameExchange::new("BTCUSDT");
4559
4560        let mut updated = open_order(Some(42), "BUY");
4561        updated.update_time = Some(1_700_000_005_000);
4562        let order =
4563            convert_open_order(&updated, ExchangeId::BinanceMargin, &inst).expect("convert");
4564        assert_eq!(
4565            order.state.time_exchange,
4566            Utc.timestamp_millis_opt(1_700_000_005_000)
4567                .single()
4568                .unwrap(),
4569            "update_time must win over time"
4570        );
4571    }
4572
4573    /// Falls back to `time` when the venue omits `update_time`, rather than to `now` -- which would
4574    /// be worse than creation time, since it is not a venue-reported instant at all.
4575    #[test]
4576    fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
4577        let inst = InstrumentNameExchange::new("BTCUSDT");
4578
4579        let base = open_order(Some(42), "BUY");
4580        assert!(base.update_time.is_none(), "fixture must omit update_time");
4581        let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst).expect("convert");
4582        assert_eq!(
4583            order.state.time_exchange,
4584            Utc.timestamp_millis_opt(1_700_000_000_000)
4585                .single()
4586                .unwrap(),
4587            "absent update_time falls back to time"
4588        );
4589    }
4590
4591    #[test]
4592    fn margin_open_order_owned_symbol_recovers_instrument() {
4593        // The no-symbol "return all" path derives the instrument from each order's own `symbol`.
4594        let mut o = open_order(Some(42), "SELL");
4595        o.symbol = Some("ETHUSDT".to_string());
4596        let order =
4597            convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).expect("convert");
4598        assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4599        assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4600        assert_eq!(order.side, Side::Sell);
4601    }
4602
4603    #[test]
4604    fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4605        // open_order() leaves `symbol` unset — the return-all path must drop it rather than guess.
4606        let o = open_order(Some(42), "BUY");
4607        assert!(o.symbol.is_none());
4608        assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).is_none());
4609    }
4610
4611    fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4612        let mut t = QueryMarginAccountsTradeListResponseInner::new();
4613        t.id = id;
4614        t.order_id = Some(7);
4615        t.is_buyer = is_buyer;
4616        t.price = Some("48000".to_string());
4617        t.qty = Some("0.25".to_string());
4618        t.commission = Some("0.001".to_string());
4619        t.commission_asset = Some("BNB".to_string());
4620        t.time = Some(1_700_000_000_000);
4621        t
4622    }
4623
4624    #[test]
4625    fn margin_trade_converts() {
4626        let inst = InstrumentNameExchange::new("BTCUSDT");
4627        let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4628        assert_eq!(tr.id.0.as_str(), "11");
4629        assert_eq!(tr.order_id.0.as_str(), "7");
4630        assert_eq!(tr.side, Side::Sell);
4631        assert_eq!(tr.price, Decimal::from(48_000));
4632        assert_eq!(tr.quantity, Decimal::new(25, 2));
4633        // Third-party fee asset (BNB) preserved verbatim; fees_quote left for the indexer.
4634        assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4635        assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4636    }
4637
4638    #[test]
4639    fn margin_trade_missing_fields_are_dropped() {
4640        let inst = InstrumentNameExchange::new("BTCUSDT");
4641        // Missing id and missing isBuyer both drop the trade rather than guess.
4642        assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4643        assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4644    }
4645
4646    // -- User-data stream: token renewal -------------------------------------------------------
4647
4648    #[test]
4649    fn token_renew_after_applies_margin_and_floor() {
4650        let now_ms = Utc::now().timestamp_millis();
4651        // ~24h out: renewal waits roughly (24h − 5min), bounded below 24h.
4652        let far = token_renew_after(now_ms + 86_400_000).as_secs();
4653        assert!(
4654            far > 80_000 && far <= 86_400,
4655            "expected ~24h-minus-margin, got {far}"
4656        );
4657        // Already expired → clamped to the floor, never zero.
4658        assert_eq!(
4659            token_renew_after(now_ms - 10_000).as_secs(),
4660            TOKEN_MIN_LIFETIME_SECS
4661        );
4662        // Within the safety margin → also the floor (renew now-ish, not at the deadline).
4663        assert_eq!(
4664            token_renew_after(now_ms + 60_000).as_secs(),
4665            TOKEN_MIN_LIFETIME_SECS
4666        );
4667    }
4668
4669    // -- User-data stream: frame discrimination + event conversion -----------------------------
4670
4671    /// Wrap an inner user-data `event` object in the WS-API push envelope observed live,
4672    /// serialized to the on-wire JSON string the converter parses.
4673    fn push(event: serde_json::Value) -> String {
4674        serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
4675    }
4676
4677    #[test]
4678    fn margin_ws_rpc_ack_yields_no_events() {
4679        // An RPC response (e.g. the subscribe ack) carries a top-level `id` and is not user data.
4680        let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
4681        let mut buf = Vec::new();
4682        assert!(!convert_margin_user_data_events(&frame, &mut buf));
4683        assert!(buf.is_empty());
4684    }
4685
4686    /// A bare event, with no envelope, or an envelope whose event has no `e` tag, is not a shape
4687    /// the subscription delivers: nothing is converted and the stream is not terminated.
4688    #[test]
4689    fn margin_ws_unrecognised_frames_yield_no_events() {
4690        for frame in [
4691            r#"{"e":"eventStreamTerminated","E":1}"#,
4692            r#"{"subscriptionId":1,"event":{"E":1}}"#,
4693        ] {
4694            let mut buf = Vec::new();
4695            assert!(!convert_margin_user_data_events(frame, &mut buf), "{frame}");
4696            assert!(buf.is_empty(), "{frame}");
4697        }
4698    }
4699
4700    /// A partial fill carries two facts, and both must reach the consumer: the execution print
4701    /// (`l`/`L`) and the order's new cumulative filled quantity (`z`).
4702    ///
4703    /// The execution alone never moves the order -- `Orders::update_from_fill` writes only
4704    /// `filled_quantity` and needs the order already tracked -- so without the paired snapshot a
4705    /// partially filled margin order reads as having nothing filled until REST reconciliation
4706    /// refreshes it.
4707    #[test]
4708    fn margin_ws_execution_report_trade_maps_to_trade_and_order_snapshot() {
4709        let frame = push(serde_json::json!({
4710            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4711            "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
4712            "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4713            "p": "48000", "q": "2", "f": "GTC",
4714            "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
4715        }));
4716        let mut buf = Vec::new();
4717        assert!(!convert_margin_user_data_events(&frame, &mut buf));
4718        assert_eq!(
4719            buf.len(),
4720            2,
4721            "a fill must produce the execution and the order snapshot, got: {buf:?}"
4722        );
4723        assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
4724        match &buf[0].kind {
4725            AccountEventKind::Trade(t) => {
4726                assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
4727                assert_eq!(t.side, Side::Buy);
4728                assert_eq!(t.price, Decimal::from(48_000));
4729                assert_eq!(t.quantity, Decimal::new(5, 1));
4730                assert_eq!(t.fees.asset.name().as_str(), "BNB"); // 3rd-party fee asset preserved
4731                assert_eq!(t.fees.fees, Decimal::new(1, 3));
4732            }
4733            other => panic!("expected Trade, got {other:?}"),
4734        }
4735        assert_eq!(buf[1].exchange, ExchangeId::BinanceMargin);
4736        match &buf[1].kind {
4737            AccountEventKind::OrderSnapshot(snap) => {
4738                assert_eq!(
4739                    snap.0.quantity,
4740                    Decimal::from(2),
4741                    "the order's total quantity"
4742                );
4743                match &snap.0.state {
4744                    OrderState::Active(ActiveOrderState::Open(open)) => {
4745                        assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("12345"));
4746                        assert_eq!(
4747                            open.filled_quantity,
4748                            Decimal::new(5, 1),
4749                            "the snapshot must carry the order's cumulative filled quantity (z)"
4750                        );
4751                        assert_eq!(
4752                            open.time_exchange.timestamp_millis(),
4753                            1_700_000_000_000,
4754                            "stamped with the execution's transaction time (T), which is what \
4755                             gives the engine's staleness gate an ordering key"
4756                        );
4757                    }
4758                    other => panic!("expected an Open order, got {other:?}"),
4759                }
4760            }
4761            other => panic!("expected OrderSnapshot, got {other:?}"),
4762        }
4763    }
4764
4765    /// The mirror of the case above: a `TRADE` whose order status says the order is no longer
4766    /// working must emit the execution *without* a snapshot, or it resurrects an order the
4767    /// engine has already retired as a resting one.
4768    #[test]
4769    fn margin_trade_for_an_order_no_longer_working_emits_no_snapshot() {
4770        for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
4771            let frame = push(serde_json::json!({
4772                "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4773                "x": "TRADE", "X": status, "i": 12_345_i64, "c": "cid-1",
4774                "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4775                "p": "48000", "q": "2", "f": "GTC", "T": 1_700_000_000_000_i64,
4776            }));
4777            let mut buf = Vec::new();
4778            assert!(!convert_margin_user_data_events(&frame, &mut buf));
4779            assert_eq!(
4780                buf.len(),
4781                1,
4782                "status {status} must produce the execution alone, got: {buf:?}"
4783            );
4784            assert!(
4785                matches!(buf[0].kind, AccountEventKind::Trade(_)),
4786                "the execution must still be reported for status {status}"
4787            );
4788        }
4789    }
4790
4791    #[test]
4792    fn margin_ws_new_report_maps_to_active_order_snapshot() {
4793        let frame = push(serde_json::json!({
4794            "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
4795            "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
4796            "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
4797        }));
4798        let mut buf = Vec::new();
4799        assert!(!convert_margin_user_data_events(&frame, &mut buf));
4800        assert_eq!(buf.len(), 1);
4801        match &buf[0].kind {
4802            AccountEventKind::OrderSnapshot(snap) => {
4803                assert_eq!(snap.0.side, Side::Sell);
4804                assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
4805                assert_eq!(snap.0.quantity, Decimal::from(1));
4806                assert!(
4807                    matches!(snap.0.state, OrderState::Active(_)),
4808                    "NEW should be an active (resting) order"
4809                );
4810            }
4811            other => panic!("expected OrderSnapshot, got {other:?}"),
4812        }
4813    }
4814
4815    #[test]
4816    fn margin_ws_canceled_report_maps_to_order_cancelled() {
4817        let frame = push(serde_json::json!({
4818            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4819            "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
4820            "z": "0", "T": 1_700_000_000_000_i64,
4821        }));
4822        let mut buf = Vec::new();
4823        assert!(!convert_margin_user_data_events(&frame, &mut buf));
4824        assert_eq!(buf.len(), 1);
4825        match &buf[0].kind {
4826            AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
4827            other => panic!("expected OrderCancelled, got {other:?}"),
4828        }
4829    }
4830
4831    #[test]
4832    fn margin_ws_execution_report_missing_symbol_is_dropped() {
4833        // Symbol absent → defensively dropped (observable warn), never a half-built event.
4834        let frame = push(serde_json::json!({
4835            "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
4836            "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
4837        }));
4838        let mut buf = Vec::new();
4839        assert!(!convert_margin_user_data_events(&frame, &mut buf));
4840        assert!(buf.is_empty());
4841    }
4842
4843    #[test]
4844    fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
4845        let frame = push(serde_json::json!({
4846            "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4847            "B": [
4848                { "a": "USDT", "f": "100.0", "l": "5.0" },
4849                { "a": "BTC", "f": "0.5", "l": "0" },
4850            ],
4851        }));
4852        let mut buf = Vec::new();
4853        assert!(!convert_margin_user_data_events(&frame, &mut buf));
4854        assert_eq!(buf.len(), 2);
4855        // WS partial → BalanceStreamUpdate (free/locked), never BalanceSnapshot (no debt clobber).
4856        for ev in &buf {
4857            assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
4858        }
4859    }
4860
4861    #[test]
4862    fn margin_ws_stream_terminated_signals_reconnect() {
4863        let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
4864        let mut buf = Vec::new();
4865        assert!(
4866            convert_margin_user_data_events(&frame, &mut buf),
4867            "eventStreamTerminated must signal reconnect"
4868        );
4869        assert!(buf.is_empty());
4870    }
4871
4872    #[test]
4873    fn margin_ws_margin_specific_events_are_observable_only() {
4874        // userLiabilityChange / marginLevelStatusChange are logged, NOT forwarded as account events
4875        // and NOT signalled as reconnects: observable, not accumulated.
4876        let mut buf = Vec::new();
4877        let liability = push(serde_json::json!({
4878            "e": "userLiabilityChange", "a": "USDT", "t": "BORROW", "p": "100", "i": "0.01",
4879        }));
4880        assert!(!convert_margin_user_data_events(&liability, &mut buf));
4881        let level = push(serde_json::json!({
4882            "e": "marginLevelStatusChange", "l": "1.5", "s": "MARGIN_LEVEL_2",
4883        }));
4884        assert!(!convert_margin_user_data_events(&level, &mut buf));
4885        assert!(buf.is_empty());
4886    }
4887
4888    // -- User-data stream: dedup -----------------------------------------------------------------
4889
4890    #[test]
4891    fn margin_trade_event_dedup_by_key() {
4892        // A recovered fill and its live WS counterpart share (instrument, trade_id, kind) → the
4893        // dedup cache must drop the second occurrence (mirrors the reconnect fill-recovery path).
4894        let frame = push(serde_json::json!({
4895            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4896            "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
4897            "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
4898            "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
4899        }));
4900        let mut buf = Vec::new();
4901        convert_margin_user_data_events(&frame, &mut buf);
4902        // Index rather than pop: a fill appends the execution *then* its order snapshot, and
4903        // popping would take the snapshot and silently test the wrong event's key.
4904        let event = buf.first().expect("a Trade event");
4905        assert!(
4906            matches!(event.kind, AccountEventKind::Trade(_)),
4907            "the execution is the first of the two events a fill produces"
4908        );
4909
4910        let cache = new_dedup_cache();
4911        // DedupKey isn't Clone; re-derive it from the same event (deterministic) for the 2nd check.
4912        let first = dedup_key_from_event(event).expect("Trade events have a dedup key");
4913        assert!(!is_duplicate(&cache, first), "first sighting is fresh");
4914        let second = dedup_key_from_event(event).expect("Trade events have a dedup key");
4915        assert!(
4916            is_duplicate(&cache, second),
4917            "second sighting is a duplicate"
4918        );
4919    }
4920
4921    /// Drive frames through the same two stages the live WebSocket callback does: convert, then
4922    /// apply the dedup gate. Returns what a consumer would actually receive.
4923    ///
4924    /// Every other user-data test in this file calls the converter directly. That is the wrong
4925    /// altitude to prove a fill reaches anyone: the converter can be perfectly correct while the
4926    /// gate downstream of it discards what the converter produced. Margin's gate is structurally
4927    /// identical to spot's, so it needs the same end-to-end coverage.
4928    fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
4929        let cache = new_dedup_cache();
4930        let mut delivered = Vec::new();
4931        let mut buf = Vec::new();
4932        for frame in frames {
4933            let _ = convert_margin_user_data_events(frame, &mut buf);
4934            for ev in buf.drain(..) {
4935                if let Some(key) = dedup_key_from_event(&ev)
4936                    && is_duplicate(&cache, key)
4937                {
4938                    continue;
4939                }
4940                delivered.push(ev);
4941            }
4942        }
4943        delivered
4944    }
4945
4946    fn ws_new(cumulative_filled: &str) -> String {
4947        push(serde_json::json!({
4948            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4949            "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "client-1",
4950            "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
4951            "T": 1_700_000_000_000_i64,
4952        }))
4953    }
4954
4955    fn ws_partial_fill(cumulative_filled: &str) -> String {
4956        push(serde_json::json!({
4957            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4958            "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "client-1",
4959            "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
4960            "l": "1", "L": "100", "t": 555_i64, "n": "0.1", "N": "USDT",
4961            "T": 1_700_000_001_000_i64,
4962        }))
4963    }
4964
4965    fn open_filled_quantities(events: &[UnindexedAccountEvent]) -> Vec<Decimal> {
4966        events
4967            .iter()
4968            .filter_map(|ev| match &ev.kind {
4969                AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
4970                    OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
4971                    _ => None,
4972                },
4973                _ => None,
4974            })
4975            .collect()
4976    }
4977
4978    /// A fill's order snapshot must survive the dedup gate that sits between the converter and
4979    /// the consumer.
4980    ///
4981    /// The acknowledgement and the fill describe one order id, so a key built from the id alone
4982    /// would make the second look like a replay of the first. `Open::filled_quantity` would then
4983    /// never leave the `0` the acknowledgement carried -- the defect fixed in the converter,
4984    /// undone one stage later.
4985    #[test]
4986    fn margin_ws_fill_snapshot_survives_the_dedup_gate() {
4987        let delivered = drive_ws_pipeline(&[&ws_new("0"), &ws_partial_fill("1")]);
4988
4989        assert_eq!(
4990            delivered.len(),
4991            3,
4992            "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
4993        );
4994        assert_eq!(
4995            open_filled_quantities(&delivered),
4996            vec![Decimal::ZERO, Decimal::ONE],
4997            "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
4998        );
4999    }
5000
5001    /// The gate must still collapse a genuinely re-delivered frame, because a replayed
5002    /// acknowledgement for a retired order is re-inserted as a live resting order by the engine.
5003    #[test]
5004    fn margin_ws_replayed_frame_is_still_deduplicated() {
5005        let delivered = drive_ws_pipeline(&[
5006            &ws_new("0"),
5007            &ws_partial_fill("1"),
5008            &ws_new("0"),
5009            &ws_partial_fill("1"),
5010        ]);
5011
5012        assert_eq!(
5013            delivered.len(),
5014            3,
5015            "re-delivering both frames must add nothing, got: {delivered:?}"
5016        );
5017    }
5018
5019    /// An acknowledgement that already carries a partial fill, followed by the `TRADE` reporting
5020    /// that same fill, describes one order state twice -- so the gate collapses the second
5021    /// snapshot. That is correct, not a loss: the dedup key includes `filled_quantity`, the two
5022    /// snapshots agree on it, and the execution itself carries a distinct trade id and is
5023    /// delivered either way.
5024    #[test]
5025    fn margin_ws_an_ack_and_its_fill_reporting_one_state_deliver_that_state_once() {
5026        let delivered = drive_ws_pipeline(&[&ws_new("1"), &ws_partial_fill("1")]);
5027
5028        assert_eq!(
5029            delivered.len(),
5030            2,
5031            "the ack snapshot and the execution, with the fill's duplicate snapshot collapsed, \
5032             got: {delivered:?}"
5033        );
5034        assert!(
5035            delivered
5036                .iter()
5037                .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
5038            "the execution must reach the consumer regardless"
5039        );
5040        assert_eq!(
5041            open_filled_quantities(&delivered),
5042            vec![Decimal::ONE],
5043            "one snapshot, carrying the state both reports agree on"
5044        );
5045    }
5046
5047    // -- Isolated user-data stream: token params, base/quote map, balance routing ---------------
5048
5049    /// Wrap an inner `event` in the WS-API push envelope with an explicit `subscriptionId` (the
5050    /// isolated routing key for symbol-less `outboundAccountPosition` frames).
5051    fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
5052        serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
5053    }
5054
5055    /// Build the isolated balance handler (closes over the routing maps) for driving
5056    /// `convert_margin_user_data_events_with` in tests.
5057    fn isolated_handler(
5058        sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
5059        base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
5060    ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
5061        move |position, subscription_id, buf| {
5062            route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
5063        }
5064    }
5065
5066    fn btcusdt() -> InstrumentNameExchange {
5067        InstrumentNameExchange::new("BTCUSDT")
5068    }
5069
5070    fn token(expiration_time_ms: i64) -> UserListenToken {
5071        UserListenToken {
5072            token: "t".to_string(),
5073            expiration_time_ms,
5074        }
5075    }
5076
5077    #[test]
5078    fn listen_token_query_cross_vs_isolated() {
5079        // Cross → no params (the signed POST sends an empty query, as it did before isolated).
5080        assert!(build_listen_token_query(None).is_empty());
5081
5082        // Isolated → isIsolated=TRUE & symbol=<sym> (the per-symbol scoping).
5083        let q = build_listen_token_query(Some(&btcusdt()));
5084        assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
5085        assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
5086        assert_eq!(q.len(), 2);
5087    }
5088
5089    #[test]
5090    fn earliest_token_expiry_picks_min() {
5091        let tokens = vec![
5092            (btcusdt(), token(3_000)),
5093            (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
5094            (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
5095        ];
5096        assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
5097        // Empty set → sentinel (never happens for isolated, which always has ≥1 symbol).
5098        assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
5099    }
5100
5101    #[test]
5102    fn base_quote_map_extracts_base_and_quote() {
5103        let entries = vec![
5104            iso_entry(
5105                "BTCUSDT",
5106                iso_base("BTC", "0", "0", "0", "0"),
5107                iso_quote("USDT", "0", "0", "0", "0"),
5108                None,
5109                None,
5110                None,
5111            ),
5112            iso_entry(
5113                "ETHBTC",
5114                iso_base("ETH", "0", "0", "0", "0"),
5115                iso_quote("BTC", "0", "0", "0", "0"),
5116                None,
5117                None,
5118                None,
5119            ),
5120        ];
5121        let map = build_base_quote_map(&entries);
5122        assert_eq!(
5123            map.get(&btcusdt())
5124                .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5125            Some(("BTC", "USDT"))
5126        );
5127        // ETHBTC: prefix/suffix string-matching would be ambiguous (BTC is the quote here, base
5128        // elsewhere) — the authoritative map resolves it unambiguously to base=ETH, quote=BTC.
5129        assert_eq!(
5130            map.get(&InstrumentNameExchange::new("ETHBTC"))
5131                .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5132            Some(("ETH", "BTC"))
5133        );
5134    }
5135
5136    #[test]
5137    fn isolated_outbound_position_routes_to_instrument_balance_update() {
5138        let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5139        let base_quote = Arc::new(HashMap::from([(
5140            btcusdt(),
5141            (
5142                AssetNameExchange::new("BTC"),
5143                AssetNameExchange::new("USDT"),
5144            ),
5145        )]));
5146        let mut handler = isolated_handler(sub_map, base_quote);
5147
5148        // Quote listed before base in `B` → assignment is by asset-name equality, not position.
5149        let frame = push_with_sub(
5150            7,
5151            serde_json::json!({
5152                "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5153                "B": [
5154                    { "a": "USDT", "f": "1000.0", "l": "50.0" },
5155                    { "a": "BTC", "f": "0.5", "l": "0.1" },
5156                ],
5157            }),
5158        );
5159        let mut buf = Vec::new();
5160        assert!(!convert_margin_user_data_events_with(
5161            &frame,
5162            &mut buf,
5163            &mut handler
5164        ));
5165        assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
5166        match &buf[0].kind {
5167            AccountEventKind::InstrumentBalanceUpdate(ibu) => {
5168                assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
5169                assert_eq!(ibu.base.asset.name().as_str(), "BTC");
5170                assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
5171                assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
5172                assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
5173                assert_eq!(ibu.quote.update.free, Decimal::from(1000));
5174                assert_eq!(ibu.quote.update.locked, Decimal::from(50));
5175            }
5176            other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
5177        }
5178        // Crucially NOT the asset-keyed BalanceStreamUpdate (which would corrupt AssetStates).
5179        assert!(!matches!(
5180            buf[0].kind,
5181            AccountEventKind::BalanceStreamUpdate(_)
5182        ));
5183    }
5184
5185    #[test]
5186    fn isolated_outbound_position_unknown_subscription_dropped() {
5187        // subscriptionId 99 is not in the map → dropped (observable warn), nothing emitted.
5188        let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5189        let base_quote = Arc::new(HashMap::from([(
5190            btcusdt(),
5191            (
5192                AssetNameExchange::new("BTC"),
5193                AssetNameExchange::new("USDT"),
5194            ),
5195        )]));
5196        let mut handler = isolated_handler(sub_map, base_quote);
5197
5198        let frame = push_with_sub(
5199            99,
5200            serde_json::json!({
5201                "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5202                "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
5203            }),
5204        );
5205        let mut buf = Vec::new();
5206        assert!(!convert_margin_user_data_events_with(
5207            &frame,
5208            &mut buf,
5209            &mut handler
5210        ));
5211        assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
5212    }
5213
5214    #[test]
5215    fn isolated_outbound_position_missing_side_dropped() {
5216        // Only the base side present → no partial InstrumentBalanceUpdate (would fabricate a zero
5217        // quote, silently mis-reporting free/locked). Dropped with a warn instead.
5218        let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5219        let base_quote = Arc::new(HashMap::from([(
5220            btcusdt(),
5221            (
5222                AssetNameExchange::new("BTC"),
5223                AssetNameExchange::new("USDT"),
5224            ),
5225        )]));
5226        let mut handler = isolated_handler(sub_map, base_quote);
5227
5228        let frame = push_with_sub(
5229            7,
5230            serde_json::json!({
5231                "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5232                "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
5233            }),
5234        );
5235        let mut buf = Vec::new();
5236        assert!(!convert_margin_user_data_events_with(
5237            &frame,
5238            &mut buf,
5239            &mut handler
5240        ));
5241        assert!(buf.is_empty(), "missing quote side → frame dropped");
5242    }
5243
5244    #[test]
5245    fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
5246        // Fills self-identify via inner `s` and route regardless of the subscriptionId map (which
5247        // is empty here) — only balance frames need the map. Confirms fills are unaffected if the
5248        // subscriptionId→symbol routing ever fails live.
5249        let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
5250        let base_quote = Arc::new(HashMap::new());
5251        let mut handler = isolated_handler(sub_map, base_quote);
5252
5253        let frame = push_with_sub(
5254            42,
5255            serde_json::json!({
5256                "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5257                "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
5258                "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
5259                "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5260            }),
5261        );
5262        let mut buf = Vec::new();
5263        assert!(!convert_margin_user_data_events_with(
5264            &frame,
5265            &mut buf,
5266            &mut handler
5267        ));
5268        assert_eq!(buf.len(), 2, "the execution and its order snapshot");
5269        match &buf[0].kind {
5270            AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
5271            other => panic!("expected Trade, got {other:?}"),
5272        }
5273        match &buf[1].kind {
5274            AccountEventKind::OrderSnapshot(snap) => {
5275                assert_eq!(snap.0.key.instrument.name().as_str(), "BTCUSDT");
5276            }
5277            other => panic!("expected OrderSnapshot, got {other:?}"),
5278        }
5279    }
5280
5281    /// A margin `myTrades` venue that enforces Binance's documented 24-hour rules: a
5282    /// `startTime`..`endTime` span of 24 hours or more is rejected, and a query by time without
5283    /// `fromId` returns at most 24 hours of trades. A `fromId` query returns trades from that id
5284    /// onwards whatever their time, and `orderId` narrows any query to one order. Pages hold at
5285    /// most `limit` trades, in ascending id.
5286    ///
5287    /// Two readings here are assumptions, not observed venue behaviour. A bare `startTime` is taken
5288    /// to mean the 24 hours from `startTime`; the pager under test never sends one, so only the
5289    /// previous pager's failure depends on it. `endTime` is inclusive unless `end_exclusive`, so a
5290    /// test can run under either reading.
5291    struct MarginMyTradesVenue {
5292        /// `(id, time_ms, order_id)`, in ascending id and time.
5293        trades: Vec<(i64, i64, i64)>,
5294        end_exclusive: bool,
5295    }
5296
5297    impl wiremock::Respond for MarginMyTradesVenue {
5298        fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
5299            const DAY_MS: i64 = 24 * 60 * 60 * 1000;
5300            let query: std::collections::HashMap<String, String> =
5301                request.url.query_pairs().into_owned().collect();
5302            let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
5303            let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
5304            let order = param("orderId");
5305
5306            let mut selected: Vec<&(i64, i64, i64)> = self
5307                .trades
5308                .iter()
5309                .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
5310                .collect();
5311            if let Some(from_id) = param("fromId") {
5312                selected.retain(|(id, _, _)| *id >= from_id);
5313            } else if order.is_none() {
5314                let start = param("startTime").expect("a query by time sends startTime");
5315                let end = match param("endTime") {
5316                    Some(end) if end - start >= DAY_MS => {
5317                        return wiremock::ResponseTemplate::new(400).set_body_json(
5318                            serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5319                        );
5320                    }
5321                    Some(end) if self.end_exclusive => end - 1,
5322                    Some(end) => end,
5323                    None => start + DAY_MS - 1,
5324                };
5325                selected.retain(|(_, time, _)| (start..=end).contains(time));
5326            }
5327
5328            let page: Vec<serde_json::Value> = selected
5329                .into_iter()
5330                .take(limit)
5331                .map(|(id, time, order_id)| {
5332                    serde_json::json!({
5333                        "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
5334                        "price": "50000", "qty": "0.01", "commission": "0",
5335                        "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
5336                        "isBestMatch": true, "isIsolated": true,
5337                    })
5338                })
5339                .collect();
5340            wiremock::ResponseTemplate::new(200).set_body_json(page)
5341        }
5342    }
5343
5344    /// Run `paginate_margin_my_trades` from `from` against `venue`, returning the ids read and the
5345    /// query parameters of each request sent.
5346    async fn read_margin_trades(
5347        venue: MarginMyTradesVenue,
5348        from: MyTradesFrom,
5349    ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
5350        let server = wiremock::MockServer::start().await;
5351        wiremock::Mock::given(wiremock::matchers::method("GET"))
5352            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5353            .respond_with(venue)
5354            .mount(&server)
5355            .await;
5356        let rest = Arc::new(MarginTradingRestApi::from_config(
5357            ConfigurationRestApi::builder()
5358                .api_key("key")
5359                .api_secret("secret")
5360                .base_path(server.uri())
5361                .build()
5362                .unwrap(),
5363        ));
5364
5365        let read = paginate_margin_my_trades(
5366            &rest,
5367            &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5368            &InstrumentNameExchange::new("BTCUSDT"),
5369            from,
5370            true,
5371            RequestKind::Query,
5372        )
5373        .await
5374        .unwrap();
5375
5376        let requests = server
5377            .received_requests()
5378            .await
5379            .unwrap()
5380            .iter()
5381            .map(|request| request.url.query_pairs().into_owned().collect())
5382            .collect();
5383        (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
5384    }
5385
5386    /// A venue whose trades each belong to an order of the same id.
5387    fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
5388        MarginMyTradesVenue {
5389            trades: trades
5390                .into_iter()
5391                .map(|(id, time)| (id, time, id))
5392                .collect(),
5393            end_exclusive: false,
5394        }
5395    }
5396
5397    const HOUR_MS: i64 = 60 * 60 * 1000;
5398
5399    /// The only fill lies on day 3 of a five-day lookback. A bare `startTime` query sees only the
5400    /// first 24 hours, finds nothing and stops; the windows must reach day 3.
5401    #[tokio::test]
5402    async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
5403        let now = Utc::now().timestamp_millis();
5404        let (ids, _) = read_margin_trades(
5405            venue(vec![(7, now - 72 * HOUR_MS)]),
5406            MyTradesFrom::Time(now - 120 * HOUR_MS),
5407        )
5408        .await;
5409
5410        assert_eq!(ids, vec![7]);
5411    }
5412
5413    /// Fills in separate days, with empty days between and a short page in the first window
5414    /// that holds one: every fill is read once, in order.
5415    #[tokio::test]
5416    async fn margin_trades_read_fills_across_several_windows_once_each() {
5417        let now = Utc::now().timestamp_millis();
5418        let trades = vec![
5419            (1, now - 119 * HOUR_MS),
5420            (2, now - 72 * HOUR_MS),
5421            (3, now - HOUR_MS),
5422        ];
5423        let (ids, _) =
5424            read_margin_trades(venue(trades), MyTradesFrom::Time(now - 144 * HOUR_MS)).await;
5425
5426        assert_eq!(ids, vec![1, 2, 3]);
5427    }
5428
5429    /// A trade stamped exactly on the end of the first window is read once, whether the venue
5430    /// treats `endTime` as inclusive or exclusive.
5431    #[tokio::test]
5432    async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
5433        for end_exclusive in [false, true] {
5434            let now = Utc::now().timestamp_millis();
5435            let from = now - 48 * HOUR_MS;
5436            let venue = MarginMyTradesVenue {
5437                end_exclusive,
5438                ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
5439            };
5440            let (ids, _) = read_margin_trades(venue, MyTradesFrom::Time(from)).await;
5441
5442            assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
5443        }
5444    }
5445
5446    /// A window holding more than one page of fills pages on by `fromId`.
5447    #[tokio::test]
5448    async fn margin_trades_page_on_by_id_past_a_full_window() {
5449        let now = Utc::now().timestamp_millis();
5450        let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5451        let trades = (1..=count)
5452            .map(|id| (id, now - 48 * HOUR_MS + id))
5453            .collect();
5454        let (ids, _) =
5455            read_margin_trades(venue(trades), MyTradesFrom::Time(now - 50 * HOUR_MS)).await;
5456
5457        assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5458    }
5459
5460    /// A final window holding more than one page still pages on by `fromId`: the early stop
5461    /// after the final window applies only to a short page.
5462    #[tokio::test]
5463    async fn margin_trades_page_on_past_a_full_final_window() {
5464        let now = Utc::now().timestamp_millis();
5465        let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5466        let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
5467        let (ids, requests) =
5468            read_margin_trades(venue(trades), MyTradesFrom::Time(now - 2 * HOUR_MS)).await;
5469
5470        assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5471        assert_eq!(requests.len(), 2);
5472    }
5473
5474    /// A lookback with no fills costs one request per window, and every window is accepted
5475    /// (the venue rejects a span of 24 hours or more, which would fail the read).
5476    #[tokio::test]
5477    async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
5478        let now = Utc::now().timestamp_millis();
5479        let (ids, requests) =
5480            read_margin_trades(venue(Vec::new()), MyTradesFrom::Time(now - 72 * HOUR_MS)).await;
5481
5482        assert!(ids.is_empty());
5483        // Windows start at 0, 23, 46 and 69 hours into the 72-hour lookback.
5484        assert_eq!(requests.len(), 4);
5485    }
5486
5487    /// An ordinary reconnect gap holding a fill is one request: the only window reaches the
5488    /// call's start and reads short, so there is nothing to page on to.
5489    #[tokio::test]
5490    async fn margin_trades_read_a_short_gap_in_one_request() {
5491        let now = Utc::now().timestamp_millis();
5492        let (ids, requests) = read_margin_trades(
5493            venue(vec![(9, now - HOUR_MS)]),
5494            MyTradesFrom::Time(now - 2 * HOUR_MS),
5495        )
5496        .await;
5497
5498        assert_eq!(ids, vec![9]);
5499        assert_eq!(requests.len(), 1);
5500    }
5501
5502    /// A walk from a time after the call's start has nothing to read and sends nothing.
5503    #[tokio::test]
5504    async fn margin_trades_from_the_future_send_no_request() {
5505        let now = Utc::now().timestamp_millis();
5506        let (ids, requests) =
5507            read_margin_trades(venue(Vec::new()), MyTradesFrom::Time(now + HOUR_MS)).await;
5508
5509        assert!(ids.is_empty());
5510        assert!(requests.is_empty());
5511    }
5512
5513    /// A walk by order pages that order's executions by `fromId`, never by time, and reads no
5514    /// other order's.
5515    #[tokio::test]
5516    async fn margin_trades_by_order_page_by_id_without_time_bounds() {
5517        let now = Utc::now().timestamp_millis();
5518        let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5519        // Order 42's executions, interleaved with another order's.
5520        let trades = (1..=2 * count)
5521            .map(|id| {
5522                (
5523                    id,
5524                    now - 48 * HOUR_MS + id,
5525                    if id % 2 == 0 { 42 } else { 7 },
5526                )
5527            })
5528            .collect();
5529        let venue = MarginMyTradesVenue {
5530            trades,
5531            end_exclusive: false,
5532        };
5533        let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
5534
5535        assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
5536        assert_eq!(requests.len(), 2);
5537        assert!(
5538            requests.iter().all(
5539                |query| query.get("orderId").map(String::as_str) == Some("42")
5540                    && !query.contains_key("startTime")
5541                    && !query.contains_key("endTime")
5542            )
5543        );
5544    }
5545
5546    /// Run `paginate_margin_my_trades` against a venue that answers every request with
5547    /// `response`, returning the error it fails with.
5548    async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
5549        let server = wiremock::MockServer::start().await;
5550        wiremock::Mock::given(wiremock::matchers::method("GET"))
5551            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5552            .respond_with(response)
5553            .mount(&server)
5554            .await;
5555        let rest = Arc::new(MarginTradingRestApi::from_config(
5556            ConfigurationRestApi::builder()
5557                .api_key("key")
5558                .api_secret("secret")
5559                .base_path(server.uri())
5560                .build()
5561                .unwrap(),
5562        ));
5563
5564        paginate_margin_my_trades(
5565            &rest,
5566            &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5567            &InstrumentNameExchange::new("BTCUSDT"),
5568            MyTradesFrom::Time(Utc::now().timestamp_millis() - HOUR_MS),
5569            true,
5570            RequestKind::Query,
5571        )
5572        .await
5573        .unwrap_err()
5574    }
5575
5576    /// A venue rejection of a query is reported as such, with its code, and not as a transient
5577    /// connectivity failure a caller would retry forever.
5578    #[tokio::test]
5579    async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
5580        let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
5581            serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5582        ))
5583        .await;
5584
5585        let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
5586            panic!("expected RequestRejected, got {err:?}");
5587        };
5588        assert!(msg.contains("-1127"), "code missing from {msg:?}");
5589        assert!(!err.is_transient());
5590    }
5591
5592    /// A 2xx body that does not fit the SDK's model fails identically on every retry.
5593    #[tokio::test]
5594    async fn margin_trades_report_an_undecodable_response_as_internal() {
5595        let err = margin_trades_error(
5596            wiremock::ResponseTemplate::new(200)
5597                .set_body_json(serde_json::json!({"unexpected": 1})),
5598        )
5599        .await;
5600
5601        assert!(
5602            matches!(err, UnindexedClientError::Internal(_)),
5603            "got {err:?}"
5604        );
5605        assert!(!err.is_transient());
5606    }
5607
5608    /// A `/sapi` response reporting `x-sapi-used-ip-weight-1m` at 90% of the documented IP limit
5609    /// pauses later queries but not orders; under it, or unreadable, nothing pauses.
5610    #[tokio::test]
5611    async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
5612        for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
5613            let server = wiremock::MockServer::start().await;
5614            wiremock::Mock::given(wiremock::matchers::method("GET"))
5615                .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
5616                .respond_with(
5617                    wiremock::ResponseTemplate::new(200)
5618                        .insert_header("x-sapi-used-ip-weight-1m", used)
5619                        .set_body_json(serde_json::json!([])),
5620                )
5621                .mount(&server)
5622                .await;
5623            let rest = Arc::new(MarginTradingRestApi::from_config(
5624                ConfigurationRestApi::builder()
5625                    .api_key("key")
5626                    .api_secret("secret")
5627                    .base_path(server.uri())
5628                    .build()
5629                    .unwrap(),
5630            ));
5631            let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5632
5633            fetch_margin_all_open_orders(rest, Arc::clone(&tracker), false)
5634                .await
5635                .unwrap();
5636
5637            let query_waits = tokio::time::timeout(
5638                Duration::from_millis(50),
5639                tracker.wait_if_blocked(RequestKind::Query),
5640            )
5641            .await
5642            .is_err();
5643            assert_eq!(query_waits, pauses, "used IP weight {used}");
5644            tokio::time::timeout(
5645                Duration::from_millis(50),
5646                tracker.wait_if_blocked(RequestKind::Order),
5647            )
5648            .await
5649            .expect("orders never wait on the pause");
5650        }
5651    }
5652
5653    /// Margin fill recovery does not wait for the pause near the weight limit either.
5654    #[tokio::test]
5655    async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
5656        let server = wiremock::MockServer::start().await;
5657        wiremock::Mock::given(wiremock::matchers::method("GET"))
5658            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5659            // One trade, so recovery also looks its order up: both reads run under the pause.
5660            .respond_with(
5661                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
5662                    "symbol": "BTCUSDT",
5663                    "id": 1,
5664                    "orderId": 7,
5665                    "price": "100",
5666                    "qty": "1",
5667                    "commission": "0",
5668                    "commissionAsset": "USDT",
5669                    "time": Utc::now().timestamp_millis(),
5670                    "isBuyer": true,
5671                    "isMaker": false,
5672                }])),
5673            )
5674            .expect(2)
5675            .mount(&server)
5676            .await;
5677        let rest = Arc::new(MarginTradingRestApi::from_config(
5678            ConfigurationRestApi::builder()
5679                .api_key("key")
5680                .api_secret("secret")
5681                .base_path(server.uri())
5682                .build()
5683                .unwrap(),
5684        ));
5685        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5686        tracker.throttle(Duration::from_secs(60));
5687        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5688        let mut unrecovered = UnrecoveredFills::default();
5689        unrecovered.open(
5690            &[InstrumentNameExchange::new("BTCUSDT")],
5691            Utc::now() - chrono::Duration::minutes(1),
5692            Utc::now(),
5693        );
5694
5695        // Far under the 60 s pause set above, and ample for two local round trips.
5696        tokio::time::timeout(
5697            Duration::from_millis(800),
5698            recover_margin_fills(
5699                &rest,
5700                &tracker,
5701                &mut unrecovered,
5702                &tx,
5703                &new_dedup_cache(),
5704                false,
5705            ),
5706        )
5707        .await
5708        .expect("recovery does not wait for the pause");
5709    }
5710
5711    /// A walk over a span reads only the trades inside it, stopping at the first page by id that
5712    /// reaches past its end.
5713    #[tokio::test]
5714    async fn margin_trades_read_over_a_span_stop_at_its_end() {
5715        let now = Utc::now().timestamp_millis();
5716        let trades = vec![
5717            (1, now - 50 * HOUR_MS),
5718            (2, now - 49 * HOUR_MS),
5719            (3, now - 10 * HOUR_MS),
5720        ];
5721        let (ids, _) = read_margin_trades(
5722            venue(trades),
5723            MyTradesFrom::Span {
5724                start: now - 60 * HOUR_MS,
5725                end: now - 20 * HOUR_MS,
5726            },
5727        )
5728        .await;
5729
5730        assert_eq!(ids, vec![1, 2]);
5731    }
5732
5733    /// A margin recovery that fails keeps its gap, closed at its own start; the retry reads only
5734    /// that span, forwards only the fill inside it, and clears it.
5735    #[tokio::test]
5736    async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
5737        let disconnect = Utc::now() - chrono::Duration::minutes(10);
5738        let reconnect = Utc::now() - chrono::Duration::minutes(5);
5739        let server = wiremock::MockServer::start().await;
5740        wiremock::Mock::given(wiremock::matchers::method("GET"))
5741            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5742            .respond_with(
5743                wiremock::ResponseTemplate::new(400)
5744                    .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5745            )
5746            .up_to_n_times(1)
5747            .with_priority(1)
5748            .mount(&server)
5749            .await;
5750        wiremock::Mock::given(wiremock::matchers::method("GET"))
5751            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5752            .respond_with(venue(vec![
5753                (
5754                    1,
5755                    (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
5756                ),
5757                (
5758                    2,
5759                    (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
5760                ),
5761            ]))
5762            .mount(&server)
5763            .await;
5764        let rest = Arc::new(MarginTradingRestApi::from_config(
5765            ConfigurationRestApi::builder()
5766                .api_key("key")
5767                .api_secret("secret")
5768                .base_path(server.uri())
5769                .build()
5770                .unwrap(),
5771        ));
5772        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5773        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5774        let dedup = new_dedup_cache();
5775        let mut unrecovered = UnrecoveredFills::default();
5776        unrecovered.open(
5777            &[InstrumentNameExchange::new("BTCUSDT")],
5778            disconnect,
5779            reconnect,
5780        );
5781
5782        recover_margin_fills(&rest, &tracker, &mut unrecovered, &tx, &dedup, false).await;
5783        assert!(!unrecovered.is_empty(), "the failed gap is kept");
5784        assert!(rx.try_recv().is_err(), "nothing forwarded");
5785
5786        unrecovered.make_due();
5787        recover_margin_fills(&rest, &tracker, &mut unrecovered, &tx, &dedup, false).await;
5788        assert!(unrecovered.is_empty(), "the retry recovered it");
5789        let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5790        assert_eq!(
5791            forwarded.len(),
5792            1,
5793            "only the fill inside the gap: {forwarded:?}"
5794        );
5795    }
5796
5797    /// When margin recovery times out, a gap whose read started has failed and waits for its retry, but
5798    /// one never started (eight are read at a time) is not charged and stays due.
5799    #[tokio::test]
5800    async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
5801        let server = wiremock::MockServer::start().await;
5802        wiremock::Mock::given(wiremock::matchers::method("GET"))
5803            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5804            .respond_with(
5805                wiremock::ResponseTemplate::new(200)
5806                    .set_body_json(serde_json::json!([]))
5807                    .set_delay(Duration::from_secs(3_600)),
5808            )
5809            .mount(&server)
5810            .await;
5811        let rest = Arc::new(MarginTradingRestApi::from_config(
5812            ConfigurationRestApi::builder()
5813                .api_key("key")
5814                .api_secret("secret")
5815                .base_path(server.uri())
5816                .timeout(3_600_000_u64)
5817                .retries(0_u32)
5818                .build()
5819                .unwrap(),
5820        ));
5821        let instruments: Vec<_> = (0..9)
5822            .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
5823            .collect();
5824        let mut unrecovered = UnrecoveredFills::default();
5825        unrecovered.open(
5826            &instruments,
5827            Utc::now() - chrono::Duration::minutes(10),
5828            Utc::now(),
5829        );
5830        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5831        // Paused: once every read is waiting, the clock jumps to the recovery timeout.
5832        tokio::time::pause();
5833
5834        recover_margin_fills(
5835            &rest,
5836            &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5837            &mut unrecovered,
5838            &tx,
5839            &new_dedup_cache(),
5840            false,
5841        )
5842        .await;
5843
5844        let now = tokio::time::Instant::now();
5845        assert_eq!(
5846            unrecovered.due(now).len(),
5847            1,
5848            "the gap never started stays due"
5849        );
5850        assert_eq!(
5851            unrecovered
5852                .due(now + Duration::from_secs(crate::client::binance::shared::GAP_RETRY_BASE_SECS))
5853                .len(),
5854            9,
5855            "the eight started wait for their first retry"
5856        );
5857    }
5858}