Skip to main content

rustrade_execution/client/binance/
margin.rs

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