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