Skip to main content

rustrade_execution/client/binance/
margin.rs

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