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