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 retired `/sapi/v1/userDataStream` listen-key path is
14//! not used), which after a reconnect recovers missed fills and reports how orders ended
15//! meanwhile, and on cross margin also reads the Risk Data Stream for margin notices. It also
16//! implements [`OrderStatusClient`], looking orders up by client order id, and reports a pair's
17//! borrow terms ([`ShortabilityClient`]) and how much this account can borrow for it
18//! ([`BorrowCapacityClient`]). Two reads beyond the traits cover what the account stream does not
19//! carry: the interest charged ([`BinanceMargin::fetch_margin_interest`]) and forced liquidations
20//! ([`BinanceMargin::fetch_forced_liquidations`]).
21//!
22//! The client supports both **cross** (`isIsolated = "FALSE"`, account-wide collateral) and
23//! **isolated** (`isIsolated = "TRUE"`, per-pair sub-accounts) margin, selected by
24//! [`BinanceMarginConfig::is_isolated`].
25//!
26//! ## Cross vs. isolated
27//! Cross is account-wide: a single `userListenToken`, one WS-API subscription, and per-asset
28//! balances surfaced in the top-level `balances`. Isolated is per-pair: the caller declares the
29//! [`BinanceMarginConfig::isolated_symbols`] universe, each symbol gets its own `userListenToken`,
30//! and all N tokens are **multiplexed onto one WS-API socket**. Isolated per-pair balances and risk
31//! are attached per-instrument (never folded into the asset-keyed model, which cannot represent
32//! `(pair, asset)` pools). See [`BinanceMargin`] for the per-method contract.
33//!
34//! ## Borrow/repay
35//! Margin orders carry a `sideEffectType` controlling whether the venue borrows on entry and
36//! repays on close. This is configured once per client via [`MarginSideEffect`] (see its docs for
37//! the silent-borrow footgun under the default [`MarginSideEffect::AutoBorrowRepay`]).
38//!
39//! ## No testnet
40//! Binance margin/SAPI exists on **no** testnet — the SDK exposes only
41//! `MarginTradingRestApi::production`. A `testnet: true` config is therefore inert for margin and
42//! always resolves to production endpoints; the constructor logs a warning so this is observable
43//! rather than silent. See [`BinanceMarginConfig::testnet`].
44//!
45//! ## Known limitations
46//! - `balanceUpdate` events (deposits/withdrawals) are not forwarded. Callers reconcile balances
47//!   after external transfers via [`ExecutionClient::fetch_balances`] or
48//!   [`ExecutionClient::account_snapshot`].
49//! - `USER_LIABILITY_CHANGE` (borrow/repay) is surfaced at INFO but never folded into balance
50//!   state: it is a delta, and accumulating it would be the position tracking this library leaves
51//!   to its consumers.
52//! - A `TRADE` report whose order status (`X`) is neither `PARTIALLY_FILLED` nor `FILLED` emits
53//!   the execution but no order snapshot, so a fill arriving after its order's terminal report
54//!   cannot resurrect a retired order as a resting one. That order's filled quantity is settled
55//!   instead by the terminal report's own `z`, or by
56//!   [`ExecutionClient::fetch_open_orders`].
57
58mod records;
59
60pub use records::MarginForcedLiquidation;
61
62use super::margin_risk::{
63    RISK_STREAM_URL, RiskStreamTask, SapiRiskListenKeys, log_liability_change,
64    log_margin_level_change, margin_level_notice,
65};
66use super::shared::{
67    AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
68    CONNECT_TIMEOUT_SECS, ExecutionReport, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS,
69    HEARTBEAT_TIMEOUT_SECS, MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing,
70    PlacementResponse, RateLimitTracker, RequestKind, SIGNAL_RECOVERY_LOOKBACK_MS,
71    SharedDedupCache, TrailingOrderError, UnhandledEvents, UnrecoveredFills, UserDataFrame,
72    WeightPool, binance_filled_qty, classify_order_kind_tif, classify_rest_order_error,
73    classify_rest_query_error, convert_ended_order, convert_execution_report,
74    convert_open_order_listing, convert_open_order_owned_symbol, dedup_key_from_event, drop_after,
75    frame_excerpt, gap_failed, gap_time, is_duplicate, is_unknown_order, log_unhandled_event,
76    log_unrecognised_frame, new_dedup_cache, parse_user_data_frame, placed_order_state,
77    recovered_order_cids, recovered_order_totals, response_decode_error, rest_call_with_retry,
78    trailing_params,
79};
80use crate::{
81    AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
82    InstrumentBalanceUpdate, IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent,
83    UnindexedAccountSnapshot,
84    balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
85    borrow_capacity::BorrowCapacity,
86    client::{
87        BorrowCapacityClient, ExecutionClient, OrderStatusClient, ShortabilityClient,
88        order_recovery::{
89            KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
90            fetch_ended_by_key, recover_ended_orders,
91        },
92    },
93    emit_stream_terminated,
94    error::{
95        ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
96    },
97    order::{
98        Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
99        id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
100        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
101        state::{Cancelled, Open, OrderState, UnindexedOrderState},
102    },
103    position::PositionReport,
104    shortability::Shortability,
105    trade::{AssetFees, Trade, TradeId, TradesRead},
106};
107use binance_sdk::{
108    common::{
109        config::{ConfigurationRestApi, ConfigurationWebsocketApi},
110        constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
111        models::WebsocketEvent,
112        websocket::{
113            SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
114            WebsocketMessageSendOptions,
115        },
116    },
117    margin_trading::{
118        MarginTradingRestApi,
119        rest_api::{
120            GetAllCrossMarginPairsParams, GetAllIsolatedMarginSymbolParams,
121            GetAllMarginAssetsParams, GetFutureHourlyInterestRateIsIsolatedEnum,
122            GetFutureHourlyInterestRateParams, MarginAccountCancelOrderIsIsolatedEnum,
123            MarginAccountCancelOrderParams, MarginAccountNewOrderIsIsolatedEnum,
124            MarginAccountNewOrderNewOrderRespTypeEnum, MarginAccountNewOrderParams,
125            MarginAccountNewOrderSideEffectTypeEnum, MarginAccountNewOrderSideEnum,
126            MarginAccountNewOrderTimeInForceEnum, MarginAccountNewOrderTypeEnum,
127            QueryCrossMarginAccountDetailsParams,
128            QueryCrossMarginAccountDetailsResponseUserAssetsInner,
129            QueryIsolatedMarginAccountInfoParams,
130            QueryIsolatedMarginAccountInfoResponseAssetsInner,
131            QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
132            QueryMarginAccountsOrderIsIsolatedEnum, QueryMarginAccountsOrderParams,
133            QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
134            QueryMarginAccountsTradeListResponseInner, QueryMaxBorrowParams, RestApi,
135        },
136        websocket_streams::{
137            MarginLevelStatusChange, OutboundAccountPosition, UserLiabilityChange,
138        },
139    },
140};
141use chrono::{DateTime, TimeZone, Utc};
142use fnv::FnvHashSet;
143use futures::stream::BoxStream;
144use rust_decimal::Decimal;
145use rustrade_instrument::{
146    Side,
147    asset::name::AssetNameExchange,
148    exchange::ExchangeId,
149    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
150};
151use serde::{Deserialize, Serialize};
152use smol_str::{SmolStr, format_smolstr};
153use std::{
154    collections::{BTreeMap, HashMap},
155    str::FromStr,
156    sync::{
157        Arc, Mutex,
158        atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
159    },
160    time::Duration,
161};
162use tokio::sync::{mpsc, oneshot};
163use tracing::{debug, error, info, trace, warn};
164
165// ---------------------------------------------------------------------------
166// Margin sideEffectType
167// ---------------------------------------------------------------------------
168
169/// Margin `sideEffectType` borrow/repay policy, fixed once per [`BinanceMargin`] client.
170///
171/// Only the two *intent-agnostic* modes are exposed as client-level variants. The borrow-vs-repay
172/// decision derives from open/close direction — position state the library deliberately does not
173/// track — so the venue is left to infer it from the account's loan state. The per-order modes
174/// (`MARGIN_BUY` / `AUTO_REPAY`) are intentionally **not** modelled here; they belong to a future
175/// generic per-order `RequestOpen::margin_effect` field that all margin adapters would map, added
176/// only when a venue with genuine per-order borrow intent (e.g. another CEX margin) appears.
177/// Mixed borrow appetite on a single venue today means running two clients (one per mode).
178///
179/// The `Serialize`/`Deserialize` wire form is the config-file value (`"auto_borrow_repay"` /
180/// `"no_borrow"`); this is distinct from the Binance API value returned by
181/// [`as_binance_str`](Self::as_binance_str) (`"AUTO_BORROW_REPAY"` / `"NO_SIDE_EFFECT"`).
182#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
183#[serde(rename_all = "snake_case")]
184pub enum MarginSideEffect {
185    /// `AUTO_BORROW_REPAY` — the venue borrows on entry and repays on close as needed.
186    ///
187    /// # Warning
188    /// This is the default because it makes shorting work out of the box, but it means a
189    /// **mis-sized order silently takes on debt**: an order larger than the free balance borrows
190    /// the shortfall without any further opt-in (same footgun class as `RequestOpen::reduce_only`).
191    /// Use [`MarginSideEffect::NoBorrow`] for a client that must never silently borrow.
192    #[default]
193    AutoBorrowRepay,
194    /// `NO_SIDE_EFFECT` — never borrow or repay; orders that would require borrowing are rejected
195    /// by the venue. The conservative opt-out from the [`AutoBorrowRepay`](Self::AutoBorrowRepay)
196    /// silent-borrow behaviour.
197    NoBorrow,
198}
199
200impl MarginSideEffect {
201    /// The Binance `sideEffectType` wire string for this policy.
202    pub fn as_binance_str(self) -> &'static str {
203        match self {
204            MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
205            MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
206        }
207    }
208
209    /// This policy as the margin SDK's `sideEffectType` parameter enum.
210    ///
211    /// The SDK also exposes `MARGIN_BUY` and `AUTO_REPAY` (the pre-`AUTO_BORROW_REPAY` split
212    /// primitives); rustrade deliberately models only the two composite policies, so those
213    /// variants are unreachable from here.
214    fn as_sdk_enum(self) -> MarginAccountNewOrderSideEffectTypeEnum {
215        match self {
216            MarginSideEffect::AutoBorrowRepay => {
217                MarginAccountNewOrderSideEffectTypeEnum::AutoBorrowRepay
218            }
219            MarginSideEffect::NoBorrow => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
220        }
221    }
222}
223
224// ---------------------------------------------------------------------------
225// Configuration
226// ---------------------------------------------------------------------------
227
228/// Configuration for the [`BinanceMargin`] execution client.
229///
230/// Mirrors [`BinanceSpotConfig`](super::spot::BinanceSpotConfig)'s credential handling (private
231/// keys, secret-redacting [`Debug`], `Deserialize`-only) and adds margin-specific knobs.
232// Serialize intentionally omitted — would expose secret_key in plaintext
233#[derive(Clone, Deserialize)]
234pub struct BinanceMarginConfig {
235    // not pub — prevents accidental credential exposure via struct access.
236    // Use BinanceMarginConfig::new() to construct, or deserialize from config file.
237    api_key: String,
238    secret_key: String,
239    /// Use testnet endpoints instead of production.
240    ///
241    /// **Inert for margin:** Binance margin/SAPI has no testnet, so this field is always treated
242    /// as production. Retained to mirror the spot config shape; [`BinanceMargin::new`] warns if it
243    /// is set to `true`.
244    pub testnet: bool,
245    /// Isolated margin (per-pair sub-accounts) when `true`; cross margin (account-wide) when
246    /// `false`.
247    ///
248    /// Defaults to `false` (cross). When `true`, [`isolated_symbols`](Self::isolated_symbols) must
249    /// be non-empty — it declares the pairs to snapshot/stream; [`BinanceMargin::new`] panics
250    /// otherwise.
251    #[serde(default)]
252    pub is_isolated: bool,
253    /// The isolated-margin pairs this client snapshots and streams when
254    /// [`is_isolated`](Self::is_isolated) is `true`.
255    ///
256    /// This is the authoritative symbol universe for the isolated per-symbol `userListenToken`
257    /// token/subscription fan-out: tokens are per-symbol and must be known at stream start, so the
258    /// set is fixed for the stream's lifetime. A pair activated *after* `account_stream` is called
259    /// is **not** auto-subscribed — reconfigure and restart the stream to pick it up.
260    ///
261    /// Ignored for cross margin (`is_isolated = false`). Empty by default; a `true` +
262    /// empty-set combination is a misconfiguration that panics in [`BinanceMargin::new`].
263    #[serde(default)]
264    pub isolated_symbols: Vec<InstrumentNameExchange>,
265    /// Borrow/repay policy applied to every order placed by this client.
266    ///
267    /// Defaults to [`MarginSideEffect::AutoBorrowRepay`] — see its `# Warning` on silent borrowing.
268    #[serde(default)]
269    pub side_effect: MarginSideEffect,
270}
271
272// custom Debug to avoid leaking credentials in logs
273impl std::fmt::Debug for BinanceMarginConfig {
274    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275        f.debug_struct("BinanceMarginConfig")
276            .field("api_key", &"***")
277            .field("secret_key", &"***")
278            .field("testnet", &self.testnet)
279            .field("is_isolated", &self.is_isolated)
280            .field("isolated_symbols", &self.isolated_symbols)
281            .field("side_effect", &self.side_effect)
282            .finish()
283    }
284}
285
286impl BinanceMarginConfig {
287    /// Construct a [`BinanceMarginConfig`] with explicit control over every field.
288    ///
289    /// Prefer [`BinanceMarginConfig::cross_margin`] / [`BinanceMarginConfig::isolated`] for the
290    /// common cases, or deserialize from a config file to keep credentials out of source. The
291    /// positional args expose every field for full control; defaultable knobs (`is_isolated`,
292    /// `isolated_symbols`, `side_effect`) also default via `#[serde(default)]` on the file path.
293    ///
294    /// Note: this does not itself validate `is_isolated` against `isolated_symbols`; that gate
295    /// lives in [`BinanceMargin::new`] (it must also cover the `Deserialize`-only path).
296    pub fn new(
297        api_key: String,
298        secret_key: String,
299        testnet: bool,
300        is_isolated: bool,
301        isolated_symbols: Vec<InstrumentNameExchange>,
302        side_effect: MarginSideEffect,
303    ) -> Self {
304        Self {
305            api_key,
306            secret_key,
307            testnet,
308            is_isolated,
309            isolated_symbols,
310            side_effect,
311        }
312    }
313
314    /// Convenience constructor for the common case: cross margin, production endpoints, and the
315    /// default [`MarginSideEffect::AutoBorrowRepay`] borrow/repay policy.
316    ///
317    /// Equivalent to [`new`](Self::new) with `testnet = false`, `is_isolated = false`, no
318    /// `isolated_symbols`, and the default `side_effect`. Use [`new`](Self::new) when any of those
319    /// need to differ.
320    pub fn cross_margin(api_key: String, secret_key: String) -> Self {
321        Self::new(
322            api_key,
323            secret_key,
324            false,
325            false,
326            Vec::new(),
327            MarginSideEffect::default(),
328        )
329    }
330
331    /// Convenience constructor for isolated margin: production endpoints, the default
332    /// [`MarginSideEffect::AutoBorrowRepay`] borrow/repay policy, and the given per-pair symbol
333    /// universe (see [`isolated_symbols`](Self::isolated_symbols)).
334    ///
335    /// `symbols` should be non-empty: an isolated client with no symbols has nothing to
336    /// snapshot or stream and [`BinanceMargin::new`] panics on it. Use [`new`](Self::new) to
337    /// override `side_effect`.
338    pub fn isolated(
339        api_key: String,
340        secret_key: String,
341        symbols: Vec<InstrumentNameExchange>,
342    ) -> Self {
343        Self::new(
344            api_key,
345            secret_key,
346            false,
347            true,
348            symbols,
349            MarginSideEffect::default(),
350        )
351    }
352
353    /// Read-only access to the API key (e.g. for logging or header construction).
354    pub fn api_key(&self) -> &str {
355        &self.api_key
356    }
357}
358
359// ---------------------------------------------------------------------------
360// BinanceMargin client
361// ---------------------------------------------------------------------------
362
363/// Binance Cross Margin execution client using the official binance-sdk.
364///
365/// Places orders and queries account state over the margin REST API
366/// (`margin_trading::rest_api`), and streams live account events via a hand-rolled
367/// `userListenToken` flow over the WS API, not the retired `/sapi/v1/userDataStream` listen-key
368/// path.
369///
370/// See [`BinanceMarginConfig::testnet`] for the no-testnet caveat. The behaviour a caller most needs to know
371/// is summarised below, with links to the authoritative detail.
372///
373/// # Borrow/repay (`sideEffectType`)
374/// Fixed once per client via [`BinanceMarginConfig::side_effect`] / [`MarginSideEffect`]. The
375/// default [`MarginSideEffect::AutoBorrowRepay`] makes shorting work out of the box but lets a
376/// **mis-sized order silently borrow** — use [`MarginSideEffect::NoBorrow`] to opt out. Per-order
377/// borrow intent is intentionally not modelled (it would require position tracking); see
378/// [`MarginSideEffect`] for the rationale and upgrade path.
379///
380/// # Cross vs. isolated margin
381/// The mode is fixed per client via [`BinanceMarginConfig::is_isolated`]:
382/// - **Cross** (`is_isolated = false`, `isIsolated = "FALSE"`, account-wide collateral): per-asset
383///   balances (incl. debt) are surfaced account-wide in the top-level `balances`.
384/// - **Isolated** (`is_isolated = true`): per-pair sub-accounts. Balances + risk are attached
385///   **per-instrument** via [`InstrumentAccountSnapshot::isolated`] (the asset-keyed top-level
386///   `balances` is left empty, since `(pair, asset)` slots would collide), and per-symbol queries
387///   are scoped to the configured [`BinanceMarginConfig::isolated_symbols`]. See
388///   [`account_snapshot`](Self::account_snapshot) for the full per-method semantics.
389///
390/// # Trailing stops
391/// As on spot, a `TrailingStop` is placed as a `STOP_LOSS` with a `trailingDelta`, and a
392/// `TrailingStopFixedLimit` as a `STOP_LOSS_LIMIT` with a `trailingDelta` and its fixed limit as
393/// `price`, for `BasisPoints` and `Percentage` offsets that come to a whole number of basis
394/// points. An activation price is sent as `stopPrice`. Absolute offsets and `TrailingStopLimit`
395/// return [`OrderError::UnsupportedOrderType`]. See [`open_order`](Self::open_order).
396///
397/// # Conditional orders read back over REST
398/// Binance margin accepts a `trailingDelta` on every conditional type (`STOP_LOSS`,
399/// `STOP_LOSS_LIMIT`, `TAKE_PROFIT`, `TAKE_PROFIT_LIMIT`), but its REST order queries, as
400/// binance-sdk models them, do not report one (#541). A conditional order read over REST could
401/// therefore be a fixed or a trailing order, so [`fetch_open_orders`](Self::fetch_open_orders) and
402/// [`account_snapshot`](Self::account_snapshot) leave a live one out rather than describe it
403/// wrongly. `account_snapshot` reports such a listing incomplete; `fetch_open_orders` has no way
404/// to, so there the omission shows only in the log. The [`account_stream`](Self::account_stream)
405/// reports these orders in full: its `executionReport` carries the trailing delta.
406///
407/// An order that has ended is still reported as ended by
408/// [`fetch_ended_orders`](crate::client::OrderStatusClient::fetch_ended_orders) and by the stream's
409/// check after a reconnect, so that a stop that fired is not held as live. Its kind is the one this
410/// client holds it with: every order it placed, listed or saw on its stream while live. An order
411/// it does not hold is reported with its fixed-trigger kind at `stopPrice`, which a trailing order
412/// it really was does not match, and one of those without a positive `stopPrice` cannot be
413/// described at all and reads as not ended, with a warning.
414///
415/// # User-data stream (`userListenToken`)
416/// [`account_stream`](Self::account_stream) is hand-rolled over the `userListenToken` model — the
417/// legacy margin listen-key user-data API (`/sapi/v1/userDataStream`) was retired by Binance on
418/// 2026-02-20, and binance-sdk has no binding for its replacement. There is **no keepalive ping**
419/// (the retired listen-key `PUT` mechanism): instead the token (~24h validity) is re-acquired and
420/// re-subscribed before its `expirationTime`, transparently across reconnects.
421///
422/// The Risk Data Stream a cross account stream also reads (see
423/// [`account_stream`](Self::account_stream)'s "Margin notices") is a different, current API: its
424/// listen key comes from `/sapi/v1/margin/listen-key`, which binance-sdk does bind, and is kept
425/// alive every 30 minutes.
426///
427/// # Margin balances & debt-freshness
428/// Balances carry per-asset margin debt: [`Balance::net_asset`](crate::balance::Balance::net_asset)
429/// returns `total - borrowed`, with `borrowed`/`interest` exposed via
430/// [`MarginDetails`](crate::balance::MarginDetails). Authoritative debt totals come from the REST
431/// [`account_snapshot`](Self::account_snapshot) (`BalanceSnapshot`); the WS stream keeps
432/// `free`/`locked` live via `BalanceStreamUpdate` but **never** clobbers or re-establishes debt, and
433/// `USER_LIABILITY_CHANGE` is surfaced as an observable log only, never accumulated into state.
434/// Consequently `net_asset` reflects debt only as fresh as the last `account_snapshot` for that
435/// asset — call it at startup and refresh on demand (see [`account_stream`](Self::account_stream)'s
436/// cold-start note).
437///
438/// As Binance documents it, margin interest is charged hourly on what is borrowed and accrues as
439/// the asset's unpaid `interest`, which comes out of `total` only when it is repaid. `net_asset`
440/// deducts `borrowed` alone, so until then `interest` is the only figure that holds it, as fresh
441/// as the rest of the debt. Fills carry no interest, so PnL computed from them excludes it.
442///
443/// # Rate limits
444/// Once a `/sapi` response reports at least 90% of the documented per-IP weight limit (12000 per
445/// minute) used, queries wait for the next minute, so the rest is left for orders and cancels,
446/// which never wait for it. A query, including a [`fetch_open_orders`](Self::fetch_open_orders)
447/// after a reconnect, can therefore take up to about a minute longer. A reconnect's fill recovery
448/// and its `userListenToken` request do not wait for it either, since a pause could outlast the
449/// recovery's 30 s and delay its fills to a retry. The per-UID `/sapi` limit is not tracked.
450/// After Binance answers with a rate-limit error, every REST call waits out a cooldown.
451///
452/// # One client per engine (`ExchangeId`)
453/// All emitted events — cross and isolated alike — are stamped [`ExchangeId::BinanceMargin`], and
454/// the engine routes `AssetStates` / `ConnectivityStates` by `ExchangeId`. Running **two**
455/// `BinanceMargin` clients in a single engine (e.g. one cross, one isolated) therefore collides
456/// their exchange identity. A single engine should run **at most one** `BinanceMargin` client; a
457/// consumer needing cross and isolated concurrently runs them in separate engines.
458///
459/// # Fills of other clients' orders
460/// The account stream, fill recovery and `fetch_trades` report every fill on the account, whoever
461/// placed the order, and Binance does not say who did. A consumer that also trades the account
462/// outside the engine filters those fills itself, for example by
463/// [`Trade::client_order_id`](crate::trade::Trade::client_order_id). See the
464/// [module docs](super#every-fill-on-the-account-is-delivered).
465#[derive(Clone)]
466pub struct BinanceMargin {
467    config: Arc<BinanceMarginConfig>,
468    // REST client for orders/queries (order submission/cancel, account snapshots, balances, trades).
469    rest: Arc<RestApi>,
470    // Production-configured REST config (credentials + base path) reused by the hand-rolled
471    // `userListenToken` POST, which goes through the SDK's `common::utils::send_request` (the SDK
472    // binds no endpoint for it) rather than the typed `RestApi` surface.
473    rest_config: Arc<ConfigurationRestApi>,
474    // WS-API configuration (credentials + ws_url) for the user-data stream. Consumed by
475    // `account_stream`, which constructs a `common::websocket::WebsocketApi` directly (the SDK's
476    // spot ws-api wrapper is unusable for margin — private base, no generic send/receive surface).
477    ws_config: ConfigurationWebsocketApi,
478    // shared rate-limit tracker across all REST calls
479    rate_limiter: Arc<RateLimitTracker>,
480    // The orders seen live and not yet seen end, which a reconnect asks about. Shared by every
481    // clone and every account stream, since an order placed through one ends on any of them.
482    known_live: SharedKnownLiveOrders,
483}
484
485impl std::fmt::Debug for BinanceMargin {
486    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
487        f.debug_struct("BinanceMargin")
488            .field("testnet", &self.config.testnet)
489            .field("is_isolated", &self.config.is_isolated)
490            .field("side_effect", &self.config.side_effect)
491            .finish_non_exhaustive()
492    }
493}
494
495impl BinanceMargin {
496    /// Hold each of `orders` as live, for a reconnect to ask about if the stream misses its end.
497    fn remember_live<'a>(
498        &self,
499        orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
500    ) {
501        let mut known = self.known_live.lock();
502        for order in orders {
503            known.live(&order.key, order.quantity, order.kind, &order.state);
504        }
505    }
506
507    /// Build the production-configured margin REST configuration (credentials + base path).
508    ///
509    /// The base path is pinned to production (`https://api.binance.com`): margin/SAPI has no
510    /// testnet, and the config is reused both for the SDK `RestApi` and the hand-rolled
511    /// `userListenToken` POST (which joins `/sapi/v1/userListenToken` onto this base).
512    ///
513    /// # Panics
514    /// Panics if the binance-sdk configuration builder fails (invalid credentials format).
515    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
516    fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
517        ConfigurationRestApi::builder()
518            .api_key(config.api_key.clone())
519            .api_secret(config.secret_key.clone())
520            .base_path(MARGIN_TRADING_REST_API_PROD_URL)
521            .build()
522            .expect("failed to build Binance margin REST configuration")
523    }
524
525    /// Build the WS-API configuration for the `userListenToken` user-data stream.
526    ///
527    /// Captures the credentials and pins the WS-API endpoint (the same `wss://ws-api.binance.com`
528    /// endpoint spot uses). The connection itself is established by
529    /// [`account_stream`](crate::client::ExecutionClient::account_stream) later (it
530    /// constructs a `common::websocket::WebsocketApi` directly); this only prepares the config.
531    ///
532    /// # Panics
533    /// Panics if the binance-sdk configuration builder fails (invalid credentials format).
534    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
535    fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
536        ConfigurationWebsocketApi::builder()
537            .api_key(config.api_key.clone())
538            .api_secret(config.secret_key.clone())
539            // Margin user-data streams use the same `wss://ws-api.binance.com` endpoint as spot — the
540            // WS API is unified; only the REST base (SAPI) differs. The constant name reflects origin,
541            // not an accidental reuse of a spot-specific URL.
542            .ws_url(SPOT_WS_API_PROD_URL)
543            .build()
544            .expect("failed to build Binance margin WebSocket configuration")
545    }
546
547    /// Resolve the per-call effective isolated symbol set (isolated mode only).
548    ///
549    /// Isolated margin queries are per-symbol on the venue (there is no account-wide "no symbol =
550    /// all" affordance), so every isolated snapshot/query iterates an explicit symbol set:
551    /// - empty `instruments` (the "return all" sentinel) → the full configured
552    ///   [`isolated_symbols`](BinanceMarginConfig::isolated_symbols);
553    /// - non-empty → `instruments ∩ isolated_symbols`; any requested instrument NOT in
554    ///   `isolated_symbols` is skipped with a `warn!` (there is no configured isolated
555    ///   token/stream/snapshot for it — returning it would be a silent mismatch).
556    ///
557    /// Shared by `account_snapshot`, `fetch_open_orders`, and `fetch_trades` so their instrument,
558    /// open-order, and trade sets cannot drift apart.
559    fn effective_isolated_set(
560        &self,
561        instruments: &[InstrumentNameExchange],
562    ) -> Vec<InstrumentNameExchange> {
563        if instruments.is_empty() {
564            return self.config.isolated_symbols.clone();
565        }
566        instruments
567            .iter()
568            .filter(|inst| {
569                let in_set = self.config.isolated_symbols.contains(*inst);
570                if !in_set {
571                    warn!(
572                        instrument = %inst.name(),
573                        "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
574                    );
575                }
576                in_set
577            })
578            .cloned()
579            .collect()
580    }
581
582    /// Isolated-margin `account_snapshot`: per-pair balances + risk attached per-instrument, with
583    /// the asset-keyed top-level `balances` left empty. Open orders and
584    /// isolated balances cover the same effective isolated set so they cannot diverge.
585    async fn isolated_account_snapshot(
586        &self,
587        instruments: &[InstrumentNameExchange],
588    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
589        use futures::{StreamExt as _, TryStreamExt as _};
590
591        let effective = self.effective_isolated_set(instruments);
592        if effective.is_empty() {
593            // No configured isolated pair matched: a no-symbol isolated call is invalid, so there is
594            // nothing to query — return an empty snapshot rather than an account-wide one.
595            return Ok(AccountSnapshot::new(
596                ExchangeId::BinanceMargin,
597                Vec::new(),
598                Vec::new(),
599            ));
600        }
601
602        // Per-pair isolated balances + risk (chunked ≤5 symbols/request), keyed by instrument.
603        let mut balances_by_symbol = convert_isolated_margin_assets(
604            fetch_isolated_margin_account_info(
605                self.rest.clone(),
606                self.rate_limiter.clone(),
607                effective.clone(),
608            )
609            .await?,
610        );
611
612        // Open orders per instrument over the same effective set, concurrently (bounded to avoid
613        // bursting request-weight limits).
614        let instrument_snapshots: Vec<_> =
615            futures::stream::iter(effective.into_iter().map(|instrument| {
616                fetch_margin_open_orders_for_instrument(
617                    self.rest.clone(),
618                    self.rate_limiter.clone(),
619                    instrument,
620                    true,
621                    RequestKind::Query,
622                )
623            }))
624            .buffer_unordered(8)
625            .map(|result| {
626                let (inst, listing) = result?;
627                self.remember_live(&listing.orders);
628                let wrapped = listing
629                    .orders
630                    .into_iter()
631                    .map(active_order_snapshot)
632                    .collect();
633                let isolated = balances_by_symbol.remove(&inst);
634                if isolated.is_none() {
635                    // The same effective set drives both balances and open orders, so a miss here
636                    // means the venue's isolated-account response omitted (or mis-keyed) a requested
637                    // pair. Surface it rather than silently emitting `isolated: None`.
638                    warn!(
639                        instrument = %inst.name(),
640                        "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
641                    );
642                }
643                Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
644                    inst,
645                    wrapped,
646                    listing.complete,
647                    PositionReport::Unreported,
648                    isolated,
649                ))
650            })
651            .try_collect()
652            .await?;
653
654        // Isolated balances live on the instrument snapshots; the asset-keyed top-level vec stays
655        // empty so the engine's `update_from_account` never writes colliding per-asset slots.
656        Ok(AccountSnapshot::new(
657            ExchangeId::BinanceMargin,
658            Vec::new(),
659            instrument_snapshots,
660        ))
661    }
662}
663
664impl ExecutionClient for BinanceMargin {
665    const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
666
667    // Margin is leverage applied to spot pairs, not a distinct instrument kind: a cross or
668    // isolated margin order is still an order on a `Spot` symbol.
669    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
670        &[InstrumentKindDiscriminant::Spot];
671    type Config = BinanceMarginConfig;
672    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
673
674    /// Construct a `BinanceMargin` client from its configuration.
675    ///
676    /// # Panics
677    /// Panics if:
678    /// - the binance-sdk configuration builder fails (e.g. empty or malformed API key/secret),
679    ///   matching [`BinanceSpot`](super::spot::BinanceSpot)'s startup contract; or
680    /// - `is_isolated = true` but [`isolated_symbols`](BinanceMarginConfig::isolated_symbols) is
681    ///   empty — an isolated client with no configured pairs has nothing to snapshot or stream.
682    ///   This gate lives here (not only in [`BinanceMarginConfig::isolated`]) because the config is
683    ///   `Deserialize`-only and a deserialized config bypasses the named constructor. The
684    ///   `ExecutionClient::new` signature returns `Self`, not `Result`, so an unusable config is a
685    ///   fail-fast panic (consistent with the credential `expect`s above), not a recoverable error.
686    fn new(config: Self::Config) -> Self {
687        if config.testnet {
688            warn!(
689                "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
690                 using production endpoints"
691            );
692        }
693        assert!(
694            !(config.is_isolated && config.isolated_symbols.is_empty()),
695            "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
696        );
697        // Built once and shared: one clone is consumed by the SDK `RestApi`, the original is retained
698        // for the hand-rolled `userListenToken` POST via `send_request`. `ConfigurationRestApi: Clone`
699        // and is cheap to copy (its `reqwest::Client` is `Arc`-backed), avoiding redundant credential
700        // clones from building it twice.
701        let rest_config = Self::build_rest_config(&config);
702        let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
703        let rest_config = Arc::new(rest_config);
704        let ws_config = Self::build_ws_config(&config);
705        Self {
706            config: Arc::new(config),
707            rest,
708            rest_config,
709            ws_config,
710            rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
711            known_live: KnownLiveOrders::shared(ExchangeId::BinanceMargin),
712        }
713    }
714
715    /// Submit a margin order over the SAPI `POST /sapi/v1/margin/order` endpoint.
716    ///
717    /// Mirrors [`BinanceSpot::open_order`](super::spot::BinanceSpot)'s contract: never returns
718    /// `None` (every failure is folded into the returned [`Order`]'s state as
719    /// [`OrderState::inactive`]), so the engine always sees a definitive outcome.
720    ///
721    /// Margin specifics:
722    /// - `sideEffectType` is the client-level [`MarginSideEffect`] (borrow/repay policy).
723    /// - `isIsolated` is config-driven (`"TRUE"` for isolated, `"FALSE"` for cross).
724    /// - `autoRepayAtCancel` is set only under [`MarginSideEffect::AutoBorrowRepay`]: a `NoBorrow`
725    ///   client takes no loan, so requesting repay-on-cancel would be incoherent.
726    /// - A `TrailingStop` is sent as a `STOP_LOSS` with `trailingDelta` in basis points, and a
727    ///   `TrailingStopFixedLimit` as a `STOP_LOSS_LIMIT` with `trailingDelta` and its fixed limit
728    ///   as `price`; an activation price goes as `stopPrice`. An Absolute offset, and
729    ///   `TrailingStopLimit`, return [`OrderError::UnsupportedOrderType`]; an offset that is not a
730    ///   positive whole number of basis points returns [`OrderError::InvalidPrecision`] (see the
731    ///   type-level docs). A non-positive activation price, or a `TrailingStopFixedLimit` without
732    ///   a positive `price`, is refused unsent as [`OrderError::Rejected`].
733    async fn open_order(
734        &self,
735        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
736    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
737        let instrument = request.key.instrument.clone();
738        let side = request.state.side;
739        let price = request.state.price;
740        let quantity = request.state.quantity;
741        let kind = request.state.kind;
742        let time_in_force = request.state.time_in_force;
743        let cid = request.key.cid.clone();
744
745        let order_key = OrderKey::new(
746            ExchangeId::BinanceMargin,
747            instrument.clone(),
748            request.key.strategy.clone(),
749            cid.clone(),
750        );
751
752        // Build the returned Order with a given inactive (failure) state, preserving the request
753        // fields — keeps the many early-return error paths to one line each.
754        let inactive = |state: UnindexedOrderState| Order {
755            key: order_key.clone(),
756            side,
757            price,
758            quantity,
759            kind,
760            time_in_force,
761            state,
762        };
763
764        let params = match build_new_order_params(
765            instrument.name().to_string(),
766            side,
767            price,
768            quantity,
769            kind,
770            time_in_force,
771            cid.0.to_string(),
772            self.config.side_effect,
773            self.config.is_isolated,
774        ) {
775            Ok(params) => params,
776            Err(BuildOrderError::Unsupported) => {
777                return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
778                    format!(
779                        "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
780                    ),
781                )));
782            }
783            Err(BuildOrderError::Trailing(refused)) => {
784                return inactive(OrderState::inactive(refused.into_order_error()));
785            }
786            Err(BuildOrderError::Build(msg)) => {
787                error!(%msg, "BinanceMargin failed to build new order params");
788                return inactive(OrderState::inactive(OrderError::Rejected(
789                    ApiError::OrderRejected(msg),
790                )));
791            }
792        };
793
794        let response =
795            match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
796                let params = params.clone();
797                Box::pin(async move { rest.margin_account_new_order(params).await })
798            })
799            .await
800            {
801                Ok(response) => response,
802                Err(e) => {
803                    return inactive(OrderState::inactive(classify_rest_order_error(
804                        &e,
805                        &instrument,
806                    )));
807                }
808            };
809
810        let data = match response.data().await {
811            Ok(data) => data,
812            Err(e) => {
813                // Deserialization failure on a 2xx response — surface as a rejection, not a
814                // transport error (the order did reach the venue).
815                return inactive(OrderState::inactive(OrderError::Rejected(
816                    ApiError::OrderRejected(e.to_string()),
817                )));
818            }
819        };
820
821        let time_exchange = data
822            .transact_time
823            .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
824            .unwrap_or_else(Utc::now);
825
826        let exchange_order_id = match data.order_id {
827            Some(id) => OrderId(format_smolstr!("{id}")),
828            None => {
829                error!("BinanceMargin open_order response missing orderId");
830                return inactive(OrderState::inactive(OrderError::Rejected(
831                    ApiError::OrderRejected("open_order response missing orderId".into()),
832                )));
833            }
834        };
835
836        // Read from the response's status, so an order that ended in it (an IOC or FOK order
837        // that expired) is not reported, or held, as live.
838        let state = placed_order_state(
839            ExchangeId::BinanceMargin,
840            &instrument,
841            quantity,
842            PlacementResponse {
843                status: data.status.as_deref(),
844                order_id: exchange_order_id,
845                time_exchange,
846                executed_qty: data.executed_qty.as_deref(),
847                cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
848            },
849        );
850        self.known_live
851            .lock()
852            .placed(&order_key, quantity, kind, &state);
853
854        Order {
855            key: order_key,
856            side,
857            price,
858            quantity,
859            kind,
860            time_in_force,
861            state,
862        }
863    }
864
865    /// Cancel a resting margin order via `DELETE /sapi/v1/margin/order`.
866    ///
867    /// Cancels by exchange `orderId` when present and parseable, otherwise by the originating
868    /// client order id. `isIsolated` is config-driven. Mirrors
869    /// [`BinanceSpot::cancel_order`](super::spot::BinanceSpot): every failure is folded into the
870    /// returned response's `state` as an `Err`.
871    ///
872    /// The margin cancel response carries no `transactTime`, so the cancellation timestamp is the
873    /// local receive time.
874    async fn cancel_order(
875        &self,
876        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
877    ) -> UnindexedOrderResponseCancel {
878        let instrument = request.key.instrument.clone();
879        let key = OrderKey {
880            exchange: request.key.exchange,
881            instrument: instrument.clone(),
882            strategy: request.key.strategy.clone(),
883            cid: request.key.cid.clone(),
884        };
885
886        let params = match build_cancel_order_params(
887            instrument.name().to_string(),
888            request.state.id.as_ref().and_then(VenueOrderId::assigned),
889            &request.key.cid,
890            self.config.is_isolated,
891        ) {
892            Ok(p) => p,
893            Err(e) => {
894                error!(%e, "BinanceMargin failed to build cancel order params");
895                return UnindexedOrderResponseCancel {
896                    key,
897                    state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
898                };
899            }
900        };
901
902        let response =
903            match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
904                let params = params.clone();
905                Box::pin(async move { rest.margin_account_cancel_order(params).await })
906            })
907            .await
908            {
909                Ok(response) => response,
910                Err(e) => {
911                    return UnindexedOrderResponseCancel {
912                        key,
913                        state: Err(classify_rest_order_error(&e, &instrument)),
914                    };
915                }
916            };
917
918        let data = match response.data().await {
919            Ok(data) => data,
920            Err(e) => {
921                // Deserialization failure on a 2xx response — surface as a rejection, not a
922                // transport error (the cancel request did reach the venue), mirroring open_order.
923                return UnindexedOrderResponseCancel {
924                    key,
925                    state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
926                };
927            }
928        };
929
930        // Margin cancel response has no transactTime field — use local receive time.
931        let time_exchange = Utc::now();
932
933        let exchange_order_id = match data.order_id {
934            // NB: the margin cancel response types orderId as a String (the new-order response
935            // types it as i64) — use it verbatim.
936            Some(id) => OrderId(SmolStr::new(id)),
937            None => {
938                error!("BinanceMargin cancel response missing orderId");
939                return UnindexedOrderResponseCancel {
940                    key,
941                    state: Err(OrderError::Rejected(ApiError::OrderRejected(
942                        "cancel response missing orderId".into(),
943                    ))),
944                };
945            }
946        };
947
948        let filled_qty = binance_filled_qty(
949            ExchangeId::BinanceMargin,
950            &exchange_order_id,
951            data.executed_qty.as_deref(),
952        );
953
954        self.known_live.lock().ended(&key.cid);
955        UnindexedOrderResponseCancel {
956            key,
957            state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
958        }
959    }
960
961    /// Fetch a full margin account snapshot: balances plus open orders per instrument.
962    ///
963    /// **Cross** (`is_isolated = false`): account-wide per-asset balances from
964    /// `query_cross_margin_account_details` (carrying `borrowed`/`interest` → [`Balance::new_margin`])
965    /// in the top-level `balances`, plus open orders per requested instrument — mirroring
966    /// [`BinanceSpot::account_snapshot`](super::spot::BinanceSpot).
967    ///
968    /// **Isolated** (`is_isolated = true`): per-pair balances + risk from
969    /// `query_isolated_margin_account_info` (chunked ≤5 symbols/request), attached **per-instrument**
970    /// via [`InstrumentAccountSnapshot::isolated`] rather than folded into the asset-keyed
971    /// top-level `balances` (which is left **empty**) — isolated sub-accounts are per-`(pair, asset)`
972    /// and would collide in the asset-keyed model. The instrument set is the effective isolated set
973    /// (`isolated_symbols`, or `instruments ∩ isolated_symbols` with out-of-set instruments skipped);
974    /// each instrument's open orders and isolated balances are fetched over that same set.
975    async fn account_snapshot(
976        &self,
977        assets: &[AssetNameExchange],
978        instruments: &[InstrumentNameExchange],
979    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
980        if self.config.is_isolated {
981            return self.isolated_account_snapshot(instruments).await;
982        }
983        let response =
984            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
985                Box::pin(async move {
986                    let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
987                    rest.query_cross_margin_account_details(params).await
988                })
989            })
990            .await
991            .map_err(|e| classify_rest_query_error(&e, None))?;
992
993        let account = response.data().await.map_err(response_decode_error)?;
994
995        let balances =
996            filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
997
998        // Fetch open orders for all instruments concurrently (with retry), limiting concurrency
999        // to avoid bursting Binance's request weight limits. account_snapshot wraps Open orders
1000        // in OrderState::active(); fetch_open_orders returns them without the wrapper — both use
1001        // fetch_margin_open_orders_for_instrument.
1002        use futures::{StreamExt as _, TryStreamExt as _};
1003        let instrument_snapshots: Vec<_> =
1004            futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1005                fetch_margin_open_orders_for_instrument(
1006                    self.rest.clone(),
1007                    self.rate_limiter.clone(),
1008                    instrument,
1009                    // Cross path only: isolated returns early via `isolated_account_snapshot`.
1010                    false,
1011                    RequestKind::Query,
1012                )
1013            }))
1014            .buffer_unordered(8)
1015            .map(|result| {
1016                let (inst, listing) = result?;
1017                self.remember_live(&listing.orders);
1018                let wrapped = listing
1019                    .orders
1020                    .into_iter()
1021                    .map(active_order_snapshot)
1022                    .collect();
1023                Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1024                    inst,
1025                    wrapped,
1026                    listing.complete,
1027                    PositionReport::Unreported,
1028                    None,
1029                ))
1030            })
1031            .try_collect()
1032            .await?;
1033
1034        Ok(AccountSnapshot::new(
1035            ExchangeId::BinanceMargin,
1036            balances,
1037            instrument_snapshots,
1038        ))
1039    }
1040
1041    /// Fetch current margin balances (incl. `borrowed`/`interest` debt) for the requested assets.
1042    ///
1043    /// **Cross**: account-wide per-asset balances; an empty `assets` slice is the "return all"
1044    /// sentinel.
1045    ///
1046    /// **Isolated**: returns an empty `Vec`. Isolated balances are per-`(pair, asset)` and the
1047    /// asset-keyed return type cannot carry them without collision — they are surfaced per-instrument
1048    /// via [`account_snapshot`](Self::account_snapshot)'s [`InstrumentAccountSnapshot::isolated`]
1049    /// instead.
1050    async fn fetch_balances(
1051        &self,
1052        assets: &[AssetNameExchange],
1053    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1054        if self.config.is_isolated {
1055            return Ok(Vec::new());
1056        }
1057        let response =
1058            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1059                Box::pin(async move {
1060                    let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1061                    rest.query_cross_margin_account_details(params).await
1062                })
1063            })
1064            .await
1065            .map_err(|e| classify_rest_query_error(&e, None))?;
1066
1067        let account = response.data().await.map_err(response_decode_error)?;
1068
1069        Ok(filter_and_convert_margin_balances(
1070            account.user_assets.unwrap_or_default(),
1071            assets,
1072        ))
1073    }
1074
1075    /// Fetch currently open margin orders, optionally filtered by instrument.
1076    ///
1077    /// **Cross**: honours the `ExecutionClient::fetch_open_orders` "return all" sentinel — an empty
1078    /// `instruments` slice is served by a single no-symbol `query_margin_accounts_open_orders` call
1079    /// (each order's instrument taken from its own `symbol`); a non-empty slice fetches the listed
1080    /// instruments concurrently, per-symbol.
1081    ///
1082    /// **Isolated**: per-symbol on the venue — always iterates the effective isolated set (empty
1083    /// `instruments` → configured `isolated_symbols`; out-of-set instruments skipped with a warning);
1084    /// never issues a no-symbol isolated call.
1085    ///
1086    /// **Conditional orders are omitted** (`STOP_LOSS`, `STOP_LOSS_LIMIT`, `TAKE_PROFIT`,
1087    /// `TAKE_PROFIT_LIMIT`, so every `Stop`, `StopLimit`, `TakeProfit`, `TakeProfitLimit` and
1088    /// `TrailingStop`): the REST row cannot tell a fixed from a trailing one (#541). This returns
1089    /// no completeness flag, so the omission shows only in the log. A caller that needs to know
1090    /// whether the result is the whole book reads [`account_snapshot`](Self::account_snapshot),
1091    /// which reports such a listing incomplete, and tracks these orders from the
1092    /// [`account_stream`](Self::account_stream). See the type-level docs.
1093    async fn fetch_open_orders(
1094        &self,
1095        instruments: &[InstrumentNameExchange],
1096    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1097        use futures::{StreamExt as _, TryStreamExt as _};
1098
1099        // Isolated: per-symbol on the venue — always iterate the effective set (never a no-symbol
1100        // isolated call, which would error). The empty-`instruments` sentinel resolves to the
1101        // configured isolated_symbols.
1102        if self.config.is_isolated {
1103            let effective = self.effective_isolated_set(instruments);
1104            let capacity = effective.len();
1105            return futures::stream::iter(effective.into_iter().map(|instrument| {
1106                fetch_margin_open_orders_for_instrument(
1107                    self.rest.clone(),
1108                    self.rate_limiter.clone(),
1109                    instrument,
1110                    true,
1111                    RequestKind::Query,
1112                )
1113            }))
1114            .buffer_unordered(8)
1115            .try_fold(
1116                Vec::with_capacity(capacity),
1117                |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1118                 (_, listing)| async move {
1119                    acc.extend(listing.orders);
1120                    Ok(acc)
1121                },
1122            )
1123            .await
1124            .inspect(|orders| self.remember_live(orders));
1125        }
1126
1127        // Cross: empty slice = "return all" sentinel — a single no-symbol query is both correct
1128        // (the contract requires all instruments) and far cheaper than enumerating every symbol.
1129        if instruments.is_empty() {
1130            let orders =
1131                fetch_margin_all_open_orders(self.rest.clone(), self.rate_limiter.clone(), false)
1132                    .await?;
1133            self.remember_live(&orders);
1134            return Ok(orders);
1135        }
1136        futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1137            fetch_margin_open_orders_for_instrument(
1138                self.rest.clone(),
1139                self.rate_limiter.clone(),
1140                instrument,
1141                false,
1142                RequestKind::Query,
1143            )
1144        }))
1145        .buffer_unordered(8)
1146        .try_fold(
1147            Vec::with_capacity(instruments.len()),
1148            |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1149                acc.extend(listing.orders);
1150                Ok(acc)
1151            },
1152        )
1153        .await
1154        .inspect(|orders| self.remember_live(orders))
1155    }
1156
1157    /// Read margin trades (fills) from `start` to `end`, optionally filtered by instrument.
1158    ///
1159    /// **Documented deviation from the `ExecutionClient::fetch_trades` "return all" contract:**
1160    /// Binance's margin trade-list endpoint (`myTrades`) requires a symbol — there is no no-symbol
1161    /// "all trades" query (unlike open orders).
1162    ///
1163    /// **Cross**: an empty `instruments` slice has nothing to query and reads nothing; callers
1164    /// wanting all trades must enumerate instruments explicitly.
1165    ///
1166    /// **Isolated**: the empty sentinel resolves to the configured `isolated_symbols` (the effective
1167    /// isolated set; out-of-set instruments skipped with a warning), iterated per-symbol (Design
1168    /// decision #4).
1169    ///
1170    /// **Complete:** each instrument is read to the span's end within the call, with no page cap,
1171    /// so the read is always complete (`resume: None`).
1172    ///
1173    /// **Lookback cost:** Binance serves margin trades by time only in spans under 24 hours, so
1174    /// each instrument is read in 23-hour windows from `start` until its first trade, one
1175    /// request (weight 10) per window, then by trade id. The cost grows with the lookback and
1176    /// has no cap: a `start` 30 days back with no trades costs about 32 requests per
1177    /// instrument, and one at the Unix epoch about 21,000. Pass the most recent `start` that
1178    /// covers what you need.
1179    ///
1180    /// **Upper bound:** trades are read up to `end`, or up to the local clock at the call if that
1181    /// is earlier. A trade stamped after the local clock, as one inside any skew between it and
1182    /// Binance's can be, is left to the next read, unless a window before the last one held a
1183    /// trade (or the last one read a full page) and the walk went on to page by id, which reads
1184    /// on to `end`.
1185    async fn fetch_trades(
1186        &self,
1187        start: DateTime<Utc>,
1188        end: DateTime<Utc>,
1189        instruments: &[InstrumentNameExchange],
1190    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1191        use futures::StreamExt as _;
1192
1193        if start > end {
1194            return Ok(TradesRead::complete(Vec::new()));
1195        }
1196
1197        // Resolve the effective instrument set. Isolated resolves the empty sentinel to the
1198        // configured isolated_symbols; cross keeps the slice verbatim (no no-symbol "all" form).
1199        let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1200            self.effective_isolated_set(instruments)
1201        } else {
1202            instruments.to_vec()
1203        };
1204        if effective.is_empty() {
1205            // Distinguish the two empty causes: cross with an empty `instruments` slice (the
1206            // documented no-op sentinel) vs. isolated with no configured `isolated_symbols` matched.
1207            debug!(
1208                is_isolated = self.config.is_isolated,
1209                "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1210            );
1211            return Ok(TradesRead::complete(Vec::new()));
1212        }
1213        // Binance stamps trades in whole milliseconds; the exact span is applied after the read.
1214        let span = MyTradesFrom::Span {
1215            start: start.timestamp_millis(),
1216            end: end.timestamp_millis(),
1217        };
1218        let is_isolated = self.config.is_isolated;
1219        let mut all_trades = Vec::new();
1220
1221        // Binance requires per-symbol queries for trade history. Limit concurrency to avoid
1222        // bursting Binance's request weight limits.
1223        let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1224            let rest = self.rest.clone();
1225            let rate_limiter = self.rate_limiter.clone();
1226            async move {
1227                let pages = paginate_margin_my_trades(
1228                    &rest,
1229                    &rate_limiter,
1230                    &inst,
1231                    span,
1232                    is_isolated,
1233                    RequestKind::Query,
1234                )
1235                .await?;
1236                Ok::<_, UnindexedClientError>((inst, pages))
1237            }
1238        }))
1239        .buffer_unordered(8);
1240        while let Some(result) = stream.next().await {
1241            let (instrument, trades_data) = result?;
1242            for t in trades_data {
1243                if let Some(trade) = convert_margin_trade(&t, &instrument)
1244                    && (start..=end).contains(&trade.time_exchange)
1245                {
1246                    all_trades.push(trade);
1247                }
1248            }
1249        }
1250
1251        Ok(TradesRead::complete(all_trades))
1252    }
1253
1254    /// Live stream of account events (fills, order updates, balance changes) over the hand-rolled
1255    /// `userListenToken` user-data stream.
1256    ///
1257    /// Acquires a `userListenToken` (signed `POST /sapi/v1/userListenToken`, cross = no params),
1258    /// subscribes over the WS API (`userDataStream.subscribe.listenToken`), and keeps the stream
1259    /// live with auto-reconnect, exponential backoff, heartbeat monitoring, and fill recovery
1260    /// (mirroring [`BinanceSpot`](super::spot::BinanceSpot)). The token is re-acquired and
1261    /// re-subscribed before its ~24h expiry — there is **no** listen-key keepalive (that API is
1262    /// retired).
1263    ///
1264    /// As on spot, each instrument's gap after a reconnect is kept until its fills are forwarded:
1265    /// a gap whose read failed or timed out is retried after 1, 2, 4, 8 and 16 minutes, connected
1266    /// or not in between, reading only the gap. After five failed retries it is given up, logged
1267    /// at `error` and reported as an [`AccountEventKind::FillRecoveryGaveUp`];
1268    /// [`ExecutionClient::fetch_trades`] can still read its fills.
1269    ///
1270    /// # Debt cold-start
1271    /// This method does **not** seed balances. Margin debt (`borrowed`/`interest`) is correct only
1272    /// if the caller invokes [`ExecutionClient::account_snapshot`] at startup (the `BalanceSnapshot`
1273    /// that populates `margin`). WS thereafter keeps `free`/`locked` live via `BalanceStreamUpdate`
1274    /// but never re-establishes debt; `USER_LIABILITY_CHANGE` is logged observably, not applied to
1275    /// balance state.
1276    ///
1277    /// # Margin notices
1278    /// Binance's `MARGIN_LEVEL_STATUS_CHANGE` is delivered as an
1279    /// [`AccountEventKind::Notice`]. Its status maps to a
1280    /// [`NoticeKind`](crate::NoticeKind): `MARGIN_CALL` → `MarginCall`, `PRE_LIQUIDATION` →
1281    /// `LiquidationWarning`, `FORCE_LIQUIDATION` → `Liquidation`, `NORMAL` and `EXCESSIVE` →
1282    /// `MarginRestored`, anything else → `Other`, with Binance's status verbatim in
1283    /// [`AccountNotice::status`](crate::AccountNotice::status) and its margin level (`l`) in
1284    /// [`margin_level`](crate::AccountNotice::margin_level).
1285    ///
1286    /// Binance's own SDKs read the event from the **Risk Data Stream**
1287    /// (`wss://margin-stream.binance.com`, keyed by `POST /sapi/v1/margin/listen-key`). Whether the
1288    /// `userListenToken` stream sends it too is not documented, so:
1289    /// - **Cross** reads both, and a notice sent on both is delivered once (matched on status and
1290    ///   Binance's event time; a frame with no time is stamped on arrival, so its two copies both
1291    ///   arrive). Notices name no instrument. The Risk Data Stream is a side channel, in a task of
1292    ///   its own: if it cannot get a listen key or connect, that is logged at `warn` and retried
1293    ///   with backoff up to 5 minutes, without limit, and the account stream carries on. Its key is
1294    ///   kept alive every 30 minutes (weight 1) and left to lapse when the stream ends.
1295    /// - **Isolated** reads only the `userListenToken` stream, so receives notices only if Binance
1296    ///   sends them there (unconfirmed). A notice names the pair whose subscription it came on, or
1297    ///   no instrument, with a `warn`, when the frame's `subscriptionId` is missing or unmapped.
1298    ///
1299    /// Each arrival is logged at `info`, naming the stream, before the duplicate is dropped. A
1300    /// notice sent while a stream is disconnected is not recovered.
1301    ///
1302    /// `USER_LIABILITY_CHANGE`, on either stream, is logged at `info` and not forwarded.
1303    ///
1304    /// # Startup race window
1305    /// Like spot, fills arriving between subscribe and the listener being registered may be missed;
1306    /// callers requiring startup fill completeness must call [`ExecutionClient::fetch_trades`] with
1307    /// a ~1s lookback after this returns.
1308    ///
1309    /// A recovered fill advances the order too. A live fill carries the order's cumulative filled
1310    /// quantity in [`Trade::order_filled_quantity`] (`executionReport`'s `z`); REST `myTrades`
1311    /// carries none, so recovery rebuilds the same figure by reading each recovered order's
1312    /// executions from its first: one extra request per order (another per further 1,000
1313    /// executions), up to four orders at a time per instrument. Those lookups have their own
1314    /// time budget inside the recovery timeout, so they can never cost a fill: a fill whose order
1315    /// was not looked up in time, or whose lookup failed, goes out with `order_filled_quantity:
1316    /// None`, logged at `warn`, and advances the position but not the order.
1317    ///
1318    /// # Orders that ended while disconnected
1319    ///
1320    /// A reconnect also reports how each order the client holds as live on one of the stream's
1321    /// instruments ended, where it did, as an [`AccountEventKind::OrderSnapshot`] of its inactive
1322    /// state: filled, cancelled with what filled before, expired, or rejected. The stream's
1323    /// instruments are `instruments` on cross and
1324    /// [`isolated_symbols`](BinanceMarginConfig::isolated_symbols) on isolated, as for fill
1325    /// recovery. Binance's order history filters on when an order was created, so such an order is
1326    /// found by asking about it, not by time.
1327    ///
1328    /// - **Which orders.** The client holds an order as live from the response to placing it,
1329    ///   from a listing of open orders ([`account_snapshot`](ExecutionClient::account_snapshot),
1330    ///   [`fetch_open_orders`](ExecutionClient::fetch_open_orders)), and from its live reports on
1331    ///   any of its account streams, until it sees the order end. It holds up to 4,096 orders and
1332    ///   forgets the oldest past that, logged at `warn`. An order placed outside this client and
1333    ///   never listed or reported to it is not covered.
1334    /// - **Cost.** One `GET /sapi/v1/margin/openOrders` per instrument with an order held (weight
1335    ///   10), then one `GET /sapi/v1/margin/order` (weight 10) for each held order the listing no
1336    ///   longer shows, all in the configured cross or isolated account.
1337    /// - **Fills first.** An instrument is checked only once its fill gap is recovered or given up,
1338    ///   so an order's recovered fills arrive before how it ended. A fill that brings an order to
1339    ///   its full quantity ends it, and that order is not reported again.
1340    /// - **Keys.** Each snapshot carries [`StrategyId::unknown`], since Binance records no
1341    ///   strategy. The engine matches it to the order it tracks by client order id.
1342    /// - **Failures.** Each order's lookup is settled as it ends, and each instrument as soon as its
1343    ///   check ends. One whose listing or any lookup fails, or whose check is still running when a
1344    ///   pass reaches 30 s, is retried on the fill gaps' schedule (1, 2, 4, 8 and 16 minutes),
1345    ///   asking only about the orders still held, then given up, logged at `error`. Its orders are
1346    ///   asked about again at the next reconnect. An order Binance does not know (`-2013`) stops being held, logged
1347    ///   at `warn`; one it still reports live, or in a state this version cannot read, stays held.
1348    ///
1349    /// The same lookup is public as [`OrderStatusClient::fetch_ended_orders`].
1350    ///
1351    /// # Isolated mode (multiplexed `userListenToken`)
1352    /// Under `is_isolated = true` a **separate** manager drives the stream (the cross path above is
1353    /// left untouched). It acquires one per-symbol `userListenToken` for each
1354    /// [`BinanceMarginConfig::isolated_symbols`] entry and multiplexes **all N subscriptions onto a
1355    /// single WS-API socket**. Every token is acquired, the socket connected, and all subscriptions
1356    /// confirmed **before this method returns** — if the connect or **any** subscribe fails, it
1357    /// returns `Err` with nothing spawned (preserving the "can't-start vs started-then-dropped"
1358    /// distinction). The `instruments` argument does **not** drive the isolated token set: the stream
1359    /// always covers exactly `isolated_symbols`.
1360    ///
1361    /// ## Live per-pair balances — [`InstrumentBalanceUpdate`](crate::AccountEventKind::InstrumentBalanceUpdate)
1362    /// The isolated stream delivers live fills and order updates (routed by the inner `symbol`) **and**
1363    /// live per-pair `free`/`locked` balances, emitted as
1364    /// [`AccountEventKind::InstrumentBalanceUpdate`]
1365    /// (base + quote per pair). Debt totals (`borrowed`/`interest`) stay REST-`BalanceSnapshot`-fresh
1366    /// per the debt-freshness contract; the stream keeps only `free`/`locked` live.
1367    ///
1368    /// **Consumption contract (important):** the engine deliberately does **not** store
1369    /// `InstrumentBalanceUpdate` — it falls through `update_from_account`'s `_ => None` wildcard,
1370    /// mirroring the snapshot's
1371    /// [`InstrumentAccountSnapshot::isolated`](crate::InstrumentAccountSnapshot::isolated). It is
1372    /// therefore **never in `EngineState`**, and a `StateReplicaManager` replica replays the same
1373    /// wildcard so it is **not** in replica state either. A consumer obtains live per-pair balances
1374    /// **only** by inspecting the raw account event off the stream (`AccountStreamEvent::Item(..)` /
1375    /// `audit_tick.event`) and matching the variant itself — a consumer reading solely replica
1376    /// `EngineState` will see nothing and wrongly conclude the feature is broken. A wrapper wanting
1377    /// engine-queryable per-instrument balances uses the `InstrumentData` extension point instead.
1378    ///
1379    /// ## First-prod-run check — `subscriptionId → symbol` routing
1380    /// `outboundAccountPosition` frames carry no symbol inline; on the multiplexed socket their pair
1381    /// is recovered via a `subscriptionId → symbol` map. This correlation is the one assumption no
1382    /// offline source could validate — **verify it on the first production run**. Fills and order
1383    /// updates are unaffected (they self-identify by `symbol`); if the correlation does not hold,
1384    /// per-pair balance frames are dropped with a `warn!` (never mis-applied) and balances degrade to
1385    /// snapshot-polling. The documented fallback is one socket per isolated symbol (symbol implied by
1386    /// the connection).
1387    async fn account_stream(
1388        &self,
1389        // _assets is intentionally ignored — Binance pushes outboundAccountPosition for all account
1390        // assets regardless of any filter (mirrors spot). See account_snapshot for filtering.
1391        _assets: &[AssetNameExchange],
1392        instruments: &[InstrumentNameExchange],
1393    ) -> Result<Self::AccountStream, UnindexedClientError> {
1394        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1395        let dedup = new_dedup_cache();
1396        let rest = self.rest.clone();
1397        let rest_config = self.rest_config.clone();
1398        let ws_config = self.ws_config.clone();
1399        let rate_limiter = self.rate_limiter.clone();
1400        let known_live = self.known_live.clone();
1401
1402        let cm_handle = if self.config.is_isolated {
1403            // --- Isolated: separate multiplexed manager over the configured isolated_symbols ---
1404            // The stream covers exactly `isolated_symbols` (the per-symbol token universe — Design
1405            // decision #1); the trait's `instruments` argument does NOT drive the isolated token set.
1406            let symbols = self.config.isolated_symbols.clone();
1407            // All current Binance margin symbols are ≤22 bytes (within SmolStr's 23-byte inline
1408            // limit); guard the implicit invariant so an over-long symbol is caught early.
1409            debug_assert!(
1410                symbols.iter().all(|i| i.name().len() <= 23),
1411                "instrument name exceeds SmolStr inline capacity: {:?}",
1412                symbols.iter().find(|i| i.name().len() > 23)
1413            );
1414
1415            // (0) Build the invariant `instrument → (base, quote)` map from the isolated account
1416            // info. Authoritative base/quote split for routing the symbol-less
1417            // `outboundAccountPosition` frames; built once, reused across
1418            // reconnects (a pair's base/quote never change). A REST failure here is a hard Err
1419            // before anything spawns.
1420            let assets = fetch_isolated_margin_account_info(
1421                rest.clone(),
1422                rate_limiter.clone(),
1423                symbols.clone(),
1424            )
1425            .await?;
1426            let base_quote = Arc::new(build_base_quote_map(&assets));
1427            for sym in &symbols {
1428                if !base_quote.contains_key(sym) {
1429                    warn!(
1430                        symbol = %sym.name(),
1431                        "BinanceMargin isolated: account info returned no base/quote for configured \
1432                         symbol — its live per-pair balance frames will be dropped (fills/orders \
1433                         unaffected; balances available via account_snapshot)"
1434                    );
1435                }
1436            }
1437
1438            // (1) Acquire all N per-symbol tokens, (2) connect one socket, (3) subscribe all N —
1439            // all before returning so connect-or-any-subscribe failure → Err (nothing spawned),
1440            // preserving cross's "can't-start vs started-then-dropped" distinction.
1441            let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1442            let live = isolated_connect_and_subscribe(
1443                &ws_config,
1444                &tx,
1445                &dedup,
1446                &known_live,
1447                &base_quote,
1448                &tokens,
1449            )
1450            .await
1451            .map_err(|e| {
1452                UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1453            })?;
1454
1455            tokio::spawn(isolated_connection_manager(
1456                tx,
1457                dedup,
1458                ws_config,
1459                rest_config,
1460                rest,
1461                rate_limiter,
1462                known_live,
1463                symbols,
1464                base_quote,
1465                Some(live),
1466            ))
1467        } else {
1468            // --- Cross: the live-validated account-wide manager (unchanged by isolated) ---
1469            let instruments = instruments.to_vec();
1470            // All current Binance margin symbols are ≤22 bytes (within SmolStr's 23-byte inline
1471            // limit), making clone() a stack memcpy with no heap allocation. Guard this implicit
1472            // invariant so future symbols that exceed the limit are caught early.
1473            debug_assert!(
1474                instruments.iter().all(|i| i.name().len() <= 23),
1475                "instrument name exceeds SmolStr inline capacity: {:?}",
1476                instruments.iter().find(|i| i.name().len() > 23)
1477            );
1478
1479            // Validate credentials + connectivity before returning the stream so the caller can
1480            // distinguish "can't start at all" from "started then disconnected" (auto-reconnect
1481            // handles the latter). The token POST is a signed REST round-trip; the WS connect proves
1482            // the socket. Subscription happens inside the manager (after the event listener
1483            // attaches), so early pushed events are not missed.
1484            let initial_token =
1485                acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1486            let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1487                UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1488            })?;
1489
1490            // The risk data stream runs beside the manager, for the margin notices it carries, in
1491            // a task of its own so nothing in it can end the account stream, and is aborted when
1492            // the manager's task ends or is aborted (see `margin_risk`).
1493            let risk = RiskStreamTask::spawn(
1494                SapiRiskListenKeys {
1495                    rest: rest.clone(),
1496                    rate_limiter: rate_limiter.clone(),
1497                },
1498                RISK_STREAM_URL.to_owned(),
1499                tx.clone(),
1500                dedup.clone(),
1501            );
1502            let manager = margin_connection_manager(
1503                tx,
1504                dedup,
1505                ws_config,
1506                rest_config,
1507                rest,
1508                rate_limiter,
1509                known_live,
1510                instruments,
1511                Some((initial_ws, initial_token)),
1512            );
1513            tokio::spawn(async move {
1514                let _risk = risk;
1515                manager.await;
1516            })
1517        };
1518
1519        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1520        let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1521        Ok(futures::StreamExt::boxed(guarded_stream))
1522    }
1523}
1524
1525/// Looks each order up with `GET /sapi/v1/margin/order` by symbol and client order id, in the
1526/// cross or isolated account as [`BinanceMarginConfig::is_isolated`] says, weight 10 each, up to
1527/// eight at a time. Binance's `FILLED` is reported with its average price, `CANCELED` with what
1528/// filled before it, `EXPIRED` and `EXPIRED_IN_MATCH` (self-trade prevention) as expired, and
1529/// `REJECTED` as [`OpenFailed`](crate::order::state::InactiveOrderState::OpenFailed). An order
1530/// Binance does not know under the key's symbol (`-2013`, or `-1121` for a symbol that does not
1531/// exist) is omitted. A row whose status this version does not know is omitted too, with a
1532/// warning, as is a `PENDING_CANCEL` one.
1533///
1534/// In isolated mode a key's symbol is asked about whether or not it is one of
1535/// [`isolated_symbols`](BinanceMarginConfig::isolated_symbols), since an order can be placed on any
1536/// pair. Any error other than those two codes fails the call, including one Binance gives for a
1537/// pair with no isolated account.
1538///
1539/// The account stream runs the same lookup itself after a reconnect; see
1540/// [`account_stream`](ExecutionClient::account_stream).
1541impl OrderStatusClient for BinanceMargin {
1542    async fn fetch_ended_orders(
1543        &self,
1544        orders: &[UnindexedOrderKey],
1545    ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1546        fetch_ended_by_key(orders, |key| {
1547            let recorded_kind = self.known_live.lock().kind_of(&key.cid);
1548            fetch_margin_order_lookup(
1549                self.rest.clone(),
1550                self.rate_limiter.clone(),
1551                key,
1552                self.config.is_isolated,
1553                RequestKind::Query,
1554                recorded_kind,
1555            )
1556        })
1557        .await
1558    }
1559}
1560
1561// ---------------------------------------------------------------------------
1562// Shortability and borrow capacity
1563// ---------------------------------------------------------------------------
1564
1565/// Hours in the 365-day year a Binance hourly interest rate is annualised over.
1566const HOURS_PER_YEAR: u32 = 24 * 365;
1567
1568impl ShortabilityClient for BinanceMargin {
1569    /// Reads the pair, its base asset and the base asset's next hourly interest rate, which cost
1570    /// 102 of Binance's per-IP request weight on cross margin and 111 on isolated, almost all of
1571    /// it the interest rate.
1572    ///
1573    /// - `shortable` is whether the pair is traded on margin and may be sold
1574    ///   (`/sapi/v1/margin/allPairs`, or `isolated/allPairs` on isolated margin), and its base
1575    ///   asset may be borrowed (`/sapi/v1/margin/allAssets`). Any of them false makes it false.
1576    ///   Binance's delisting schedule (`delistTime`) is not read, as Binance documents neither its
1577    ///   unit nor when it is present, so a pair due to be delisted reads shortable until Binance
1578    ///   clears these flags.
1579    /// - `fee_rate` is the base asset's next hourly interest rate
1580    ///   (`/sapi/v1/margin/next-hourly-interest-rate`, for this client's cross or isolated margin)
1581    ///   times 24 × 365. It is this account's rate, at its VIP level, for the coming hour only.
1582    /// - `easy_to_borrow` is unknown: Binance has no such distinction.
1583    /// - `available` is unknown: Binance does not say how much it has to lend in total, only how
1584    ///   much this account can borrow, which
1585    ///   [`fetch_borrow_capacity`](BorrowCapacityClient::fetch_borrow_capacity) reports.
1586    ///
1587    /// # Errors
1588    ///
1589    /// [`ApiError::InstrumentInvalid`] for a pair Binance does not trade on this client's cross
1590    /// or isolated margin.
1591    async fn fetch_shortability(
1592        &self,
1593        instrument: &InstrumentNameExchange,
1594    ) -> Result<Shortability, UnindexedClientError> {
1595        let pair = self.fetch_margin_pair(instrument).await?;
1596        let (borrowable, hourly_rate) = tokio::try_join!(
1597            self.fetch_asset_borrowable(&pair.base),
1598            self.fetch_next_hourly_interest_rate(&pair.base),
1599        )?;
1600
1601        let mut shortability = Shortability::new();
1602        // Any flag false is enough to forbid the short; all must be known true to allow it.
1603        let flags = [pair.is_margin_trade, pair.is_sell_allowed, borrowable];
1604        if flags.contains(&Some(false)) {
1605            shortability = shortability.with_shortable(false);
1606        } else if flags.iter().all(|flag| *flag == Some(true)) {
1607            shortability = shortability.with_shortable(true);
1608        }
1609        if let Some(hourly_rate) = hourly_rate {
1610            shortability = shortability.with_fee_rate(hourly_rate * Decimal::from(HOURS_PER_YEAR));
1611        }
1612        Ok(shortability)
1613    }
1614}
1615
1616impl BorrowCapacityClient for BinanceMargin {
1617    /// Reads the pair, then `/sapi/v1/margin/maxBorrowable` for the asset an order on `side`
1618    /// borrows, on this client's cross margin or on the pair's isolated account. They cost 51 of
1619    /// Binance's per-IP request weight on cross margin and 60 on isolated.
1620    ///
1621    /// `borrowable_now` is Binance's `amount`, which it describes as the account's "max
1622    /// borrowable amount with sufficient system availability", so it is also capped by what
1623    /// Binance has to lend. `account_limit` is its `borrowLimit`, the most the account's VIP level
1624    /// allows.
1625    ///
1626    /// # Errors
1627    ///
1628    /// - [`ApiError::InstrumentInvalid`] for a pair Binance does not trade on this client's cross
1629    ///   or isolated margin.
1630    /// - [`ApiError::RequestRejected`] with Binance's `-11001` on isolated margin, for a pair this
1631    ///   account has no isolated margin account for. Open one on Binance, or ask a cross-margin
1632    ///   client.
1633    async fn fetch_borrow_capacity(
1634        &self,
1635        instrument: &InstrumentNameExchange,
1636        side: Side,
1637    ) -> Result<BorrowCapacity, UnindexedClientError> {
1638        let pair = self.fetch_margin_pair(instrument).await?;
1639        let asset = match side {
1640            Side::Buy => pair.quote,
1641            Side::Sell => pair.base,
1642        };
1643        let isolated_symbol = self
1644            .config
1645            .is_isolated
1646            .then(|| instrument.name().to_string());
1647        let response =
1648            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1649                let asset = asset.name().to_string();
1650                let isolated_symbol = isolated_symbol.clone();
1651                Box::pin(async move {
1652                    let params = QueryMaxBorrowParams::builder(asset)
1653                        .isolated_symbol(isolated_symbol)
1654                        .build()?;
1655                    rest.query_max_borrow(params).await
1656                })
1657            })
1658            .await
1659            .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1660        let max_borrow = response.data().await.map_err(response_decode_error)?;
1661
1662        let mut capacity = BorrowCapacity::new(asset);
1663        if let Some(amount) = optional_decimal("amount", max_borrow.amount.as_deref())? {
1664            capacity = capacity.with_borrowable_now(amount);
1665        }
1666        if let Some(limit) = optional_decimal("borrowLimit", max_borrow.borrow_limit.as_deref())? {
1667            capacity = capacity.with_account_limit(limit);
1668        }
1669        Ok(capacity)
1670    }
1671}
1672
1673/// A margin pair as Binance lists it, for this client's cross or isolated margin.
1674#[derive(Debug)]
1675struct MarginPair {
1676    base: AssetNameExchange,
1677    quote: AssetNameExchange,
1678    is_margin_trade: Option<bool>,
1679    is_sell_allowed: Option<bool>,
1680}
1681
1682impl BinanceMargin {
1683    /// `instrument` as Binance lists it for this client's margin: `/sapi/v1/margin/allPairs`
1684    /// (weight 1) on cross, `/sapi/v1/margin/isolated/allPairs` (weight 10) on isolated, both
1685    /// filtered to the one symbol.
1686    async fn fetch_margin_pair(
1687        &self,
1688        instrument: &InstrumentNameExchange,
1689    ) -> Result<MarginPair, UnindexedClientError> {
1690        let symbol = instrument.name().to_string();
1691        let listed = if self.config.is_isolated {
1692            let response =
1693                rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1694                    let symbol = symbol.clone();
1695                    Box::pin(async move {
1696                        let params = GetAllIsolatedMarginSymbolParams::builder()
1697                            .symbol(symbol)
1698                            .build()?;
1699                        rest.get_all_isolated_margin_symbol(params).await
1700                    })
1701                })
1702                .await
1703                .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1704            response
1705                .data()
1706                .await
1707                .map_err(response_decode_error)?
1708                .into_iter()
1709                .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
1710                .map(|pair| {
1711                    (
1712                        pair.base,
1713                        pair.quote,
1714                        pair.is_margin_trade,
1715                        pair.is_sell_allowed,
1716                    )
1717                })
1718        } else {
1719            let response =
1720                rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1721                    let symbol = symbol.clone();
1722                    Box::pin(async move {
1723                        let params = GetAllCrossMarginPairsParams::builder()
1724                            .symbol(symbol)
1725                            .build()?;
1726                        rest.get_all_cross_margin_pairs(params).await
1727                    })
1728                })
1729                .await
1730                .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1731            response
1732                .data()
1733                .await
1734                .map_err(response_decode_error)?
1735                .into_iter()
1736                .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
1737                .map(|pair| {
1738                    (
1739                        pair.base,
1740                        pair.quote,
1741                        pair.is_margin_trade,
1742                        pair.is_sell_allowed,
1743                    )
1744                })
1745        };
1746        let margin = if self.config.is_isolated {
1747            "isolated"
1748        } else {
1749            "cross"
1750        };
1751        let Some((base, quote, is_margin_trade, is_sell_allowed)) = listed else {
1752            return Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(
1753                instrument.clone(),
1754                format!("Binance lists no {margin} margin pair {symbol}"),
1755            )));
1756        };
1757        let (Some(base), Some(quote)) = (base, quote) else {
1758            return Err(UnindexedClientError::Internal(format!(
1759                "Binance lists {margin} margin pair {symbol} without its base or quote asset"
1760            )));
1761        };
1762        Ok(MarginPair {
1763            base: AssetNameExchange::new(base),
1764            quote: AssetNameExchange::new(quote),
1765            is_margin_trade,
1766            is_sell_allowed,
1767        })
1768    }
1769
1770    /// Whether Binance lends `asset` on margin: its `isBorrowable` in `/sapi/v1/margin/allAssets`
1771    /// (weight 1). Unknown if Binance does not list it.
1772    async fn fetch_asset_borrowable(
1773        &self,
1774        asset: &AssetNameExchange,
1775    ) -> Result<Option<bool>, UnindexedClientError> {
1776        let name = asset.name().to_string();
1777        let response =
1778            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1779                let name = name.clone();
1780                Box::pin(async move {
1781                    let params = GetAllMarginAssetsParams::builder().asset(name).build()?;
1782                    rest.get_all_margin_assets(params).await
1783                })
1784            })
1785            .await
1786            .map_err(|e| classify_rest_query_error(&e, None))?;
1787        Ok(response
1788            .data()
1789            .await
1790            .map_err(response_decode_error)?
1791            .into_iter()
1792            .find(|listed| listed.asset_name.as_deref() == Some(name.as_str()))
1793            .and_then(|listed| listed.is_borrowable))
1794    }
1795
1796    /// `asset`'s next hourly interest rate on this client's cross or isolated margin, a fraction
1797    /// per hour: `/sapi/v1/margin/next-hourly-interest-rate` (weight 100). Unknown if Binance does
1798    /// not answer for it.
1799    async fn fetch_next_hourly_interest_rate(
1800        &self,
1801        asset: &AssetNameExchange,
1802    ) -> Result<Option<Decimal>, UnindexedClientError> {
1803        let name = asset.name().to_string();
1804        let is_isolated = self.config.is_isolated;
1805        let response =
1806            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1807                let name = name.clone();
1808                Box::pin(async move {
1809                    let params = GetFutureHourlyInterestRateParams::builder(
1810                        name,
1811                        IsolatedFlag::from_flag(is_isolated),
1812                    )
1813                    .build()?;
1814                    rest.get_future_hourly_interest_rate(params).await
1815                })
1816            })
1817            .await
1818            .map_err(|e| classify_rest_query_error(&e, None))?;
1819        let rate = response
1820            .data()
1821            .await
1822            .map_err(response_decode_error)?
1823            .into_iter()
1824            .find(|listed| listed.asset.as_deref() == Some(name.as_str()))
1825            .and_then(|listed| listed.next_hourly_interest_rate);
1826        optional_decimal("nextHourlyInterestRate", rate.as_deref())
1827    }
1828}
1829
1830/// A decimal Binance may leave out: absent is `None`, but present and unreadable is an error, so a
1831/// malformed amount is not mistaken for an unreported one.
1832fn optional_decimal(
1833    field: &str,
1834    value: Option<&str>,
1835) -> Result<Option<Decimal>, UnindexedClientError> {
1836    value
1837        .map(|value| {
1838            Decimal::from_str(value).map_err(|error| {
1839                UnindexedClientError::Internal(format!(
1840                    "decoding Binance response: {field} {value:?}: {error}"
1841                ))
1842            })
1843        })
1844        .transpose()
1845}
1846
1847// ---------------------------------------------------------------------------
1848// User-data stream (hand-rolled userListenToken)
1849// ---------------------------------------------------------------------------
1850
1851/// Safety margin subtracted from a token's `expirationTime` so renewal happens before it expires.
1852const TOKEN_RENEW_MARGIN_SECS: i64 = 300; // 5 min
1853/// Floor for the post-acquire renewal wait, so a near-expired/odd token still yields a sane wait.
1854const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1855
1856/// A `userListenToken` and its expiry (epoch ms), from `POST /sapi/v1/userListenToken`.
1857struct UserListenToken {
1858    token: String,
1859    expiration_time_ms: i64,
1860}
1861
1862/// Build the query params for the `userListenToken` POST: empty for **cross**, or
1863/// `isIsolated=TRUE&symbol=SYM` for **isolated** (a per-symbol token). Factored out so the
1864/// cross-vs-isolated param shape is unit-testable without a network round-trip.
1865fn build_listen_token_query(
1866    symbol: Option<&InstrumentNameExchange>,
1867) -> BTreeMap<String, serde_json::Value> {
1868    let mut query = BTreeMap::new();
1869    if let Some(sym) = symbol {
1870        query.insert(
1871            "isIsolated".to_string(),
1872            serde_json::Value::String("TRUE".to_string()),
1873        );
1874        query.insert(
1875            "symbol".to_string(),
1876            serde_json::Value::String(sym.name().to_string()),
1877        );
1878    }
1879    query
1880}
1881
1882/// Wire shape of the `POST /sapi/v1/userListenToken` response.
1883#[derive(Deserialize)]
1884struct UserListenTokenResponse {
1885    // The live endpoint was observed returning `token`; accept `listenToken` defensively too.
1886    #[serde(alias = "listenToken")]
1887    token: String,
1888    #[serde(rename = "expirationTime")]
1889    expiration_time: i64,
1890}
1891
1892/// Acquire a `userListenToken` via the SDK's generic signed sender.
1893///
1894/// The endpoint is unbound in the SDK, so this reuses `common::utils::send_request` (signing,
1895/// timestamp, `X-MBX-APIKEY`, and the SDK's reqwest client) against the production REST config.
1896/// `symbol` selects the scope:
1897/// - `None` → **cross** margin (account-wide), no `isIsolated`/`symbol` params;
1898/// - `Some(sym)` → **isolated** margin, scoped to that pair (`isIsolated=TRUE&symbol=SYM`). Each
1899///   isolated symbol gets its own per-symbol token (the isolated stream multiplexes N of them).
1900///
1901/// Routed through [`rest_call_with_retry`] so a transient rate-limit during a *planned* token
1902/// renewal retries in place rather than collapsing the stream into the full reconnect+backoff
1903/// path. `ConfigurationRestApi` stands in as the retry helper's `R` (it only ever hands back an
1904/// `Arc` clone to the per-attempt closure). It is [`RequestKind::Essential`]: the stream cannot
1905/// resume without a token, and every second it waits widens the gap fill recovery must cover.
1906async fn acquire_user_listen_token(
1907    rest_config: &Arc<ConfigurationRestApi>,
1908    rate_limiter: &RateLimitTracker,
1909    symbol: Option<&InstrumentNameExchange>,
1910) -> Result<UserListenToken, UnindexedClientError> {
1911    // Cross sends no params; isolated scopes the token to one symbol. Built once and cloned per
1912    // retry attempt (the closure runs per attempt; mirrors `fetch_isolated_margin_account_info`).
1913    let query = build_listen_token_query(symbol);
1914    let response = rest_call_with_retry(rest_config, rate_limiter, RequestKind::Essential, |cfg| {
1915        let query = query.clone();
1916        Box::pin(async move {
1917            binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1918                &cfg,
1919                "/sapi/v1/userListenToken",
1920                reqwest::Method::POST,
1921                // `send_request` takes owned query + body maps; params (if any) ride the query
1922                // string the signature is computed over, the body stays empty.
1923                query,
1924                BTreeMap::new(),
1925                None,
1926                true, // is_signed — token is auth-bearing; HMAC/timestamp/api-key by send_request
1927            )
1928            .await
1929        })
1930    })
1931    .await
1932    .map_err(|e| classify_rest_query_error(&e, symbol))?;
1933
1934    let data = response.data().await.map_err(response_decode_error)?;
1935    Ok(UserListenToken {
1936        token: data.token,
1937        expiration_time_ms: data.expiration_time,
1938    })
1939}
1940
1941/// How long to wait before renewing, from a token's `expirationTime` (epoch ms), with a safety
1942/// margin and a floor so a near-expired token still yields a sane (non-zero) wait.
1943fn token_renew_after(expiration_time_ms: i64) -> Duration {
1944    let now_ms = Utc::now().timestamp_millis();
1945    // Saturating arithmetic so a malformed/huge `expirationTime` can't overflow i64 (debug panic /
1946    // release wrap); the `/ 1_000` is a truncating ms→s conversion. try_from then maps any negative
1947    // (already-expired/odd) result to 0.
1948    let remaining_secs = expiration_time_ms
1949        .saturating_sub(now_ms)
1950        .saturating_div(1_000)
1951        .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1952    let secs = u64::try_from(remaining_secs).unwrap_or(0);
1953    // The floor guarantees a sane, non-zero wait; warn when it kicks in so a degraded renewal cadence
1954    // (near-expired or odd token) is observable rather than silent.
1955    if secs < TOKEN_MIN_LIFETIME_SECS {
1956        warn!(
1957            computed_secs = secs,
1958            floor_secs = TOKEN_MIN_LIFETIME_SECS,
1959            "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1960        );
1961    }
1962    Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1963}
1964
1965/// Connect a directly-constructed common-layer WS-API connection (no subscription yet).
1966///
1967/// The SDK's spot ws-api wrapper can't be reused for margin (private base, no generic
1968/// send/receive surface), so margin drives `common::websocket::WebsocketApi` directly. An empty
1969/// pool is auto-populated by the SDK. The handshake is bounded by `CONNECT_TIMEOUT_SECS` explicitly
1970/// rather than relying on the SDK's internal timeout (an unstable, version-pinned detail).
1971async fn connect_margin_ws(
1972    ws_config: &ConfigurationWebsocketApi,
1973) -> anyhow::Result<Arc<WsApiBase>> {
1974    let ws = WsApiBase::new(ws_config.clone(), vec![]);
1975    // Mirrors BinanceSpot's reconnect path: a stalled connect must not block the connection
1976    // manager for an OS-length TCP timeout if the SDK's internal bound ever changes.
1977    tokio::time::timeout(
1978        Duration::from_secs(CONNECT_TIMEOUT_SECS),
1979        ws.clone().connect(),
1980    )
1981    .await
1982    .map_err(|_| {
1983        anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1984    })??;
1985    Ok(ws)
1986}
1987
1988/// Subscribe to the user-data stream over an existing WS-API connection.
1989///
1990/// `userDataStream.subscribe.listenToken` is unbound in the SDK, so it is sent via the generic
1991/// `send_message` with the literal method string. The frame is **unsigned with no API key** — the
1992/// token is the sole auth (verified live); `WebsocketMessageSendOptions::new()` yields
1993/// exactly that (no `.signed()`/`.with_api_key()`, no session logon).
1994async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1995    // The SDK's `send_message` takes an owned `BTreeMap<String, Value>`, so the single-entry map and
1996    // its key/value allocations are unavoidable here. Runs once per (re)connection, not per event.
1997    let mut payload = BTreeMap::new();
1998    payload.insert(
1999        "listenToken".to_string(),
2000        serde_json::Value::String(token.to_string()),
2001    );
2002    ws.send_message::<serde_json::Value>(
2003        "userDataStream.subscribe.listenToken",
2004        payload,
2005        WebsocketMessageSendOptions::new(),
2006    )
2007    .await
2008    .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
2009    Ok(())
2010}
2011
2012/// Subscribe over an existing WS-API connection and return the ack's `subscriptionId` (isolated).
2013///
2014/// Identical wire frame to [`subscribe_listen_token`] (unsigned, token-as-auth) but deserializes the
2015/// ack's `result.subscriptionId` so the isolated manager can build the `subscriptionId → symbol`
2016/// routing map (the symbol-less `outboundAccountPosition` frames carry only this id — Design
2017/// decision #5). Returns:
2018/// - `Ok(Some(id))` — subscribed; the id correlates this symbol's pushed balance frames;
2019/// - `Ok(None)` — subscribed, but the ack carried no `subscriptionId` (per-instrument balance
2020///   routing for this pair is then unavailable: its `outboundAccountPosition` frames drop with a
2021///   `warn!` and the consumer falls back to snapshot-polled balances — fills/orders are unaffected,
2022///   they self-identify by inner `s`). This is the documented first-prod-run degradation path.
2023/// - `Err(..)` — the subscribe itself failed (a startup/reconnect error, surfaced by the caller).
2024async fn subscribe_listen_token_capture(
2025    ws: &Arc<WsApiBase>,
2026    token: &str,
2027) -> anyhow::Result<Option<i64>> {
2028    /// Ack `result` shape — `subscriptionId` is read defensively as an `Option` (its presence on the
2029    /// `.listenToken` ack is the one assumption no offline source closes; absent → `Ok(None)`).
2030    #[derive(Deserialize)]
2031    struct SubscribeAck {
2032        #[serde(rename = "subscriptionId", default)]
2033        subscription_id: Option<i64>,
2034    }
2035
2036    let mut payload = BTreeMap::new();
2037    payload.insert(
2038        "listenToken".to_string(),
2039        serde_json::Value::String(token.to_string()),
2040    );
2041    let result = ws
2042        .send_message::<SubscribeAck>(
2043            "userDataStream.subscribe.listenToken",
2044            payload,
2045            WebsocketMessageSendOptions::new(),
2046        )
2047        .await
2048        .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
2049    let ack = match result {
2050        SendWebsocketMessageResult::Single(resp) => resp.data()?,
2051        // The subscribe is a single request; defensively take the first if the SDK ever batches.
2052        SendWebsocketMessageResult::Multiple(responses) => responses
2053            .into_iter()
2054            .next()
2055            .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
2056            .data()?,
2057    };
2058    Ok(ack.subscription_id)
2059}
2060
2061/// Cross-margin balance-arm handler: convert an `outboundAccountPosition` into asset-keyed
2062/// `BalanceStreamUpdate`s (the `subscriptionId` is unused — cross is account-wide). This is the
2063/// `handle_position` passed to [`convert_margin_user_data_events_with`] / the cross manager.
2064fn cross_account_position_handler(
2065    position: OutboundAccountPosition,
2066    _subscription_id: Option<i64>,
2067    buf: &mut Vec<UnindexedAccountEvent>,
2068) {
2069    convert_margin_account_position(position, buf);
2070}
2071
2072/// Cross-margin notice attribution: a cross account's margin level is the whole account's, so a
2073/// notice names no instrument. The `notice_instrument` passed to
2074/// [`convert_margin_user_data_events_with`] / the cross manager.
2075fn cross_notice_instrument(_subscription_id: Option<i64>) -> Option<InstrumentNameExchange> {
2076    None
2077}
2078
2079/// Isolated-margin notice attribution: the pair whose subscription the frame came on, recovered
2080/// from `subscription_id` via `sub_map` as [`route_isolated_account_position`] recovers a balance
2081/// frame's. A notice whose subscription is missing or unmapped is still delivered, naming no
2082/// instrument, with a `warn`: losing a margin call costs more than losing its attribution, and
2083/// unlike a balance there is no snapshot to repair it.
2084fn isolated_notice_instrument(
2085    subscription_id: Option<i64>,
2086    sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
2087) -> Option<InstrumentNameExchange> {
2088    let Some(sub_id) = subscription_id else {
2089        warn!(
2090            "BinanceMargin isolated: MARGIN_LEVEL_STATUS_CHANGE without subscriptionId — \
2091             delivering the notice without an instrument"
2092        );
2093        return None;
2094    };
2095    let instrument = sub_map
2096        .lock()
2097        .unwrap_or_else(|p| p.into_inner())
2098        .get(&sub_id)
2099        .cloned();
2100    if instrument.is_none() {
2101        warn!(
2102            subscription_id = sub_id,
2103            "BinanceMargin isolated: MARGIN_LEVEL_STATUS_CHANGE for unmapped subscriptionId — \
2104             delivering the notice without an instrument"
2105        );
2106    }
2107    instrument
2108}
2109
2110/// Cross-margin convenience wrapper over [`convert_margin_user_data_events_with`] using
2111/// [`cross_account_position_handler`] (account-wide `BalanceStreamUpdate`s) and
2112/// [`cross_notice_instrument`]. Test-only: the cross manager calls the listener helper with the
2113/// handlers directly, but the converter unit tests exercise frame discrimination through this
2114/// two-arg form (they do not route balances per-instrument).
2115#[cfg(test)]
2116fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2117    convert_margin_user_data_events_with(
2118        frame,
2119        buf,
2120        &mut cross_account_position_handler,
2121        &mut cross_notice_instrument,
2122    )
2123}
2124
2125/// Frame discrimination for the WS-API user-data delivery, as observed live.
2126///
2127/// Each text frame is either an RPC response (`{ "id", "status", "result", … }` — e.g. the
2128/// subscribe ack) or a pushed user-data event wrapped as `{ "subscriptionId", "event": { "e", … } }`.
2129/// Returns `true` if the exchange signalled stream termination (a reconnect trigger). Unrecognised
2130/// frames are ignored and logged by [`log_unrecognised_frame`] (throttled `warn`); event types with
2131/// no arm are ignored and logged by [`log_unhandled_event`] (throttled `warn`), so a renamed event
2132/// is seen rather than dropped silently. Deserialization of a known event type is defensive: a
2133/// mismatch is logged and the event dropped (observable), never silently mis-parsed.
2134///
2135/// Two arms differ between cross and isolated, and are delegated: the `outboundAccountPosition`
2136/// (balance) arm to `handle_position` (cross: account-wide `BalanceStreamUpdate`; isolated:
2137/// per-instrument `InstrumentBalanceUpdate`, routed via the frame's `subscriptionId`), and the
2138/// instrument a `MARGIN_LEVEL_STATUS_CHANGE` notice names to `notice_instrument` (cross: none;
2139/// isolated: the subscription's pair). Everything else (`executionReport` routing by inner `s`,
2140/// the margin events, termination signalling) is single-sourced here.
2141fn convert_margin_user_data_events_with(
2142    frame: &str,
2143    buf: &mut Vec<UnindexedAccountEvent>,
2144    handle_position: &mut impl FnMut(
2145        OutboundAccountPosition,
2146        Option<i64>,
2147        &mut Vec<UnindexedAccountEvent>,
2148    ),
2149    notice_instrument: &mut impl FnMut(Option<i64>) -> Option<InstrumentNameExchange>,
2150) -> bool {
2151    let (subscription_id, event_type, event_raw) = match parse_user_data_frame(frame) {
2152        // RPC responses (subscribe ack, errors): not a user-data event.
2153        UserDataFrame::Response => return false,
2154        UserDataFrame::Event {
2155            subscription_id,
2156            event_type,
2157            event,
2158        } => (subscription_id, event_type, event),
2159        UserDataFrame::Unrecognised => {
2160            static SEEN: AtomicU64 = AtomicU64::new(0);
2161            log_unrecognised_frame("BinanceMargin", &SEEN, frame);
2162            return false;
2163        }
2164    };
2165    match event_type {
2166        "executionReport" => {
2167            // Single typed pass straight from the raw inner slice — no intermediate DOM, and only
2168            // the matched branch deserializes its payload.
2169            match serde_json::from_str::<ExecutionReport>(event_raw) {
2170                Ok(report) => {
2171                    // Evidence for a future margin-liquidation marker: Binance documents none for
2172                    // margin, so a forced liquidation's report looks like any other. These fields
2173                    // are where one would show.
2174                    debug!(
2175                        symbol = report.symbol.as_deref().unwrap_or("?"),
2176                        order_id = ?report.order_id,
2177                        client_order_id = report.client_order_id.as_deref().unwrap_or("?"),
2178                        order_type = report.order_type.as_deref().unwrap_or("?"),
2179                        execution_type = report.execution_type.as_deref().unwrap_or("?"),
2180                        order_status = report.order_status.as_deref().unwrap_or("?"),
2181                        "BinanceMargin executionReport"
2182                    );
2183                    convert_execution_report(&report, ExchangeId::BinanceMargin, buf);
2184                }
2185                Err(e) => {
2186                    warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable executionReport, dropping")
2187                }
2188            }
2189            false
2190        }
2191        "outboundAccountPosition" => {
2192            match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
2193                // Balance arm is the one cross/isolated divergence — delegate to the handler.
2194                Ok(position) => handle_position(position, subscription_id, buf),
2195                Err(e) => {
2196                    warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable outboundAccountPosition, dropping")
2197                }
2198            }
2199            false
2200        }
2201        "balanceUpdate" => {
2202            // Deposit/withdrawal delta; outboundAccountPosition covers trade-driven balance changes.
2203            // Mirrors spot — not forwarded. Consumers reconcile transfers via account_snapshot.
2204            // No log here: this is the per-frame receive hot path (fires on every balance change).
2205            false
2206        }
2207        // Binance's docs and binance-sdk name these two `USER_LIABILITY_CHANGE` and
2208        // `MARGIN_LEVEL_STATUS_CHANGE`. The camelCase spellings are also accepted until a live frame
2209        // from the `userListenToken` stream confirms which one it sends (#547).
2210        "USER_LIABILITY_CHANGE" | "userLiabilityChange" => {
2211            // Observable, NOT accumulated into balance state: borrow/repay is a
2212            // delta, and folding it in would be the position tracking the library refuses. Surfaced
2213            // via logs, naming this stream (the risk data stream logs its copy too); consumers
2214            // needing exact live debt refresh via account_snapshot. Always deserialized (rare
2215            // borrow/repay path, not per-frame) so a parse failure — exchange schema drift — is
2216            // surfaced at WARN rather than silently dropped (observable failures over silent ones).
2217            match serde_json::from_str::<UserLiabilityChange>(event_raw) {
2218                Ok(c) => log_liability_change(&c, "user data"),
2219                Err(e) => warn!(
2220                    error = %e,
2221                    frame = frame_excerpt(event_raw),
2222                    "BinanceMargin: undeserializable USER_LIABILITY_CHANGE, dropping"
2223                ),
2224            }
2225            false
2226        }
2227        "MARGIN_LEVEL_STATUS_CHANGE" | "marginLevelStatusChange" => {
2228            // Forwarded as a Notice; what to do about it is the consumer's policy. The risk data
2229            // stream may send the same change, and the listener's dedup gate delivers it once.
2230            match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
2231                Ok(change) => {
2232                    log_margin_level_change(&change, "user data");
2233                    buf.extend(margin_level_notice(
2234                        change,
2235                        notice_instrument(subscription_id),
2236                    ));
2237                }
2238                Err(e) => warn!(
2239                    error = %e,
2240                    frame = frame_excerpt(event_raw),
2241                    "BinanceMargin: undeserializable MARGIN_LEVEL_STATUS_CHANGE, dropping"
2242                ),
2243            }
2244            false
2245        }
2246        "eventStreamTerminated" => {
2247            warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
2248            true
2249        }
2250        "listStatus" => {
2251            // OCO/OTO list status: this client places no order lists, and each leg's own
2252            // executionReport carries the order state. Ignored knowingly.
2253            trace!("BinanceMargin ignoring listStatus user data event");
2254            false
2255        }
2256        other => {
2257            static SEEN: UnhandledEvents = UnhandledEvents::new();
2258            log_unhandled_event("BinanceMargin", &SEEN, other, event_raw);
2259            false
2260        }
2261    }
2262}
2263
2264/// Convert a margin `outboundAccountPosition` to `BalanceStreamUpdate` events (one per asset).
2265///
2266/// The WS message is a `free`/`locked` partial (no debt), so it emits `BalanceStreamUpdate` — which
2267/// structurally cannot clobber the `margin` debt established by a REST `BalanceSnapshot`.
2268fn convert_margin_account_position(
2269    position: OutboundAccountPosition,
2270    buf: &mut Vec<UnindexedAccountEvent>,
2271) {
2272    let time_exchange = position
2273        .u
2274        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2275        .unwrap_or_else(Utc::now);
2276
2277    for b in position.b_uppercase.unwrap_or_default() {
2278        let asset = match b.a {
2279            Some(a) => AssetNameExchange::new(a),
2280            None => {
2281                warn!("BinanceMargin account position entry missing asset name");
2282                continue;
2283            }
2284        };
2285        let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2286            Some(v) => v,
2287            None => {
2288                warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
2289                continue;
2290            }
2291        };
2292        let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2293            Some(v) => v,
2294            None => {
2295                warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
2296                continue;
2297            }
2298        };
2299        let update =
2300            AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2301        buf.push(UnindexedAccountEvent::new(
2302            ExchangeId::BinanceMargin,
2303            AccountEventKind::BalanceStreamUpdate(
2304                rustrade_integration::collection::snapshot::Snapshot::new(update),
2305            ),
2306        ));
2307    }
2308}
2309
2310/// Route an isolated `outboundAccountPosition` frame to an [`InstrumentBalanceUpdate`] (Tier B).
2311///
2312/// The frame carries no symbol inline, so the pair is recovered from `subscription_id` via
2313/// `sub_map` (populated from the per-symbol subscribe acks). The flat `b[]` asset list is then
2314/// split into `base`/`quote` by **asset-name equality** against the pair's known `(base, quote)`
2315/// from `base_quote` (the authoritative startup map — never string-matched against the symbol).
2316/// Emits one `InstrumentBalanceUpdate` carrying both sides' free/locked. Dropped with a `warn!`
2317/// (observable, never mis-applied) when: the `subscriptionId` is missing/unmapped; the pair's
2318/// base/quote is unknown; or either side is absent/unparseable in the frame. The engine does not
2319/// store this variant (`_ => None` wildcard); the wrapper consumes it off the account-event feed.
2320fn route_isolated_account_position(
2321    position: OutboundAccountPosition,
2322    subscription_id: Option<i64>,
2323    sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
2324    base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
2325    buf: &mut Vec<UnindexedAccountEvent>,
2326) {
2327    let Some(sub_id) = subscription_id else {
2328        warn!(
2329            "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
2330             (per-instrument balance routing requires it)"
2331        );
2332        return;
2333    };
2334    let instrument = {
2335        // Brief lock: the map is read-only after startup and only written by the subscribe loop on
2336        // (re)connect. Recover from poisoning rather than killing balance routing.
2337        let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
2338        match map.get(&sub_id) {
2339            Some(inst) => inst.clone(),
2340            None => {
2341                warn!(
2342                    subscription_id = sub_id,
2343                    "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
2344                );
2345                return;
2346            }
2347        }
2348    };
2349    let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
2350        warn!(
2351            instrument = %instrument.name(),
2352            "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
2353        );
2354        return;
2355    };
2356
2357    let time_exchange = position
2358        .u
2359        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2360        .unwrap_or_else(Utc::now);
2361
2362    let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
2363    let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
2364    for b in position.b_uppercase.unwrap_or_default() {
2365        let Some(asset) = b.a.as_deref() else {
2366            warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
2367            continue;
2368        };
2369        // Only the pair's own base/quote are relevant; ignore any stray asset on the frame.
2370        let side = if asset == base_asset.name().as_str() {
2371            &mut base_update
2372        } else if asset == quote_asset.name().as_str() {
2373            &mut quote_update
2374        } else {
2375            continue;
2376        };
2377        let (Some(free), Some(locked)) = (
2378            b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2379            b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2380        ) else {
2381            warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
2382            continue;
2383        };
2384        *side = Some(AssetBalanceUpdate::new(
2385            AssetNameExchange::new(asset),
2386            BalanceUpdate::new(free, locked),
2387            time_exchange,
2388        ));
2389    }
2390
2391    // InstrumentBalanceUpdate carries BOTH sides; a frame missing either is dropped rather than
2392    // fabricating a zero side (which would silently report a wrong free/locked). A trade-driven
2393    // outboundAccountPosition changes both base and quote together, so both are normally present.
2394    let (Some(base), Some(quote)) = (base_update, quote_update) else {
2395        warn!(
2396            instrument = %instrument.name(),
2397            "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
2398        );
2399        return;
2400    };
2401    buf.push(UnindexedAccountEvent::new(
2402        ExchangeId::BinanceMargin,
2403        AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
2404            instrument, base, quote,
2405        )),
2406    ));
2407}
2408
2409/// Register the WS-API event-dispatch callback shared by both margin managers.
2410///
2411/// Single-sources the demux/dedup/dispatch + heartbeat + terminal-signal logic the cross and
2412/// isolated managers both need; only the `outboundAccountPosition` (balance) arm and the
2413/// instrument a margin notice names differ, supplied as `handle_position` (cross → account-wide
2414/// `BalanceStreamUpdate` via [`cross_account_position_handler`]; isolated → per-instrument
2415/// `InstrumentBalanceUpdate` via [`route_isolated_account_position`]) and `notice_instrument`
2416/// ([`cross_notice_instrument`]; [`isolated_notice_instrument`]). Returns the [`Subscription`] handle; the caller must
2417/// `unsubscribe()` it on disconnect. `heartbeat_flag` is shared with the caller's monitor loop
2418/// (set on every inbound frame/ping); `signal_tx` fires once on a terminal condition
2419/// (consumer-drop, socket error/close, or exchange `eventStreamTerminated`). Each event `known`
2420/// learns from updates it before it is sent.
2421fn register_user_data_listener(
2422    ws: &Arc<WsApiBase>,
2423    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2424    dedup: SharedDedupCache,
2425    known: SharedKnownLiveOrders,
2426    heartbeat_flag: Arc<AtomicBool>,
2427    signal_tx: oneshot::Sender<()>,
2428    mut handle_position: impl FnMut(
2429        OutboundAccountPosition,
2430        Option<i64>,
2431        &mut Vec<UnindexedAccountEvent>,
2432    ) + Send
2433    + 'static,
2434    mut notice_instrument: impl FnMut(Option<i64>) -> Option<InstrumentNameExchange> + Send + 'static,
2435) -> Subscription {
2436    let mut signal_tx_opt = Some(signal_tx);
2437    let mut event_tx = Some(tx);
2438    // 32 comfortably covers a typical account's outboundAccountPosition (one entry per asset) plus
2439    // the execution events in a single message; an account with >32 assets reallocates once, after
2440    // which the buffer is reused across messages via drain(..).
2441    let mut event_buf = Vec::with_capacity(32);
2442
2443    // Safety — non-atomic Option::take() in the callback is safe: binance-sdk drives one spawned
2444    // task per subscription, invoking the FnMut sequentially (verified =70.2.0; re-verify on SDK
2445    // upgrade). Same contract spot relies on.
2446    ws.common.events.subscribe(move |event| {
2447        let Some(ref sender) = event_tx else { return };
2448        match event {
2449            WebsocketEvent::Message(json_str) => {
2450                heartbeat_flag.store(true, Ordering::Release);
2451                // Frame parsing (including skipping non-JSON frames) happens inside the converter,
2452                // which parses borrowed slices rather than a full DOM (hot path).
2453                let terminated = convert_margin_user_data_events_with(
2454                    &json_str,
2455                    &mut event_buf,
2456                    &mut handle_position,
2457                    &mut notice_instrument,
2458                );
2459                for ev in event_buf.drain(..) {
2460                    if let Some(key) = dedup_key_from_event(&ev)
2461                        && is_duplicate(&dedup, key)
2462                    {
2463                        trace!("BinanceMargin dedup: skipping duplicate event");
2464                        continue;
2465                    }
2466                    // Held across the send, so an order this reports ending and a reconnect's
2467                    // check of it reach the stream in the order they were decided, and the check
2468                    // reports only an order not already reported.
2469                    let held = KnownLiveOrders::observes(&ev.kind).then(|| {
2470                        let mut held = known.lock();
2471                        held.observe(&ev);
2472                        held
2473                    });
2474                    if sender.send(ev).is_err() {
2475                        drop(held);
2476                        warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
2477                        event_tx.take();
2478                        if let Some(s) = signal_tx_opt.take() {
2479                            let _ = s.send(());
2480                        }
2481                        return;
2482                    }
2483                }
2484                if terminated {
2485                    event_tx.take();
2486                    if let Some(s) = signal_tx_opt.take() {
2487                        let _ = s.send(());
2488                    }
2489                }
2490            }
2491            WebsocketEvent::Ping | WebsocketEvent::Pong => {
2492                heartbeat_flag.store(true, Ordering::Release);
2493            }
2494            WebsocketEvent::Error(e) => {
2495                warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
2496                event_tx.take();
2497                if let Some(s) = signal_tx_opt.take() {
2498                    let _ = s.send(());
2499                }
2500            }
2501            WebsocketEvent::Close(code, reason) => {
2502                warn!(code, %reason, "BinanceMargin WebSocket closed");
2503                event_tx.take();
2504                if let Some(s) = signal_tx_opt.take() {
2505                    let _ = s.send(());
2506                }
2507            }
2508            _ => {
2509                trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
2510            }
2511        }
2512    })
2513}
2514
2515/// Recover the fills of every gap in `unrecovered` that is due, via REST `myTrades`, routed
2516/// through the dedup cache. Mirrors `BinanceSpot::recover_fills`, which documents how gaps are
2517/// settled and retried.
2518///
2519/// Only fills are recovered here; how the orders held as live ended is checked afterwards by
2520/// [`recover_margin_ended_orders`]. A recovered fill that completes its order ends it in `known`.
2521///
2522/// `myTrades` reports executions only, with no cumulative, so each recovered trade's
2523/// `order_filled_quantity` is rebuilt from its order's executions by
2524/// [`recovered_order_totals`]. Nor does it carry a client order id, so each recovered trade's
2525/// `client_order_id` is then read from its order by [`recovered_order_cids`], with whatever time
2526/// the walks left. A trade whose order could not be looked up keeps `None` in either.
2527///
2528/// Margin `myTrades` is read by time in windows under 24 hours (see
2529/// [`paginate_margin_my_trades`]), so each day of a gap before its first fill costs one sequential
2530/// request. A gap of weeks can therefore run into [`FILL_RECOVERY_TIMEOUT_SECS`], which bounds the
2531/// whole recovery; it is then retried, and given up once it has failed every retry.
2532///
2533/// Fills forwarded before the deadline stay delivered. Its reads are [`RequestKind::Essential`]:
2534/// they wait out a rate-limit cooldown but not the pause near the weight limit, which could
2535/// outlast the budget.
2536async fn recover_margin_fills(
2537    rest: &Arc<RestApi>,
2538    rate_limiter: &Arc<RateLimitTracker>,
2539    unrecovered: &mut UnrecoveredFills,
2540    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2541    dedup: &SharedDedupCache,
2542    known: &SharedKnownLiveOrders,
2543    is_isolated: bool,
2544) {
2545    use futures::StreamExt as _;
2546
2547    let due = unrecovered.due(tokio::time::Instant::now());
2548    let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2549        return;
2550    };
2551    info!(
2552        gaps = due.len(),
2553        oldest = %gap_time(oldest),
2554        is_isolated,
2555        "BinanceMargin recovering fills missed while disconnected"
2556    );
2557
2558    // Which gaps of `due` have been settled, recovered or failed, and how many have started: the
2559    // stream starts them in order, eight at a time, so those from `started` on were never read.
2560    let mut settled = vec![false; due.len()];
2561    let started = AtomicUsize::new(0);
2562    let recovery = async {
2563        let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2564        let mut recovered = 0u32;
2565        let mut duplicates = 0u32;
2566
2567        let mut stream =
2568            futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2569                started.store(index + 1, Ordering::Relaxed);
2570                let rest = rest.clone();
2571                let rl = rate_limiter.clone();
2572                async move {
2573                    let span = MyTradesFrom::Span {
2574                        start: gap.start_ms,
2575                        end: gap.end_ms,
2576                    };
2577                    let raw = match paginate_margin_my_trades(
2578                        &rest,
2579                        &rl,
2580                        &inst,
2581                        span,
2582                        is_isolated,
2583                        RequestKind::Essential,
2584                    )
2585                    .await
2586                    {
2587                        Ok(pages) => pages,
2588                        Err(e) => return (index, Err(e)),
2589                    };
2590                    // `myTrades` carries no cumulative, so each recovered fill's is rebuilt from
2591                    // its order's executions; without it the fill advances the position but not
2592                    // the order.
2593                    let totals = recovered_order_totals(
2594                        ExchangeId::BinanceMargin,
2595                        &inst,
2596                        &raw,
2597                        order_executions_deadline,
2598                        |order_id| {
2599                            paginate_margin_my_trades(
2600                                &rest,
2601                                &rl,
2602                                &inst,
2603                                MyTradesFrom::Order(order_id),
2604                                is_isolated,
2605                                RequestKind::Essential,
2606                            )
2607                        },
2608                    )
2609                    .await;
2610                    // After the walks, so these reads only use time the walks left: the walks
2611                    // matter more, since a fill without a cumulative leaves its order behind.
2612                    let cids = recovered_order_cids(
2613                        ExchangeId::BinanceMargin,
2614                        &inst,
2615                        &raw,
2616                        order_executions_deadline,
2617                        |order_id| fetch_margin_order_cid(&rest, &rl, &inst, order_id, is_isolated),
2618                    )
2619                    .await;
2620                    let trades = raw
2621                        .iter()
2622                        .filter_map(|t| {
2623                            let mut trade = convert_margin_trade(t, &inst)?;
2624                            trade.order_filled_quantity =
2625                                t.id.and_then(|id| totals.get(&id).copied());
2626                            trade.client_order_id =
2627                                t.order_id.and_then(|id| cids.get(&id).cloned());
2628                            Some(trade)
2629                        })
2630                        .collect::<Vec<_>>();
2631                    (index, Ok(trades))
2632                }
2633            }))
2634            .buffer_unordered(8);
2635        while let Some((index, result)) = stream.next().await {
2636            let (inst, gap) = &due[index];
2637            settled[index] = true;
2638            let trades = match result {
2639                Ok(trades) => trades,
2640                Err(e) => {
2641                    gap_failed(
2642                        unrecovered,
2643                        tx,
2644                        ExchangeId::BinanceMargin,
2645                        inst,
2646                        gap,
2647                        FillRecoveryFailure::Request(e.to_string()),
2648                    );
2649                    continue;
2650                }
2651            };
2652            for trade in trades {
2653                let event = UnindexedAccountEvent::new(
2654                    ExchangeId::BinanceMargin,
2655                    AccountEventKind::Trade(trade),
2656                );
2657                if let Some(key) = dedup_key_from_event(&event)
2658                    && is_duplicate(dedup, key)
2659                {
2660                    duplicates += 1;
2661                    continue;
2662                }
2663                // A recovered fill that completes its order ends it, so a later check of how the
2664                // orders held as live ended does not report it again.
2665                let sent = {
2666                    let mut known = known.lock();
2667                    known.observe(&event);
2668                    tx.send(event)
2669                };
2670                if sent.is_err() {
2671                    debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2672                    return;
2673                }
2674                recovered += 1;
2675            }
2676            unrecovered.recovered(inst, gap);
2677        }
2678        info!(
2679            recovered,
2680            duplicates, is_isolated, "BinanceMargin fill recovery complete"
2681        );
2682    };
2683    // A timeout drops `recovery` at an await, between gaps: one gap's fills are sent without
2684    // awaiting, so each is either fully forwarded and settled, or not forwarded at all. A gap
2685    // whose read started and did not finish has failed; one never started stays due as it was.
2686    if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2687        .await
2688        .is_err()
2689    {
2690        let started = started.load(Ordering::Relaxed);
2691        for ((inst, gap), _) in due[..started]
2692            .iter()
2693            .zip(&settled)
2694            .filter(|(_, settled)| !**settled)
2695        {
2696            gap_failed(
2697                unrecovered,
2698                tx,
2699                ExchangeId::BinanceMargin,
2700                inst,
2701                gap,
2702                FillRecoveryFailure::TimedOut {
2703                    timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2704                },
2705            );
2706        }
2707    }
2708}
2709
2710/// Open what a reconnect after a disconnect at `disconnected` recovers: a fill gap on each of the
2711/// stream's `instruments`, and a check of how the orders held as live ended on each of them that
2712/// has one. Only the stream's instruments are checked, since only their fills are recovered, and
2713/// an instrument is checked once its fills are.
2714fn open_reconnect_recovery(
2715    unrecovered: &mut UnrecoveredFills,
2716    unchecked: &mut UncheckedOrders,
2717    known: &SharedKnownLiveOrders,
2718    instruments: &[InstrumentNameExchange],
2719    disconnected: DateTime<Utc>,
2720) {
2721    unrecovered.open(instruments, disconnected, Utc::now());
2722    let held = known.lock().instruments_among(instruments);
2723    unchecked.open(held);
2724}
2725
2726/// Long-running task driving the `account_stream` WebSocket lifecycle.
2727///
2728/// Reconnect loop: acquire token → connect → register listener → subscribe → stream events → on
2729/// disconnect/heartbeat-timeout/token-expiry → backoff → fill recovery → reconnect. The `tx`
2730/// channel persists across reconnections so the consumer sees a seamless stream. Terminates when
2731/// the consumer drops the stream or max reconnect attempts are exhausted.
2732#[allow(
2733    clippy::cognitive_complexity,
2734    reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2735              + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2736)]
2737async fn margin_connection_manager(
2738    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2739    dedup: SharedDedupCache,
2740    ws_config: ConfigurationWebsocketApi,
2741    rest_config: Arc<ConfigurationRestApi>,
2742    rest: Arc<RestApi>,
2743    rate_limiter: Arc<RateLimitTracker>,
2744    known: SharedKnownLiveOrders,
2745    instruments: Vec<InstrumentNameExchange>,
2746    initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2747) {
2748    enum DisconnectReason {
2749        Signal,
2750        HeartbeatTimeout,
2751        TokenRefresh,
2752        ConsumerDropped,
2753    }
2754
2755    let mut backoff = ExponentialBackoff::new();
2756    let mut disconnect_time: Option<DateTime<Utc>> = None;
2757    // Gaps not yet recovered: read at reconnect, and retried on a timer while connected, once due.
2758    let mut unrecovered = UnrecoveredFills::default();
2759    // Instruments whose known-live orders a reconnect has yet to check, likewise.
2760    let mut unchecked = UncheckedOrders::default();
2761    let (mut current_ws, mut current_token) = match initial {
2762        Some((ws, token)) => (Some(ws), Some(token)),
2763        None => (None, None),
2764    };
2765
2766    loop {
2767        // --- Token: reuse the verified/previous token, else acquire a fresh one ---
2768        let token = match current_token.take() {
2769            Some(t) => t,
2770            None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2771                Ok(t) => t,
2772                Err(e) => {
2773                    error!(%e, "BinanceMargin userListenToken acquisition failed");
2774                    if !backoff.wait().await {
2775                        error!("BinanceMargin max reconnect attempts exhausted");
2776                        emit_stream_terminated(
2777                            &tx,
2778                            ExchangeId::BinanceMargin,
2779                            StreamTerminationReason::ReconnectBudgetExhausted {
2780                                attempts: backoff.attempts(),
2781                                last_error: e.to_string(),
2782                            },
2783                        );
2784                        break;
2785                    }
2786                    continue;
2787                }
2788            },
2789        };
2790
2791        // --- Connect (skip if holding a verified initial connection) ---
2792        let ws = match current_ws.take() {
2793            Some(ws) => ws,
2794            None => match connect_margin_ws(&ws_config).await {
2795                Ok(ws) => ws,
2796                Err(e) => {
2797                    error!(%e, "BinanceMargin WS connect failed");
2798                    if !backoff.wait().await {
2799                        error!("BinanceMargin max reconnect attempts exhausted");
2800                        emit_stream_terminated(
2801                            &tx,
2802                            ExchangeId::BinanceMargin,
2803                            StreamTerminationReason::ReconnectBudgetExhausted {
2804                                attempts: backoff.attempts(),
2805                                last_error: e.to_string(),
2806                            },
2807                        );
2808                        break;
2809                    }
2810                    continue;
2811                }
2812            },
2813        };
2814
2815        // --- Register the event listener BEFORE subscribing so no pushed event is missed ---
2816        let (signal_tx, signal_rx) = oneshot::channel::<()>();
2817        // start true — grant one full heartbeat window before requiring activity.
2818        let heartbeat_flag = Arc::new(AtomicBool::new(true));
2819        // Cross is account-wide: balance frames become asset-keyed BalanceStreamUpdates (no
2820        // per-instrument routing). The shared dispatch/dedup/heartbeat logic lives in the helper.
2821        let subscription = register_user_data_listener(
2822            &ws,
2823            tx.clone(),
2824            dedup.clone(),
2825            known.clone(),
2826            heartbeat_flag.clone(),
2827            signal_tx,
2828            cross_account_position_handler,
2829            cross_notice_instrument,
2830        );
2831
2832        // --- Subscribe (token is the sole auth) ---
2833        if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2834            warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2835            subscription.unsubscribe();
2836            if let Err(de) = ws.disconnect().await {
2837                warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2838            }
2839            // token left consumed → next iteration acquires a fresh one (auth/expiry-safe).
2840            if !backoff.wait().await {
2841                error!("BinanceMargin max reconnect attempts exhausted");
2842                emit_stream_terminated(
2843                    &tx,
2844                    ExchangeId::BinanceMargin,
2845                    StreamTerminationReason::ReconnectBudgetExhausted {
2846                        attempts: backoff.attempts(),
2847                        last_error: e.to_string(),
2848                    },
2849                );
2850                break;
2851            }
2852            continue;
2853        }
2854        info!("BinanceMargin account_stream connected and subscribed");
2855        // Reaching a live, subscribed connection clears any prior failure count so only *consecutive*
2856        // failures exhaust the reconnect budget — a clean rotation or a transient blip that recovers
2857        // shouldn't leave the backoff counter inflated for the next genuine failure.
2858        backoff.reset();
2859
2860        // Absolute deadline (not a per-iteration relative sleep) so heartbeat ticks that `continue`
2861        // the monitor loop don't keep restarting the renewal timer.
2862        let token_deadline =
2863            tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2864
2865        // --- Recovery after a reconnect (each bounded) ---
2866        // Fills first, then how the orders held as live ended: an order whose fills are not all
2867        // recovered is not checked yet. Each gap and check is opened before it is read, so one that
2868        // fails or times out is kept for a retry.
2869        if let Some(dt) = disconnect_time.take() {
2870            open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &instruments, dt);
2871        }
2872        // This is the cross manager (account-wide); recovery is always cross-scoped. The isolated
2873        // manager (a separate path) passes `true`.
2874        recover_margin_fills(
2875            &rest,
2876            &rate_limiter,
2877            &mut unrecovered,
2878            &tx,
2879            &dedup,
2880            &known,
2881            false,
2882        )
2883        .await;
2884        recover_margin_ended_orders(
2885            &rest,
2886            &rate_limiter,
2887            &known,
2888            &mut unchecked,
2889            &unrecovered,
2890            &tx,
2891            false,
2892        )
2893        .await;
2894
2895        // --- Monitor: disconnect signal, heartbeat timeout, token refresh, or consumer drop ---
2896        let reason = {
2897            let mut signal_rx = signal_rx;
2898            // Gaps and order checks a recovery did not finish are retried as they fall due,
2899            // alongside the monitor, so a disconnect is still seen at once. An order check waiting
2900            // on a gap runs right after the gap's retry. It never completes; dropping it when the
2901            // monitor ends loses nothing, since each gap, and each instrument's order check, is
2902            // settled in one step as soon as its read ends.
2903            let retry_gaps = async {
2904                loop {
2905                    let next_check = unchecked.next_due(&unrecovered);
2906                    match unrecovered.next_due().into_iter().chain(next_check).min() {
2907                        Some(due) => tokio::time::sleep_until(due).await,
2908                        None => std::future::pending::<()>().await,
2909                    }
2910                    recover_margin_fills(
2911                        &rest,
2912                        &rate_limiter,
2913                        &mut unrecovered,
2914                        &tx,
2915                        &dedup,
2916                        &known,
2917                        false,
2918                    )
2919                    .await;
2920                    recover_margin_ended_orders(
2921                        &rest,
2922                        &rate_limiter,
2923                        &known,
2924                        &mut unchecked,
2925                        &unrecovered,
2926                        &tx,
2927                        false,
2928                    )
2929                    .await;
2930                }
2931            };
2932            tokio::pin!(retry_gaps);
2933            loop {
2934                tokio::select! {
2935                    // Biased: a consumer drop is terminal and must win over a simultaneously-ready
2936                    // disconnect signal, which would otherwise enqueue one pointless reconnect.
2937                    biased;
2938                    _ = tx.closed() => {
2939                        debug!("BinanceMargin account_stream consumer dropped, terminating");
2940                        break DisconnectReason::ConsumerDropped;
2941                    }
2942                    _ = &mut signal_rx => {
2943                        warn!("BinanceMargin WS disconnected, will attempt reconnect");
2944                        break DisconnectReason::Signal;
2945                    }
2946                    () = tokio::time::sleep_until(token_deadline) => {
2947                        info!("BinanceMargin userListenToken nearing expiry, renewing");
2948                        break DisconnectReason::TokenRefresh;
2949                    }
2950                    () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2951                        if heartbeat_flag.swap(false, Ordering::AcqRel) {
2952                            // Backoff is already cleared post-subscribe (see backoff.reset() above);
2953                            // the monitor loop is only entered on a live connection, so a heartbeat
2954                            // tick never sees a non-zero count. Just consume the flag and keep waiting.
2955                            continue;
2956                        }
2957                        warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2958                        break DisconnectReason::HeartbeatTimeout;
2959                    }
2960                    // Last: the arms above end the monitor and win when ready together.
2961                    () = &mut retry_gaps => {}
2962                }
2963            }
2964        };
2965        let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2966
2967        if should_reconnect {
2968            disconnect_time = Some(match reason {
2969                DisconnectReason::HeartbeatTimeout => {
2970                    Utc::now()
2971                        - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2972                        - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2973                }
2974                DisconnectReason::TokenRefresh => {
2975                    // A rotation fully disconnects + reconnects (token POST + WS connect + subscribe).
2976                    // Look back far enough to bound that whole window — the WS handshake alone is
2977                    // capped at CONNECT_TIMEOUT_SECS — so a fill landing during the gap isn't missed.
2978                    // The dedup cache absorbs the redundant re-query of fills delivered pre-rotation.
2979                    Utc::now()
2980                        - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2981                        - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2982                }
2983                _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2984            });
2985        }
2986
2987        // --- Cleanup ---
2988        subscription.unsubscribe();
2989        if let Err(e) = ws.disconnect().await {
2990            warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2991        }
2992
2993        if !should_reconnect || tx.is_closed() {
2994            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed).
2995            debug!("BinanceMargin connection manager exiting");
2996            break;
2997        }
2998
2999        // A planned token refresh is not a failure — reconnect immediately (no backoff). Both
3000        // `current_token` and `current_ws` are already None, forcing a fresh token + connection.
3001        if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
3002            error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
3003            // reason here is Signal or HeartbeatTimeout (TokenRefresh is guarded above,
3004            // ConsumerDropped already broke out earlier).
3005            let last_error = match reason {
3006                DisconnectReason::HeartbeatTimeout => {
3007                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3008                }
3009                _ => "WebSocket disconnected (server close/error)".to_string(),
3010            };
3011            emit_stream_terminated(
3012                &tx,
3013                ExchangeId::BinanceMargin,
3014                StreamTerminationReason::ReconnectBudgetExhausted {
3015                    attempts: backoff.attempts(),
3016                    last_error,
3017                },
3018            );
3019            break;
3020        }
3021    }
3022}
3023
3024// ---------------------------------------------------------------------------
3025// Isolated user-data stream (separate multiplexed manager; cross untouched)
3026// ---------------------------------------------------------------------------
3027
3028/// Extract the invariant `instrument → (base_asset, quote_asset)` map from an isolated
3029/// account-info response.
3030///
3031/// The authoritative base/quote split for routing symbol-less `outboundAccountPosition` frames
3032/// ([`route_isolated_account_position`]) — base/quote never change for a pair,
3033/// so this is built once at stream start and reused across reconnects. Entries missing a symbol or
3034/// either asset name are skipped (their balance frames then drop with a `warn!` at routing time).
3035fn build_base_quote_map(
3036    assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
3037) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
3038    let mut map = HashMap::with_capacity(assets.len());
3039    for entry in assets {
3040        let (Some(symbol), Some(base), Some(quote)) = (
3041            entry.symbol.as_deref(),
3042            entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
3043            entry
3044                .quote_asset
3045                .as_deref()
3046                .and_then(|q| q.asset.as_deref()),
3047        ) else {
3048            warn!(
3049                "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
3050                 skipping base/quote map entry"
3051            );
3052            continue;
3053        };
3054        map.insert(
3055            InstrumentNameExchange::new(symbol),
3056            (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
3057        );
3058    }
3059    map
3060}
3061
3062/// Acquire one per-symbol isolated `userListenToken` for every configured symbol, bounded-concurrent.
3063///
3064/// Fans out at 8-wide (mirroring `recover_margin_fills`) through the shared rate-limit/backoff
3065/// machinery — ~1s at the v1 N≤100 ceiling vs ~5–10s sequential. Each token is
3066/// a weight-1 signed POST; the bound stays well inside SAPI weight limits. Errors if any acquisition
3067/// fails (the caller treats a partial set as a startup/reconnect failure).
3068///
3069/// Takes `&Arc<_>` rather than the file-wide `&RateLimitTracker` convention because each per-symbol
3070/// `async move` future needs an owned `Arc` clone to share the tracker without borrowing from this
3071/// frame across the concurrent `buffer_unordered` fan-out. Cloning the `Arc` (a refcount bump) is
3072/// correct; cloning `RateLimitTracker` itself would be semantically wrong — each clone would get its
3073/// own `blocked_until`, splitting the shared rate-limit state the fan-out is meant to respect.
3074async fn acquire_all_isolated_tokens(
3075    rest_config: &Arc<ConfigurationRestApi>,
3076    rate_limiter: &Arc<RateLimitTracker>,
3077    symbols: &[InstrumentNameExchange],
3078) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
3079    use futures::{StreamExt as _, TryStreamExt as _};
3080
3081    futures::stream::iter(symbols.iter().cloned().map(|sym| {
3082        let rest_config = rest_config.clone();
3083        let rate_limiter = rate_limiter.clone();
3084        async move {
3085            let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
3086            Ok::<_, UnindexedClientError>((sym, token))
3087        }
3088    }))
3089    .buffer_unordered(8)
3090    .try_collect()
3091    .await
3092}
3093
3094/// Earliest `expirationTime` (epoch ms) across the per-symbol tokens — drives the planned-renewal
3095/// deadline. The N tokens are acquired in a tight startup loop so their 24h expiries cluster;
3096/// renewing on the earliest refreshes the whole set in one reconnect. Empty → `i64::MAX` (no token
3097/// to expire; never happens for isolated, which always has ≥1 configured symbol).
3098fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
3099    tokens
3100        .iter()
3101        .map(|(_, t)| t.expiration_time_ms)
3102        .min()
3103        .unwrap_or(i64::MAX)
3104}
3105
3106/// A live, subscribed isolated connection: the single multiplexed socket plus the per-connection
3107/// lifecycle handles the manager's monitor loop needs.
3108struct IsolatedLiveConn {
3109    ws: Arc<WsApiBase>,
3110    subscription: Subscription,
3111    signal_rx: oneshot::Receiver<()>,
3112    heartbeat_flag: Arc<AtomicBool>,
3113    /// Earliest `expirationTime` across the N tokens — drives the planned-renewal deadline (the
3114    /// tokens cluster, so renewing on the earliest refreshes the whole set in one reconnect).
3115    earliest_expiry_ms: i64,
3116}
3117
3118/// Connect one WS-API socket and subscribe all N per-symbol `userListenToken`s on it (multiplex).
3119///
3120/// Registers the shared dispatch listener (with the isolated per-instrument balance handler) BEFORE
3121/// the first subscribe so no pushed event between the N acks is missed, then issues all N subscribes,
3122/// capturing each ack's `subscriptionId` into the shared `subscriptionId → symbol` routing map. If
3123/// **any** subscribe fails, the partially-subscribed socket is cleaned up and the error returned
3124/// (startup → `Err` with nothing spawned; reconnect → retry with backoff). Reused by both the
3125/// initial `account_stream` startup and the manager's reconnect path so connect+subscribe is
3126/// single-sourced.
3127async fn isolated_connect_and_subscribe(
3128    ws_config: &ConfigurationWebsocketApi,
3129    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3130    dedup: &SharedDedupCache,
3131    known: &SharedKnownLiveOrders,
3132    base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
3133    tokens: &[(InstrumentNameExchange, UserListenToken)],
3134) -> anyhow::Result<IsolatedLiveConn> {
3135    let ws = connect_margin_ws(ws_config).await?;
3136    // subscriptionId → symbol routing map: written here as each subscribe ack resolves, read by the
3137    // dispatch callback on every `outboundAccountPosition`. Shared (Mutex) because the two run on
3138    // different tasks (manager vs the SDK's per-subscription callback task); contention is negligible
3139    // (writes only at (re)connect, reads only on balance frames).
3140    // Sized to the token count up front — exactly one entry is inserted per subscribe ack below.
3141    let sub_map = Arc::new(Mutex::new(
3142        HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
3143    ));
3144    let (signal_tx, signal_rx) = oneshot::channel::<()>();
3145    // start true — grant one full heartbeat window before requiring activity.
3146    let heartbeat_flag = Arc::new(AtomicBool::new(true));
3147
3148    let handle_position = {
3149        let sub_map = sub_map.clone();
3150        let base_quote = base_quote.clone();
3151        move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
3152            route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
3153        }
3154    };
3155    let notice_instrument = {
3156        let sub_map = sub_map.clone();
3157        move |subscription_id| isolated_notice_instrument(subscription_id, &sub_map)
3158    };
3159    // Register BEFORE subscribing so a pushed event arriving mid-fan-out is not missed.
3160    let subscription = register_user_data_listener(
3161        &ws,
3162        tx.clone(),
3163        dedup.clone(),
3164        known.clone(),
3165        heartbeat_flag.clone(),
3166        signal_tx,
3167        handle_position,
3168        notice_instrument,
3169    );
3170
3171    let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
3172    for (sym, token) in tokens {
3173        match subscribe_listen_token_capture(&ws, &token.token).await {
3174            Ok(Some(id)) => {
3175                sub_map
3176                    .lock()
3177                    .unwrap_or_else(|p| p.into_inner())
3178                    .insert(id, sym.clone());
3179            }
3180            Ok(None) => warn!(
3181                symbol = %sym.name(),
3182                "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
3183                 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
3184            ),
3185            Err(e) => {
3186                // Any subscribe failing voids the whole stream — clean up the partially-subscribed
3187                // socket rather than leaking it, and surface the error to the caller.
3188                warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
3189                subscription.unsubscribe();
3190                if let Err(de) = ws.disconnect().await {
3191                    warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
3192                }
3193                return Err(e);
3194            }
3195        }
3196    }
3197
3198    Ok(IsolatedLiveConn {
3199        ws,
3200        subscription,
3201        signal_rx,
3202        heartbeat_flag,
3203        earliest_expiry_ms,
3204    })
3205}
3206
3207/// Long-running task driving the isolated `account_stream` lifecycle over one multiplexed socket.
3208///
3209/// Mirrors [`margin_connection_manager`] (the cross manager, left untouched) but for the N-symbol
3210/// multiplex: reconnect re-acquires all N tokens + re-subscribes all N from the configured symbol
3211/// set; renewal is a planned reconnect on the earliest token deadline (`DisconnectReason::TokenRefresh`,
3212/// reusing cross's `token_renew_after`/`sleep_until` discipline — no make-before-break). One socket
3213/// ⇒ one heartbeat, one reconnect loop, one backoff. The `base_quote` map is invariant and reused
3214/// across reconnects (never rebuilt).
3215#[allow(
3216    clippy::cognitive_complexity,
3217    reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
3218              + backoff); mirrors the cross manager, not worth splitting further"
3219)]
3220async fn isolated_connection_manager(
3221    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
3222    dedup: SharedDedupCache,
3223    ws_config: ConfigurationWebsocketApi,
3224    rest_config: Arc<ConfigurationRestApi>,
3225    rest: Arc<RestApi>,
3226    rate_limiter: Arc<RateLimitTracker>,
3227    known: SharedKnownLiveOrders,
3228    symbols: Vec<InstrumentNameExchange>,
3229    base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
3230    initial: Option<IsolatedLiveConn>,
3231) {
3232    enum DisconnectReason {
3233        Signal,
3234        HeartbeatTimeout,
3235        TokenRefresh,
3236        ConsumerDropped,
3237    }
3238
3239    let mut backoff = ExponentialBackoff::new();
3240    let mut disconnect_time: Option<DateTime<Utc>> = None;
3241    // Gaps not yet recovered: read at reconnect, and retried on a timer while connected, once due.
3242    let mut unrecovered = UnrecoveredFills::default();
3243    // Instruments whose known-live orders a reconnect has yet to check, likewise.
3244    let mut unchecked = UncheckedOrders::default();
3245    let mut current = initial;
3246
3247    loop {
3248        // --- (Re)establish the live, subscribed connection ---
3249        let IsolatedLiveConn {
3250            ws,
3251            subscription,
3252            signal_rx,
3253            heartbeat_flag,
3254            earliest_expiry_ms,
3255        } = match current.take() {
3256            // Verified initial connection from account_stream — used as-is on the first pass.
3257            Some(live) => live,
3258            None => {
3259                // Reconnect: re-acquire all N tokens (they cluster; a planned refresh re-acquires the
3260                // whole set) then connect a fresh socket and re-subscribe all N.
3261                let tokens = match acquire_all_isolated_tokens(
3262                    &rest_config,
3263                    &rate_limiter,
3264                    &symbols,
3265                )
3266                .await
3267                {
3268                    Ok(t) => t,
3269                    Err(e) => {
3270                        error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
3271                        if !backoff.wait().await {
3272                            error!("BinanceMargin isolated max reconnect attempts exhausted");
3273                            emit_stream_terminated(
3274                                &tx,
3275                                ExchangeId::BinanceMargin,
3276                                StreamTerminationReason::ReconnectBudgetExhausted {
3277                                    attempts: backoff.attempts(),
3278                                    last_error: e.to_string(),
3279                                },
3280                            );
3281                            break;
3282                        }
3283                        continue;
3284                    }
3285                };
3286                match isolated_connect_and_subscribe(
3287                    &ws_config,
3288                    &tx,
3289                    &dedup,
3290                    &known,
3291                    &base_quote,
3292                    &tokens,
3293                )
3294                .await
3295                {
3296                    Ok(live) => live,
3297                    Err(e) => {
3298                        error!(%e, "BinanceMargin isolated connect/subscribe failed");
3299                        if !backoff.wait().await {
3300                            error!("BinanceMargin isolated max reconnect attempts exhausted");
3301                            emit_stream_terminated(
3302                                &tx,
3303                                ExchangeId::BinanceMargin,
3304                                StreamTerminationReason::ReconnectBudgetExhausted {
3305                                    attempts: backoff.attempts(),
3306                                    last_error: e.to_string(),
3307                                },
3308                            );
3309                            break;
3310                        }
3311                        continue;
3312                    }
3313                }
3314            }
3315        };
3316
3317        info!(
3318            symbols = symbols.len(),
3319            "BinanceMargin isolated account_stream connected and subscribed"
3320        );
3321        // Reaching a live, subscribed connection clears any prior failure count (see the cross
3322        // manager) so only *consecutive* failures exhaust the reconnect budget.
3323        backoff.reset();
3324
3325        // Absolute deadline (not a per-iteration relative sleep) so heartbeat ticks that `continue`
3326        // the monitor loop don't keep restarting the renewal timer. Earliest token expiry drives it.
3327        let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
3328
3329        // --- Recovery after a reconnect (isolated-scoped over the full symbol set) ---
3330        // Fills first, then how the orders held as live ended, as on cross.
3331        if let Some(dt) = disconnect_time.take() {
3332            open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &symbols, dt);
3333        }
3334        // is_isolated = true: every margin read must query the isolated accounts.
3335        recover_margin_fills(
3336            &rest,
3337            &rate_limiter,
3338            &mut unrecovered,
3339            &tx,
3340            &dedup,
3341            &known,
3342            true,
3343        )
3344        .await;
3345        recover_margin_ended_orders(
3346            &rest,
3347            &rate_limiter,
3348            &known,
3349            &mut unchecked,
3350            &unrecovered,
3351            &tx,
3352            true,
3353        )
3354        .await;
3355
3356        // --- Monitor: disconnect signal, heartbeat timeout, token refresh, or consumer drop ---
3357        // One socket regardless of N, so the conditions are identical to cross.
3358        let reason = {
3359            let mut signal_rx = signal_rx;
3360            // Gaps and order checks a recovery did not finish are retried as they fall due,
3361            // alongside the monitor, as on cross.
3362            let retry_gaps = async {
3363                loop {
3364                    let next_check = unchecked.next_due(&unrecovered);
3365                    match unrecovered.next_due().into_iter().chain(next_check).min() {
3366                        Some(due) => tokio::time::sleep_until(due).await,
3367                        None => std::future::pending::<()>().await,
3368                    }
3369                    recover_margin_fills(
3370                        &rest,
3371                        &rate_limiter,
3372                        &mut unrecovered,
3373                        &tx,
3374                        &dedup,
3375                        &known,
3376                        true,
3377                    )
3378                    .await;
3379                    recover_margin_ended_orders(
3380                        &rest,
3381                        &rate_limiter,
3382                        &known,
3383                        &mut unchecked,
3384                        &unrecovered,
3385                        &tx,
3386                        true,
3387                    )
3388                    .await;
3389                }
3390            };
3391            tokio::pin!(retry_gaps);
3392            loop {
3393                tokio::select! {
3394                    biased;
3395                    _ = tx.closed() => {
3396                        debug!("BinanceMargin isolated consumer dropped, terminating");
3397                        break DisconnectReason::ConsumerDropped;
3398                    }
3399                    _ = &mut signal_rx => {
3400                        warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
3401                        break DisconnectReason::Signal;
3402                    }
3403                    () = tokio::time::sleep_until(token_deadline) => {
3404                        info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
3405                        break DisconnectReason::TokenRefresh;
3406                    }
3407                    () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
3408                        if heartbeat_flag.swap(false, Ordering::AcqRel) {
3409                            continue;
3410                        }
3411                        warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
3412                        break DisconnectReason::HeartbeatTimeout;
3413                    }
3414                    // Last: the arms above end the monitor and win when ready together.
3415                    () = &mut retry_gaps => {}
3416                }
3417            }
3418        };
3419        let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
3420
3421        if should_reconnect {
3422            disconnect_time = Some(match reason {
3423                DisconnectReason::HeartbeatTimeout => {
3424                    Utc::now()
3425                        - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
3426                        - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3427                }
3428                DisconnectReason::TokenRefresh => {
3429                    // A rotation fully disconnects + reconnects (re-acquire N tokens + reconnect +
3430                    // re-subscribe N). Look back far enough to bound that whole window so a fill
3431                    // landing during the sub-second gap isn't missed; dedup absorbs the overlap.
3432                    Utc::now()
3433                        - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
3434                        - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3435                }
3436                _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
3437            });
3438        }
3439
3440        // --- Cleanup ---
3441        subscription.unsubscribe();
3442        if let Err(e) = ws.disconnect().await {
3443            warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
3444        }
3445
3446        if !should_reconnect || tx.is_closed() {
3447            // Consumer dropped the receiver — no StreamTerminated emit (channel already closed).
3448            debug!("BinanceMargin isolated connection manager exiting");
3449            break;
3450        }
3451
3452        // A planned token refresh is not a failure — reconnect immediately (no backoff); `current`
3453        // is already None, forcing fresh tokens + a fresh connection next iteration.
3454        if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
3455            error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
3456            // reason here is Signal or HeartbeatTimeout (TokenRefresh guarded above,
3457            // ConsumerDropped already broke out earlier).
3458            let last_error = match reason {
3459                DisconnectReason::HeartbeatTimeout => {
3460                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
3461                }
3462                _ => "WebSocket disconnected (server close/error)".to_string(),
3463            };
3464            emit_stream_terminated(
3465                &tx,
3466                ExchangeId::BinanceMargin,
3467                StreamTerminationReason::ReconnectBudgetExhausted {
3468                    attempts: backoff.attempts(),
3469                    last_error,
3470                },
3471            );
3472            break;
3473        }
3474    }
3475}
3476
3477// ---------------------------------------------------------------------------
3478// Account query helpers (margin-specific)
3479// ---------------------------------------------------------------------------
3480
3481/// Fetch open orders for a single instrument via `query_margin_accounts_open_orders`
3482/// (`isIsolated` config-driven). Mirrors `BinanceSpot::fetch_open_orders_for_instrument`.
3483async fn fetch_margin_open_orders_for_instrument(
3484    rest: Arc<RestApi>,
3485    rate_limiter: Arc<RateLimitTracker>,
3486    instrument: InstrumentNameExchange,
3487    is_isolated: bool,
3488    kind: RequestKind,
3489) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
3490    // Convert once before the retry closure to avoid a String allocation on every retry.
3491    let symbol_str = instrument.name().to_string();
3492    let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3493    let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3494        let sym = symbol_str.clone();
3495        let isolated = isolated.clone();
3496        Box::pin(async move {
3497            let params = QueryMarginAccountsOpenOrdersParams::builder()
3498                .symbol(sym)
3499                .is_isolated(isolated)
3500                .build()?;
3501            rest.query_margin_accounts_open_orders(params).await
3502        })
3503    })
3504    .await
3505    .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
3506
3507    let orders_data = response.data().await.map_err(response_decode_error)?;
3508
3509    let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceMargin, &instrument);
3510
3511    Ok((instrument, listing))
3512}
3513
3514/// Fetch *all* open margin orders in a single no-symbol `query_margin_accounts_open_orders`
3515/// call. Backs the [`fetch_open_orders`](BinanceMargin::fetch_open_orders) "return all" sentinel for
3516/// **cross** (the no-symbol affordance is cross-only): with no symbol the venue returns orders
3517/// across every instrument, so each order's instrument is recovered from its own `symbol` field
3518/// (orders missing it are dropped). `isIsolated` is config-driven, but under isolated the caller
3519/// iterates the configured symbol set per-symbol rather than using this no-symbol path.
3520async fn fetch_margin_all_open_orders(
3521    rest: Arc<RestApi>,
3522    rate_limiter: Arc<RateLimitTracker>,
3523    is_isolated: bool,
3524) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
3525    let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3526    let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3527        let isolated = isolated.clone();
3528        Box::pin(async move {
3529            let params = QueryMarginAccountsOpenOrdersParams::builder()
3530                .is_isolated(isolated)
3531                .build()?;
3532            rest.query_margin_accounts_open_orders(params).await
3533        })
3534    })
3535    .await
3536    .map_err(|e| classify_rest_query_error(&e, None))?;
3537
3538    let orders_data = response.data().await.map_err(response_decode_error)?;
3539
3540    let orders = orders_data
3541        .into_iter()
3542        .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin))
3543        .collect();
3544
3545    Ok(orders)
3546}
3547
3548/// Look the order under `key` up with `GET /sapi/v1/margin/order` by its client order id
3549/// (weight 10), in the cross or isolated account as `is_isolated` says.
3550///
3551/// `recorded_kind` is the kind the client holds the order with, if it does. The row cannot say
3552/// whether a conditional order trailed (#541), so an ended one is reported with that kind, or
3553/// failing it with its fixed trigger (see [`convert_ended_order`]).
3554async fn fetch_margin_order_lookup(
3555    rest: Arc<RestApi>,
3556    rate_limiter: Arc<RateLimitTracker>,
3557    key: UnindexedOrderKey,
3558    is_isolated: bool,
3559    kind: RequestKind,
3560    recorded_kind: Option<OrderKind>,
3561) -> Result<OrderLookup, UnindexedClientError> {
3562    // Convert once before the retry closure to avoid a String allocation on every retry.
3563    let symbol = key.instrument.name().to_string();
3564    let cid = key.cid.0.to_string();
3565    let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
3566    let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3567        let (symbol, cid, isolated) = (symbol.clone(), cid.clone(), isolated.clone());
3568        Box::pin(async move {
3569            let params = QueryMarginAccountsOrderParams::builder(symbol)
3570                .orig_client_order_id(cid)
3571                .is_isolated(isolated)
3572                .build()?;
3573            rest.query_margin_accounts_order(params).await
3574        })
3575    })
3576    .await
3577    {
3578        Ok(response) => response,
3579        Err(e) if is_unknown_order(&e) => {
3580            debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceMargin does not know this order");
3581            return Ok(OrderLookup::Unknown);
3582        }
3583        Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
3584    };
3585
3586    let row = response.data().await.map_err(response_decode_error)?;
3587
3588    Ok(
3589        convert_ended_order(&row, ExchangeId::BinanceMargin, &key, recorded_kind)
3590            .map_or(OrderLookup::NotEnded, |order| {
3591                OrderLookup::Ended(Box::new(order))
3592            }),
3593    )
3594}
3595
3596/// The client order id of the order `order_id` on `instrument`, read with
3597/// `GET /sapi/v1/margin/order` (weight 10) in the cross or isolated account as `is_isolated`
3598/// says, for fill recovery: `myTrades` carries none. `None` if Binance reports none, or does not
3599/// know the order.
3600async fn fetch_margin_order_cid(
3601    rest: &Arc<RestApi>,
3602    rate_limiter: &RateLimitTracker,
3603    instrument: &InstrumentNameExchange,
3604    order_id: i64,
3605    is_isolated: bool,
3606) -> Result<Option<ClientOrderId>, UnindexedClientError> {
3607    let symbol = instrument.name().to_string();
3608    let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
3609    let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
3610        let (symbol, isolated) = (symbol.clone(), isolated.clone());
3611        Box::pin(async move {
3612            let params = QueryMarginAccountsOrderParams::builder(symbol)
3613                .order_id(order_id)
3614                .is_isolated(isolated)
3615                .build()?;
3616            rest.query_margin_accounts_order(params).await
3617        })
3618    })
3619    .await
3620    {
3621        Ok(response) => response,
3622        Err(e) if is_unknown_order(&e) => {
3623            debug!(%instrument, order_id, error = %e, "BinanceMargin does not know this order");
3624            return Ok(None);
3625        }
3626        Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
3627    };
3628    let row = response.data().await.map_err(response_decode_error)?;
3629    Ok(row
3630        .client_order_id
3631        .filter(|cid| !cid.is_empty())
3632        .map(ClientOrderId::new))
3633}
3634
3635/// The client order ids `GET /sapi/v1/margin/openOrders` lists on `instruments` (weight 10 each,
3636/// one at a time), for a reconnect's check of the orders held as live.
3637async fn listed_margin_open_cids(
3638    rest: Arc<RestApi>,
3639    rate_limiter: Arc<RateLimitTracker>,
3640    instruments: Vec<InstrumentNameExchange>,
3641    is_isolated: bool,
3642) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3643    let mut listed = FnvHashSet::default();
3644    for instrument in instruments {
3645        let (_, listing) = fetch_margin_open_orders_for_instrument(
3646            rest.clone(),
3647            rate_limiter.clone(),
3648            instrument,
3649            is_isolated,
3650            RequestKind::Essential,
3651        )
3652        .await?;
3653        listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
3654    }
3655    Ok(listed)
3656}
3657
3658/// [`recover_ended_orders`] on Binance Margin: listings by [`listed_margin_open_cids`] and lookups
3659/// by [`fetch_margin_order_lookup`], both [`RequestKind::Essential`], as fill recovery's reads are.
3660async fn recover_margin_ended_orders(
3661    rest: &Arc<RestApi>,
3662    rate_limiter: &Arc<RateLimitTracker>,
3663    known: &SharedKnownLiveOrders,
3664    unchecked: &mut UncheckedOrders,
3665    unrecovered: &UnrecoveredFills,
3666    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3667    is_isolated: bool,
3668) {
3669    recover_ended_orders(
3670        ExchangeId::BinanceMargin,
3671        known,
3672        unchecked,
3673        unrecovered,
3674        tx,
3675        OpenListing::PerInstrument,
3676        |instruments| {
3677            listed_margin_open_cids(rest.clone(), rate_limiter.clone(), instruments, is_isolated)
3678        },
3679        |key| {
3680            // Held as live, so the client knows its kind, which the row may not show.
3681            let recorded_kind = known.lock().kind_of(&key.cid);
3682            fetch_margin_order_lookup(
3683                rest.clone(),
3684                rate_limiter.clone(),
3685                key,
3686                is_isolated,
3687                RequestKind::Essential,
3688                recorded_kind,
3689            )
3690        },
3691    )
3692    .await;
3693}
3694
3695/// Length of each time window [`paginate_margin_my_trades`] walks before it finds a trade.
3696///
3697/// Binance requires "less than 24 hours between startTime and endTime" on margin `myTrades`,
3698/// and returns only 24 hours of trades to a query without `fromId`. Kept well under that
3699/// limit so no reading of the bound (inclusive ends, clock rounding) can make a window too long.
3700const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
3701
3702/// One margin `myTrades` request in a [`paginate_margin_my_trades`] walk. The walk's state is the
3703/// next request to send.
3704#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3705enum MarginTradesQuery {
3706    /// Trades stamped from `start` to `end`, in epoch milliseconds.
3707    Window { start: i64, end: i64 },
3708    /// Trades from this id onwards, whatever their time.
3709    FromId(i64),
3710    /// One order's executions, from its first.
3711    Order(i64),
3712    /// One order's executions, from this id onwards.
3713    OrderFromId { order_id: i64, from_id: i64 },
3714}
3715
3716impl MarginTradesQuery {
3717    /// The first request of a walk from `from`, or `None` when a walk by time starts after
3718    /// `now_ms` and so has nothing to read. `now_ms` is where a walk by time stops stepping by
3719    /// window: the call's start, or a span's end if earlier.
3720    fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
3721        match from {
3722            MyTradesFrom::Span { start, .. } => {
3723                (start <= now_ms).then(|| Self::window(start, now_ms))
3724            }
3725            MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
3726        }
3727    }
3728
3729    /// The window from `start`, ending [`MARGIN_MY_TRADES_WINDOW_MS`] later or at `now_ms`.
3730    fn window(start: i64, now_ms: i64) -> Self {
3731        Self::Window {
3732            start,
3733            end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
3734        }
3735    }
3736
3737    /// The request after this one returned `page_len` trades, the last with id `last_id`, or
3738    /// `None` when the walk is complete.
3739    fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
3740        let full = page_len >= BINANCE_MAX_TRADES;
3741        let after_last = || last_id?.checked_add(1);
3742        match self {
3743            // An empty window steps on until it reaches `now_ms`. The next window starts at this
3744            // one's end, so a trade stamped exactly on the boundary is read whether Binance treats
3745            // `endTime` as inclusive or not. That cannot duplicate a trade: windows only follow
3746            // one another while every window so far was empty.
3747            Self::Window { end, .. } if page_len == 0 => {
3748                (end < now_ms).then(|| Self::window(end, now_ms))
3749            }
3750            // The final window read short holds everything up to `now_ms`: done.
3751            Self::Window { end, .. } if end >= now_ms && !full => None,
3752            // Any other window holding a trade: later trades may lie beyond its end, so read on
3753            // by id, which Binance serves whatever the time.
3754            Self::Window { .. } => after_last().map(Self::FromId),
3755            Self::FromId(_) if full => after_last().map(Self::FromId),
3756            Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
3757                after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
3758            }
3759            Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
3760        }
3761    }
3762}
3763
3764/// Paginate the margin trade list for a single instrument from `from` (`isIsolated`
3765/// config-driven), returning every trade it covers with no gap.
3766///
3767/// - [`MyTradesFrom::Order`]: first page by `order_id`, then `from_id = last_id + 1` alongside it,
3768///   as `BinanceSpot::paginate_my_trades` does.
3769/// - [`MyTradesFrom::Span`]: unlike spot, margin `myTrades` does not return every trade since a
3770///   bare `startTime` — without `fromId` it returns only 24 hours of trades, and it rejects a
3771///   `startTime`..`endTime` span of 24 hours or more. So the walk first steps forward in windows
3772///   of [`MARGIN_MY_TRADES_WINDOW_MS`], each bounded by `startTime` and `endTime`, from the span's
3773///   start to its end or the call's start by the local clock, whichever is earlier. At the first
3774///   window holding a trade it switches to `from_id = last_id + 1` and reads on until a short
3775///   page, unless that window was the last and read a short page, and stops at the first page
3776///   reaching past the span's end, without the executions after it.
3777///
3778/// **Cost:** each window before the first trade is one request (weight 10), so the cost grows
3779/// with the lookback: about one request per day, and about 21,000 from the Unix epoch. An
3780/// ordinary reconnect gap fits in one window.
3781///
3782/// **Isolated correctness:** this also backs reconnect fill-recovery (`recover_margin_fills`), so a
3783/// missed `isIsolated` flip would silently query *cross* trades on an isolated client — hence the
3784/// value is threaded from config rather than hardcoded.
3785async fn paginate_margin_my_trades(
3786    rest: &Arc<RestApi>,
3787    rate_limiter: &Arc<RateLimitTracker>,
3788    instrument: &InstrumentNameExchange,
3789    from: MyTradesFrom,
3790    is_isolated: bool,
3791    kind: RequestKind,
3792) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
3793    // Convert once before the retry closure to avoid a String allocation on every retry.
3794    let symbol_str = instrument.name().to_string();
3795    let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
3796    // The const_assert! in shared bounds BINANCE_MAX_TRADES <= i32::MAX, which trivially fits in
3797    // i64, so this cast is always lossless. clippy::cast_possible_wrap fires only because usize is
3798    // the same width as i64 on 64-bit targets (a hypothetical >64-bit usize could wrap); the bound
3799    // rules that out.
3800    #[allow(clippy::cast_possible_wrap)]
3801    let limit = BINANCE_MAX_TRADES as i64;
3802    // A walk by time reads up to here. A trade after it is left to the caller's next read (or, on
3803    // reconnect, to the stream), unless the walk is already paging by id.
3804    let now_ms = match from {
3805        MyTradesFrom::Span { end, .. } => end.min(Utc::now().timestamp_millis()),
3806        MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
3807    };
3808    let mut all_pages = Vec::new();
3809    let mut requests = 0u32;
3810    let mut next = MarginTradesQuery::first(from, now_ms);
3811    while let Some(query) = next {
3812        requests += 1;
3813        let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
3814            let sym = symbol_str.clone();
3815            let isolated = isolated.clone();
3816            Box::pin(async move {
3817                let builder = QueryMarginAccountsTradeListParams::builder(sym)
3818                    .is_isolated(isolated)
3819                    .limit(limit);
3820                let params = match query {
3821                    MarginTradesQuery::Window { start, end } => {
3822                        builder.start_time(start).end_time(end)
3823                    }
3824                    MarginTradesQuery::FromId(id) => builder.from_id(id),
3825                    MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
3826                    MarginTradesQuery::OrderFromId { order_id, from_id } => {
3827                        builder.order_id(order_id).from_id(from_id)
3828                    }
3829                }
3830                .build()?;
3831                rest.query_margin_accounts_trade_list(params).await
3832            })
3833        })
3834        .await
3835        .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
3836
3837        let mut page = response.data().await.map_err(response_decode_error)?;
3838
3839        let page_len = page.len();
3840        let last_id = page.last().and_then(|t| t.id);
3841        // A span ends once a page reaches past it; paging by id would otherwise read on.
3842        let past_end = match from {
3843            MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
3844            MyTradesFrom::Order(_) => false,
3845        };
3846        all_pages.extend(page);
3847        if past_end {
3848            break;
3849        }
3850
3851        if page_len > 0 && last_id.is_none() {
3852            warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
3853            break;
3854        }
3855        next = query.next(page_len, last_id, now_ms);
3856    }
3857    debug!(
3858        %instrument,
3859        requests,
3860        trades = all_pages.len(),
3861        "BinanceMargin paginate_my_trades: walk complete"
3862    );
3863    Ok(all_pages)
3864}
3865
3866// ---------------------------------------------------------------------------
3867// Account conversion helpers (margin-specific)
3868// ---------------------------------------------------------------------------
3869
3870/// Convert one cross-margin `userAsset` into a margin [`AssetBalance`], carrying the per-asset
3871/// `borrowed`/`interest` debt via [`Balance::new_margin`]. `total = free + locked`.
3872///
3873/// Distinct from spot's `convert_balance_entry`: spot has no debt fields and calls
3874/// [`Balance::new`]. Returns `None` (with a warning) if a required field is missing/unparseable.
3875fn convert_margin_balance_entry(
3876    b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
3877    now: DateTime<Utc>,
3878) -> Option<AssetBalance<AssetNameExchange>> {
3879    let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
3880    let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3881        Some(v) => v,
3882        None => {
3883            warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
3884            return None;
3885        }
3886    };
3887    let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3888        Some(v) => v,
3889        None => {
3890            warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
3891            return None;
3892        }
3893    };
3894    // Debt fields default to zero when missing/unparseable: a userAsset row always represents a
3895    // real margin position, so absent debt means "no debt", not corrupt data — emit a margin
3896    // Balance (carrying zero debt) rather than dropping the asset. This preserves the
3897    // Design-decision-#4 invariant that a REST snapshot always populates `margin`.
3898    let borrowed = b
3899        .borrowed
3900        .as_deref()
3901        .and_then(|s| Decimal::from_str(s).ok())
3902        .unwrap_or(Decimal::ZERO);
3903    let interest = b
3904        .interest
3905        .as_deref()
3906        .and_then(|s| Decimal::from_str(s).ok())
3907        .unwrap_or(Decimal::ZERO);
3908    Some(AssetBalance::new(
3909        asset_name,
3910        Balance::new_margin(free + locked, free, borrowed, interest),
3911        now,
3912    ))
3913}
3914
3915/// Filter cross-margin `userAssets` to the requested assets and convert to [`AssetBalance`]s.
3916/// An empty `assets` slice returns all. Mirrors spot's `filter_and_convert_balances`.
3917fn filter_and_convert_margin_balances(
3918    user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3919    assets: &[AssetNameExchange],
3920) -> Vec<AssetBalance<AssetNameExchange>> {
3921    let now = Utc::now();
3922    // Empty assets slice means "return all" — skip building the set entirely.
3923    if assets.is_empty() {
3924        return user_assets
3925            .into_iter()
3926            .filter_map(|b| convert_margin_balance_entry(b, now))
3927            .collect();
3928    }
3929    // For small slices (≤16 assets), linear scan avoids allocation and hashing overhead.
3930    if assets.len() <= 16 {
3931        return user_assets
3932            .into_iter()
3933            .filter_map(|b| {
3934                let asset_name_str = b.asset.as_deref()?;
3935                if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3936                    return None;
3937                }
3938                convert_margin_balance_entry(b, now)
3939            })
3940            .collect();
3941    }
3942    use std::collections::HashSet;
3943    let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3944    user_assets
3945        .into_iter()
3946        .filter_map(|b| {
3947            let asset_name_str = b.asset.as_deref()?;
3948            if !asset_set.contains(asset_name_str) {
3949                return None;
3950            }
3951            convert_margin_balance_entry(b, now)
3952        })
3953        .collect()
3954}
3955
3956/// Wrap a fetched `Open` order as an `OrderState::active` snapshot for `account_snapshot`.
3957///
3958/// `account_snapshot` reports open orders wrapped in `OrderState::active(..)`, whereas
3959/// `fetch_open_orders` returns the bare `Open` — both share
3960/// [`fetch_margin_open_orders_for_instrument`], so this is the single wrap used by the cross and
3961/// isolated snapshot paths.
3962fn active_order_snapshot(
3963    o: Order<ExchangeId, InstrumentNameExchange, Open>,
3964) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3965{
3966    Order {
3967        key: o.key,
3968        side: o.side,
3969        price: o.price,
3970        quantity: o.quantity,
3971        kind: o.kind,
3972        time_in_force: o.time_in_force,
3973        state: OrderState::active(o.state),
3974    }
3975}
3976
3977/// Convert one side (base or quote) of an isolated `assets[]` entry into a margin [`AssetBalance`].
3978///
3979/// Mirrors [`convert_margin_balance_entry`]'s field handling: `free`/`locked` are required (missing
3980/// → `None`, drop), while `borrowed`/`interest` default to zero when absent — a real isolated
3981/// sub-account row with absent debt means "no debt", not corrupt data, upholding the
3982/// Design-decision-#4 invariant that a REST snapshot always populates `margin`. `total = free + locked`.
3983///
3984/// Takes the fields rather than the SDK side type because the base and quote sides are distinct
3985/// nominal types (`...BaseAsset` / `...QuoteAsset`) with identical fields.
3986fn convert_isolated_asset_balance(
3987    asset: Option<&str>,
3988    free: Option<&str>,
3989    locked: Option<&str>,
3990    borrowed: Option<&str>,
3991    interest: Option<&str>,
3992    now: DateTime<Utc>,
3993) -> Option<AssetBalance<AssetNameExchange>> {
3994    let asset_name = AssetNameExchange::new(asset?);
3995    let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3996        Some(v) => v,
3997        None => {
3998            warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3999            return None;
4000        }
4001    };
4002    let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
4003        Some(v) => v,
4004        None => {
4005            warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
4006            return None;
4007        }
4008    };
4009    let borrowed = borrowed
4010        .and_then(|s| Decimal::from_str(s).ok())
4011        .unwrap_or(Decimal::ZERO);
4012    let interest = interest
4013        .and_then(|s| Decimal::from_str(s).ok())
4014        .unwrap_or(Decimal::ZERO);
4015    Some(AssetBalance::new(
4016        asset_name,
4017        Balance::new_margin(free + locked, free, borrowed, interest),
4018        now,
4019    ))
4020}
4021
4022/// Map isolated `query_isolated_margin_account_info` `assets[]` entries to per-instrument
4023/// [`IsolatedInstrumentState`], keyed by instrument (pair symbol).
4024///
4025/// An entry must carry a `symbol` plus a `baseAsset` and `quoteAsset` with parseable `free`/`locked`;
4026/// an entry missing any of these is dropped with a `warn!` (observable, never silently mis-mapped).
4027/// Per-pair risk metrics (`marginLevel`/`marginRatio`/`liquidatePrice`) are best-effort `Option`s — a
4028/// missing/unparseable risk field becomes `None` and does NOT drop the entry.
4029fn convert_isolated_margin_assets(
4030    assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
4031) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
4032    // Single timestamp for the whole batch: every entry came from the same REST response, so this
4033    // is the response's freshness (mirrors the cross-margin `convert_margin_balance_entry` `now`).
4034    let now = Utc::now();
4035    let mut map = HashMap::with_capacity(assets.len());
4036    for entry in assets {
4037        let Some(symbol) = entry.symbol.as_deref() else {
4038            warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
4039            continue;
4040        };
4041        let instrument = InstrumentNameExchange::new(symbol);
4042
4043        let Some(base_raw) = entry.base_asset.as_deref() else {
4044            warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
4045            continue;
4046        };
4047        let Some(quote_raw) = entry.quote_asset.as_deref() else {
4048            warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
4049            continue;
4050        };
4051
4052        let Some(base) = convert_isolated_asset_balance(
4053            base_raw.asset.as_deref(),
4054            base_raw.free.as_deref(),
4055            base_raw.locked.as_deref(),
4056            base_raw.borrowed.as_deref(),
4057            base_raw.interest.as_deref(),
4058            now,
4059        ) else {
4060            warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
4061            continue;
4062        };
4063        let Some(quote) = convert_isolated_asset_balance(
4064            quote_raw.asset.as_deref(),
4065            quote_raw.free.as_deref(),
4066            quote_raw.locked.as_deref(),
4067            quote_raw.borrowed.as_deref(),
4068            quote_raw.interest.as_deref(),
4069            now,
4070        ) else {
4071            warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
4072            continue;
4073        };
4074
4075        let risk = IsolatedMarginRisk {
4076            margin_level: entry
4077                .margin_level
4078                .as_deref()
4079                .and_then(|s| Decimal::from_str(s).ok()),
4080            margin_ratio: entry
4081                .margin_ratio
4082                .as_deref()
4083                .and_then(|s| Decimal::from_str(s).ok()),
4084            liquidation_price: entry
4085                .liquidate_price
4086                .as_deref()
4087                .and_then(|s| Decimal::from_str(s).ok()),
4088        };
4089
4090        map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
4091    }
4092    map
4093}
4094
4095/// Group symbols into comma-separated request strings, ≤5 symbols each (the venue's per-request
4096/// cap on `query_isolated_margin_account_info`'s `symbols`). An empty input yields no chunks.
4097fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
4098    symbols
4099        .chunks(5)
4100        .map(|chunk| {
4101            chunk
4102                .iter()
4103                .map(|s| s.name().as_str())
4104                .collect::<Vec<_>>()
4105                .join(",")
4106        })
4107        .collect()
4108}
4109
4110/// Fetch isolated-margin account info for `symbols`, chunked at the venue's max-5-symbols/request
4111/// cap and flattened to the `assets[]` entries across all chunks.
4112///
4113/// `symbols` must be non-empty (a no-symbol isolated call is invalid). Chunks are fetched
4114/// concurrently (bounded to 8) through the shared rate-limit/backoff machinery.
4115async fn fetch_isolated_margin_account_info(
4116    rest: Arc<RestApi>,
4117    rate_limiter: Arc<RateLimitTracker>,
4118    symbols: Vec<InstrumentNameExchange>,
4119) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
4120    use futures::{StreamExt as _, TryStreamExt as _};
4121
4122    let chunks = chunk_symbols(&symbols);
4123
4124    // One `assets[]` entry per requested symbol, so pre-size the flat accumulator to `symbols.len()`
4125    // and `try_fold` the per-chunk results straight into it — avoiding the intermediate
4126    // `Vec<Vec<_>>` + re-copying `flatten().collect()` (mirrors the `try_fold` in `fetch_open_orders`).
4127    futures::stream::iter(chunks.into_iter().map(|symbols_param| {
4128        let rest = rest.clone();
4129        let rate_limiter = rate_limiter.clone();
4130        async move {
4131            let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
4132                let symbols_param = symbols_param.clone();
4133                Box::pin(async move {
4134                    let params = QueryIsolatedMarginAccountInfoParams::builder()
4135                        .symbols(symbols_param)
4136                        .build()?;
4137                    rest.query_isolated_margin_account_info(params).await
4138                })
4139            })
4140            .await
4141            .map_err(|e| classify_rest_query_error(&e, None))?;
4142
4143            let info = response.data().await.map_err(response_decode_error)?;
4144            Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
4145        }
4146    }))
4147    .buffer_unordered(8)
4148    .try_fold(
4149        Vec::with_capacity(symbols.len()),
4150        |mut acc, assets| async move {
4151            acc.extend(assets);
4152            Ok(acc)
4153        },
4154    )
4155    .await
4156}
4157
4158/// Convert a margin trade-list response into a rustrade [`Trade`].
4159///
4160/// Field-for-field identical to spot's `convert_my_trade` but typed to the margin SDK response and
4161/// stamped with [`ExchangeId::BinanceMargin`]. Fee asset is taken verbatim from `commissionAsset`
4162/// (may be base, quote, or third-party e.g. BNB); `fees_quote` is left `None` for the indexer to
4163/// resolve.
4164fn convert_margin_trade(
4165    t: &QueryMarginAccountsTradeListResponseInner,
4166    instrument: &InstrumentNameExchange,
4167) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4168    let trade_id_raw = match t.id {
4169        Some(id) => id,
4170        None => {
4171            warn!(%instrument, "BinanceMargin trade missing id");
4172            return None;
4173        }
4174    };
4175    let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
4176    let order_id = match t.order_id {
4177        Some(id) => OrderId(format_smolstr!("{id}")),
4178        None => {
4179            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
4180            return None;
4181        }
4182    };
4183    let side = match t.is_buyer {
4184        Some(true) => Side::Buy,
4185        Some(false) => Side::Sell,
4186        None => {
4187            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
4188            return None;
4189        }
4190    };
4191    let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4192        Some(v) => v,
4193        None => {
4194            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
4195            return None;
4196        }
4197    };
4198    let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4199        Some(v) => v,
4200        None => {
4201            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
4202            return None;
4203        }
4204    };
4205    let commission = t
4206        .commission
4207        .as_deref()
4208        .and_then(|s| Decimal::from_str(s).ok())
4209        .unwrap_or(Decimal::ZERO);
4210    let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
4211        Some(ts) => ts,
4212        None => {
4213            warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
4214            Utc::now()
4215        }
4216    };
4217
4218    // Use actual commission asset from Binance (e.g., BNB, USDT, BTC). fees_quote is None here;
4219    // the indexer computes it when the fee is in quote or base asset. "UNKNOWN" fallback (rare:
4220    // API omits commissionAsset) will fail indexing rather than silently misattribute the fee.
4221    let fee_asset = t
4222        .commission_asset
4223        .as_deref()
4224        .map(AssetNameExchange::from)
4225        .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
4226
4227    Some(Trade::new(
4228        trade_id,
4229        order_id,
4230        instrument.clone(),
4231        StrategyId::unknown(), // Binance doesn't carry strategy IDs
4232        time_exchange,
4233        side,
4234        price,
4235        quantity,
4236        // `myTrades` reports executions only -- no cumulative, no order status.
4237        None,
4238        AssetFees::new(fee_asset, commission, None),
4239    ))
4240}
4241
4242// ---------------------------------------------------------------------------
4243// Order conversion helpers (margin-specific)
4244// ---------------------------------------------------------------------------
4245
4246/// Failure modes when constructing [`MarginAccountNewOrderParams`] from a rustrade request.
4247#[derive(Debug)]
4248enum BuildOrderError {
4249    /// The `OrderKind`/`TimeInForce` combination is not supported on Binance margin.
4250    Unsupported,
4251    /// The SDK params builder rejected the inputs (carries the builder's error message).
4252    Build(String),
4253    /// A trailing order cannot be sent as asked.
4254    Trailing(TrailingOrderError),
4255}
4256
4257/// Construct a margin endpoint's `isIsolated` parameter enum from the configured margin mode.
4258///
4259/// The SDK generates a *separate* two-variant `isIsolated` enum per endpoint, all with identical
4260/// `True`/`False` variants serialising to `"TRUE"`/`"FALSE"`. This trait keeps the one
4261/// `bool` → wire-value mapping defined (and unit-tested) in a single place, as the former
4262/// `isolated_str` helper did, rather than repeating a ternary at each call site.
4263///
4264/// `true` → `True` (`"TRUE"`, isolated, per-pair sub-accounts); `false` → `False` (`"FALSE"`,
4265/// cross, account-wide). Threaded through every margin order/cancel/query call so the mode is
4266/// config-driven from a single source rather than hardcoded per-call.
4267trait IsolatedFlag {
4268    /// The endpoint's `isIsolated` variant for `is_isolated`.
4269    fn from_flag(is_isolated: bool) -> Self;
4270}
4271
4272/// Implement [`IsolatedFlag`] for each endpoint's `isIsolated` enum.
4273///
4274/// Every one of these SDK enums is the same two-variant shape, so a macro keeps the impls honest:
4275/// adding an endpoint is one line here rather than a copy-pasted block that can silently invert.
4276macro_rules! impl_isolated_flag {
4277    ($($t:ty),* $(,)?) => {
4278        $(
4279            impl IsolatedFlag for $t {
4280                fn from_flag(is_isolated: bool) -> Self {
4281                    if is_isolated { Self::True } else { Self::False }
4282                }
4283            }
4284        )*
4285    };
4286}
4287
4288impl_isolated_flag!(
4289    MarginAccountNewOrderIsIsolatedEnum,
4290    MarginAccountCancelOrderIsIsolatedEnum,
4291    QueryMarginAccountsOpenOrdersIsIsolatedEnum,
4292    QueryMarginAccountsOrderIsIsolatedEnum,
4293    QueryMarginAccountsTradeListIsIsolatedEnum,
4294    GetFutureHourlyInterestRateIsIsolatedEnum,
4295);
4296
4297/// Build the margin cancel-order params (pure; no I/O).
4298///
4299/// Cancels by exchange `orderId` when present and parseable as `i64`, otherwise falls back to the
4300/// originating client order id (`origClientOrderId`). `isIsolated` is config-driven. Factored out of
4301/// [`BinanceMargin::cancel_order`] so the id-vs-cid fallback branch and the `isIsolated` value are
4302/// unit-testable without a live REST call. Returns the builder's error message (stringified) on the
4303/// rare build failure (e.g. a malformed symbol).
4304fn build_cancel_order_params(
4305    symbol: String,
4306    id: Option<&OrderId>,
4307    cid: &ClientOrderId,
4308    is_isolated: bool,
4309) -> Result<MarginAccountCancelOrderParams, String> {
4310    let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
4311        MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
4312    );
4313
4314    match id {
4315        Some(order_id) => match order_id.0.parse::<i64>() {
4316            Ok(id) => builder = builder.order_id(id),
4317            Err(_) => {
4318                // exchange order id exists but isn't a valid i64 — fall back to the cid.
4319                error!(
4320                    order_id = %order_id.0,
4321                    "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
4322                );
4323                builder = builder.orig_client_order_id(cid.0.to_string());
4324            }
4325        },
4326        None => builder = builder.orig_client_order_id(cid.0.to_string()),
4327    }
4328
4329    builder.build().map_err(|e| e.to_string())
4330}
4331
4332/// Build the margin new-order params from a rustrade order request (pure; no I/O).
4333///
4334/// Factored out of [`BinanceMargin::open_order`] so the rustrade→Binance mapping (sideEffectType,
4335/// `isIsolated`, conditional `stopPrice`, `trailingDelta`, `autoRepayAtCancel` gating) is
4336/// unit-testable without a live REST call. `isIsolated` is config-driven via `is_isolated`
4337/// (`true` = isolated, `false` = cross).
4338#[allow(clippy::too_many_arguments)] // mirrors the flat request fields; grouping them into a
4339// struct would just shuffle the same data and obscure the 1:1 mapping to SDK params.
4340fn build_new_order_params(
4341    symbol: String,
4342    side: Side,
4343    price: Option<Decimal>,
4344    quantity: Decimal,
4345    kind: OrderKind,
4346    time_in_force: TimeInForce,
4347    new_client_order_id: String,
4348    side_effect: MarginSideEffect,
4349    is_isolated: bool,
4350) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
4351    let binance_side = match side {
4352        Side::Buy => MarginAccountNewOrderSideEnum::Buy,
4353        Side::Sell => MarginAccountNewOrderSideEnum::Sell,
4354    };
4355
4356    let (binance_type, binance_tif) =
4357        convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
4358
4359    // isIsolated is config-driven: "TRUE" for isolated, "FALSE" for cross.
4360    let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
4361        .quantity(quantity)
4362        .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
4363        .side_effect_type(side_effect.as_sdk_enum())
4364        .new_client_order_id(new_client_order_id)
4365        .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
4366
4367    // auto_repay_at_cancel only coheres under AutoBorrowRepay: a NoBorrow client never borrows,
4368    // so there is no loan to repay when an order is cancelled.
4369    if side_effect == MarginSideEffect::AutoBorrowRepay {
4370        builder = builder.auto_repay_at_cancel(true);
4371    }
4372
4373    if let Some(tif) = binance_tif {
4374        builder = builder.time_in_force(tif);
4375    }
4376
4377    // A trailing order's trailingDelta, fixed limit and activation price (as stopPrice), as on
4378    // spot.
4379    if let Some(trailing) = trailing_params(&kind, price).map_err(BuildOrderError::Trailing)? {
4380        builder = builder.trailing_delta(i64::from(trailing.trailing_delta));
4381        if let Some(limit) = trailing.price {
4382            builder = builder.price(limit);
4383        }
4384        if let Some(activation_price) = trailing.stop_price {
4385            builder = builder.stop_price(activation_price);
4386        }
4387    }
4388
4389    // The other kinds' price fields (mirror spot): LIMIT carries price; STOP/TAKE_PROFIT carry a
4390    // stop (trigger) price; the *_LIMIT variants carry both. No wildcard, so that a new OrderKind
4391    // fails to compile until it is handled here.
4392    match kind {
4393        OrderKind::Limit => {
4394            builder = builder.price(price);
4395        }
4396        OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
4397            builder = builder.stop_price(trigger_price);
4398        }
4399        OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
4400            builder = builder.price(price).stop_price(trigger_price);
4401        }
4402        // Trailing orders are set above. Market carries no price, and TrailingStopLimit is
4403        // rejected by convert_order_kind_tif_margin before this.
4404        OrderKind::TrailingStop { .. }
4405        | OrderKind::TrailingStopFixedLimit { .. }
4406        | OrderKind::Market
4407        | OrderKind::TrailingStopLimit { .. } => {}
4408    }
4409
4410    builder
4411        .build()
4412        .map_err(|e| BuildOrderError::Build(e.to_string()))
4413}
4414
4415/// Map a rustrade [`OrderKind`] + [`TimeInForce`] to the margin SDK's order `type`/`timeInForce`.
4416///
4417/// Reuses the shared [`classify_order_kind_tif`] decision logic, mapping only the venue-neutral
4418/// result onto margin's SDK output types — a [`MarginAccountNewOrderTypeEnum`] and a
4419/// [`MarginAccountNewOrderTimeInForceEnum`]. Mirrors spot's own `convert_order_kind_tif` adapter.
4420///
4421/// Returns `None` for unsupported combinations, as spot does: a `TrailingStop` maps to a
4422/// `STOP_LOSS` and a `TrailingStopFixedLimit` to a `STOP_LOSS_LIMIT` (with the `trailingDelta`
4423/// [`build_new_order_params`] adds), and `TrailingStopLimit` and Absolute trailing offsets are
4424/// unsupported.
4425fn convert_order_kind_tif_margin(
4426    kind: OrderKind,
4427    tif: TimeInForce,
4428) -> Option<(
4429    MarginAccountNewOrderTypeEnum,
4430    Option<MarginAccountNewOrderTimeInForceEnum>,
4431)> {
4432    let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
4433    let margin_type = match binance_type {
4434        BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
4435        BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
4436        BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
4437        BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
4438        BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
4439        BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
4440        BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
4441    };
4442    let margin_tif = binance_tif.map(|t| match t {
4443        BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
4444        BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
4445        BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
4446    });
4447    Some((margin_type, margin_tif))
4448}
4449
4450#[cfg(test)]
4451#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
4452mod tests {
4453    use super::*;
4454    use crate::client::binance::shared::convert_open_order;
4455    use crate::notice::{AccountNotice, NoticeKind};
4456    use crate::order::state::ActiveOrderState;
4457    use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
4458
4459    #[test]
4460    fn margin_side_effect_default_is_auto_borrow_repay() {
4461        assert_eq!(
4462            MarginSideEffect::default(),
4463            MarginSideEffect::AutoBorrowRepay
4464        );
4465    }
4466
4467    #[test]
4468    fn margin_side_effect_wire_strings() {
4469        assert_eq!(
4470            MarginSideEffect::AutoBorrowRepay.as_binance_str(),
4471            "AUTO_BORROW_REPAY"
4472        );
4473        assert_eq!(
4474            MarginSideEffect::NoBorrow.as_binance_str(),
4475            "NO_SIDE_EFFECT"
4476        );
4477    }
4478
4479    #[test]
4480    fn margin_side_effect_serde_round_trip() {
4481        // serde/config-file form is snake_case, distinct from the Binance wire string (as_binance_str).
4482        assert_eq!(
4483            serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
4484            r#""auto_borrow_repay""#
4485        );
4486        assert_eq!(
4487            serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
4488            r#""no_borrow""#
4489        );
4490        assert_eq!(
4491            serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
4492            MarginSideEffect::AutoBorrowRepay
4493        );
4494        assert_eq!(
4495            serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
4496            MarginSideEffect::NoBorrow
4497        );
4498    }
4499
4500    #[test]
4501    fn config_debug_redacts_secrets() {
4502        let config = BinanceMarginConfig::new(
4503            "my_api_key".to_string(),
4504            "my_secret_key".to_string(),
4505            false,
4506            false,
4507            Vec::new(),
4508            MarginSideEffect::default(),
4509        );
4510        let debug = format!("{config:?}");
4511        assert!(!debug.contains("my_api_key"));
4512        assert!(!debug.contains("my_secret_key"));
4513        assert!(debug.contains("***"));
4514    }
4515
4516    #[test]
4517    fn cross_margin_uses_common_case_defaults() {
4518        let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
4519        assert!(!config.testnet);
4520        assert!(!config.is_isolated);
4521        assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4522        assert_eq!(config.api_key(), "k");
4523    }
4524
4525    #[test]
4526    fn config_deserializes_with_defaults() {
4527        // is_isolated and side_effect default when omitted; testnet has no default and must be present.
4528        let config: BinanceMarginConfig =
4529            serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
4530                .expect("deserialize");
4531        assert!(!config.is_isolated);
4532        assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4533    }
4534
4535    #[test]
4536    fn config_deserializes_explicit_side_effect() {
4537        let config: BinanceMarginConfig = serde_json::from_str(
4538            r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
4539        )
4540        .expect("deserialize");
4541        assert!(config.is_isolated);
4542        assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
4543    }
4544
4545    #[test]
4546    fn isolated_ctor_sets_symbols_and_flag() {
4547        let symbols = vec![
4548            InstrumentNameExchange::new("BTCUSDT"),
4549            InstrumentNameExchange::new("ETHUSDT"),
4550        ];
4551        let config =
4552            BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
4553        assert!(config.is_isolated);
4554        assert_eq!(config.isolated_symbols, symbols);
4555        assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
4556    }
4557
4558    #[test]
4559    fn isolated_config_with_symbols_constructs() {
4560        // The construction gate only rejects isolated + *empty* symbols; a populated set is fine.
4561        let config = BinanceMarginConfig::isolated(
4562            "k".to_string(),
4563            "s".to_string(),
4564            vec![InstrumentNameExchange::new("BTCUSDT")],
4565        );
4566        let _client = BinanceMargin::new(config); // must not panic
4567    }
4568
4569    #[test]
4570    #[should_panic(expected = "non-empty isolated_symbols")]
4571    fn isolated_empty_symbols_panics_at_new() {
4572        // is_isolated = true with no isolated_symbols is an unusable config → fail-fast panic.
4573        let config = BinanceMarginConfig::new(
4574            "k".to_string(),
4575            "s".to_string(),
4576            false,
4577            true,
4578            Vec::new(),
4579            MarginSideEffect::default(),
4580        );
4581        let _ = BinanceMargin::new(config);
4582    }
4583
4584    #[test]
4585    #[should_panic(expected = "non-empty isolated_symbols")]
4586    fn isolated_empty_symbols_panics_via_deserialize_path() {
4587        // The gate must also cover the Deserialize-only path (a config file with is_isolated = true
4588        // and no isolated_symbols bypasses the named constructor).
4589        let config: BinanceMarginConfig = serde_json::from_str(
4590            r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
4591        )
4592        .expect("deserialize");
4593        assert!(config.isolated_symbols.is_empty());
4594        let _ = BinanceMargin::new(config);
4595    }
4596
4597    #[test]
4598    fn isolated_flag_maps_mode() {
4599        // One assertion per endpoint enum: the SDK generates a separate `isIsolated` type for
4600        // each, so an inverted arm in any single impl would otherwise go unnoticed.
4601        assert_eq!(
4602            MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
4603            "TRUE"
4604        );
4605        assert_eq!(
4606            MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
4607            "FALSE"
4608        );
4609        assert_eq!(
4610            MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
4611            "TRUE"
4612        );
4613        assert_eq!(
4614            MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
4615            "FALSE"
4616        );
4617        assert_eq!(
4618            QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
4619            "TRUE"
4620        );
4621        assert_eq!(
4622            QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
4623            "FALSE"
4624        );
4625        assert_eq!(
4626            QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
4627            "TRUE"
4628        );
4629        assert_eq!(
4630            QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
4631            "FALSE"
4632        );
4633    }
4634
4635    // -----------------------------------------------------------------------
4636    // Cancel param mapping (orderId-vs-cid fallback + config-driven isIsolated)
4637    // -----------------------------------------------------------------------
4638
4639    #[test]
4640    fn cancel_params_cross_with_parseable_order_id() {
4641        let p = build_cancel_order_params(
4642            "BTCUSDT".to_string(),
4643            Some(&OrderId::new("12345")),
4644            &ClientOrderId::new("cid-1"),
4645            false,
4646        )
4647        .expect("build");
4648        assert_eq!(p.symbol, "BTCUSDT");
4649        assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
4650        assert_eq!(p.order_id, Some(12345));
4651        assert_eq!(p.orig_client_order_id, None);
4652    }
4653
4654    #[test]
4655    fn cancel_params_isolated_is_config_driven() {
4656        let p = build_cancel_order_params(
4657            "BTCUSDT".to_string(),
4658            Some(&OrderId::new("12345")),
4659            &ClientOrderId::new("cid-1"),
4660            true,
4661        )
4662        .expect("build");
4663        assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
4664        assert_eq!(p.order_id, Some(12345));
4665    }
4666
4667    #[test]
4668    fn cancel_params_non_i64_order_id_falls_back_to_cid() {
4669        let p = build_cancel_order_params(
4670            "BTCUSDT".to_string(),
4671            Some(&OrderId::new("not-an-i64")),
4672            &ClientOrderId::new("cid-1"),
4673            false,
4674        )
4675        .expect("build");
4676        assert_eq!(p.order_id, None);
4677        assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4678    }
4679
4680    #[test]
4681    fn cancel_params_absent_order_id_uses_cid() {
4682        let p = build_cancel_order_params(
4683            "BTCUSDT".to_string(),
4684            None,
4685            &ClientOrderId::new("cid-1"),
4686            false,
4687        )
4688        .expect("build");
4689        assert_eq!(p.order_id, None);
4690        assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4691    }
4692
4693    // -----------------------------------------------------------------------
4694    // Order param mapping (17.4.x)
4695    // -----------------------------------------------------------------------
4696
4697    use crate::order::TrailingOffsetType;
4698
4699    /// Build params for the common case (cross), overriding only what a test cares about.
4700    fn params(
4701        side: Side,
4702        price: Option<Decimal>,
4703        kind: OrderKind,
4704        tif: TimeInForce,
4705        side_effect: MarginSideEffect,
4706    ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
4707        build_new_order_params(
4708            "BTCUSDT".to_string(),
4709            side,
4710            price,
4711            Decimal::from(2),
4712            kind,
4713            tif,
4714            "cid-1".to_string(),
4715            side_effect,
4716            false, // cross
4717        )
4718    }
4719
4720    fn gtc() -> TimeInForce {
4721        TimeInForce::GoodUntilCancelled { post_only: false }
4722    }
4723
4724    #[test]
4725    fn new_order_limit_maps_core_fields() {
4726        let p = params(
4727            Side::Buy,
4728            Some(Decimal::from(50_000)),
4729            OrderKind::Limit,
4730            gtc(),
4731            MarginSideEffect::AutoBorrowRepay,
4732        )
4733        .expect("build");
4734
4735        assert_eq!(p.symbol, "BTCUSDT");
4736        assert_eq!(p.side.as_str(), "BUY");
4737        assert_eq!(p.r#type.as_str(), "LIMIT");
4738        assert_eq!(p.quantity, Some(Decimal::from(2)));
4739        assert_eq!(p.price, Some(Decimal::from(50_000)));
4740        assert_eq!(p.stop_price, None);
4741        assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
4742        assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
4743        // FULL response so immediate fills come back in the order response.
4744        assert_eq!(
4745            p.new_order_resp_type.as_ref().map(|r| r.as_str()),
4746            Some("FULL")
4747        );
4748    }
4749
4750    #[test]
4751    fn new_order_is_isolated_is_config_driven() {
4752        // Cross (is_isolated = false) → "FALSE".
4753        let cross = build_new_order_params(
4754            "BTCUSDT".to_string(),
4755            Side::Sell,
4756            Some(Decimal::from(10)),
4757            Decimal::from(2),
4758            OrderKind::Limit,
4759            gtc(),
4760            "cid-1".to_string(),
4761            MarginSideEffect::AutoBorrowRepay,
4762            false,
4763        )
4764        .expect("build");
4765        assert_eq!(
4766            cross.is_isolated.as_ref().map(|i| i.as_str()),
4767            Some("FALSE")
4768        );
4769        assert_eq!(cross.side.as_str(), "SELL");
4770
4771        // Isolated (is_isolated = true) → "TRUE".
4772        let isolated = build_new_order_params(
4773            "BTCUSDT".to_string(),
4774            Side::Sell,
4775            Some(Decimal::from(10)),
4776            Decimal::from(2),
4777            OrderKind::Limit,
4778            gtc(),
4779            "cid-1".to_string(),
4780            MarginSideEffect::AutoBorrowRepay,
4781            true,
4782        )
4783        .expect("build");
4784        assert_eq!(
4785            isolated.is_isolated.as_ref().map(|i| i.as_str()),
4786            Some("TRUE")
4787        );
4788    }
4789
4790    #[test]
4791    fn side_effect_and_auto_repay_gating() {
4792        // AutoBorrowRepay → AUTO_BORROW_REPAY + auto_repay_at_cancel(true).
4793        let auto = params(
4794            Side::Buy,
4795            Some(Decimal::from(1)),
4796            OrderKind::Limit,
4797            gtc(),
4798            MarginSideEffect::AutoBorrowRepay,
4799        )
4800        .expect("build");
4801        assert_eq!(
4802            auto.side_effect_type.as_ref().map(|s| s.as_str()),
4803            Some("AUTO_BORROW_REPAY")
4804        );
4805        assert_eq!(auto.auto_repay_at_cancel, Some(true));
4806
4807        // NoBorrow → NO_SIDE_EFFECT and NO auto_repay_at_cancel (no loan to repay).
4808        let no_borrow = params(
4809            Side::Buy,
4810            Some(Decimal::from(1)),
4811            OrderKind::Limit,
4812            gtc(),
4813            MarginSideEffect::NoBorrow,
4814        )
4815        .expect("build");
4816        assert_eq!(
4817            no_borrow.side_effect_type.as_ref().map(|s| s.as_str()),
4818            Some("NO_SIDE_EFFECT")
4819        );
4820        assert_eq!(no_borrow.auto_repay_at_cancel, None);
4821    }
4822
4823    #[test]
4824    fn new_order_market_has_no_price_or_tif() {
4825        let p = params(
4826            Side::Buy,
4827            None,
4828            OrderKind::Market,
4829            gtc(),
4830            MarginSideEffect::AutoBorrowRepay,
4831        )
4832        .expect("build");
4833        assert_eq!(p.r#type.as_str(), "MARKET");
4834        assert_eq!(p.price, None);
4835        assert_eq!(p.stop_price, None);
4836        assert!(p.time_in_force.is_none());
4837    }
4838
4839    #[test]
4840    fn post_only_limit_maps_to_limit_maker() {
4841        let p = params(
4842            Side::Buy,
4843            Some(Decimal::from(10)),
4844            OrderKind::Limit,
4845            TimeInForce::GoodUntilCancelled { post_only: true },
4846            MarginSideEffect::AutoBorrowRepay,
4847        )
4848        .expect("build");
4849        assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
4850        // LIMIT_MAKER carries no timeInForce (post-only is the type, not a TIF).
4851        assert!(p.time_in_force.is_none());
4852    }
4853
4854    #[test]
4855    fn conditional_kinds_set_stop_price() {
4856        let trigger = Decimal::from(48_000);
4857
4858        let stop = params(
4859            Side::Sell,
4860            None,
4861            OrderKind::Stop {
4862                trigger_price: trigger,
4863            },
4864            gtc(),
4865            MarginSideEffect::AutoBorrowRepay,
4866        )
4867        .expect("build");
4868        assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
4869        assert_eq!(stop.stop_price, Some(trigger));
4870        assert_eq!(stop.price, None);
4871
4872        let stop_limit = params(
4873            Side::Sell,
4874            Some(Decimal::from(47_900)),
4875            OrderKind::StopLimit {
4876                trigger_price: trigger,
4877            },
4878            gtc(),
4879            MarginSideEffect::AutoBorrowRepay,
4880        )
4881        .expect("build");
4882        assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4883        assert_eq!(stop_limit.stop_price, Some(trigger));
4884        assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4885        assert_eq!(
4886            stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4887            Some("GTC")
4888        );
4889
4890        let take_profit = params(
4891            Side::Sell,
4892            None,
4893            OrderKind::TakeProfit {
4894                trigger_price: trigger,
4895            },
4896            gtc(),
4897            MarginSideEffect::AutoBorrowRepay,
4898        )
4899        .expect("build");
4900        assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
4901        assert_eq!(take_profit.stop_price, Some(trigger));
4902    }
4903
4904    /// A `TrailingStop` is a `STOP_LOSS` with `trailingDelta` in basis points and no `stopPrice`,
4905    /// as on spot.
4906    #[test]
4907    fn a_trailing_stop_maps_to_stop_loss_with_trailing_delta() {
4908        let cases = [
4909            (Decimal::from(25), TrailingOffsetType::BasisPoints, 25),
4910            (Decimal::new(15, 1), TrailingOffsetType::Percentage, 150),
4911        ];
4912        for (offset, offset_type, trailing_delta) in cases {
4913            let p = params(
4914                Side::Sell,
4915                None,
4916                OrderKind::TrailingStop {
4917                    offset,
4918                    offset_type,
4919                    activation_price: None,
4920                },
4921                gtc(),
4922                MarginSideEffect::NoBorrow,
4923            )
4924            .expect("build");
4925            assert_eq!(p.r#type.as_str(), "STOP_LOSS");
4926            assert_eq!(p.trailing_delta, Some(trailing_delta), "{offset_type:?}");
4927            assert_eq!(p.stop_price, None);
4928            assert_eq!(p.price, None);
4929            assert!(p.time_in_force.is_none());
4930        }
4931    }
4932
4933    /// An activation price goes as `stopPrice`. A `TrailingStopFixedLimit` is a
4934    /// `STOP_LOSS_LIMIT` with `trailingDelta`, its fixed limit as `price`, and a stop-limit's time
4935    /// in force.
4936    #[test]
4937    fn trailing_orders_carry_their_activation_price_and_fixed_limit() {
4938        let activation = Some(Decimal::from(50_000));
4939        let trailing_stop = params(
4940            Side::Sell,
4941            None,
4942            OrderKind::TrailingStop {
4943                offset: Decimal::from(25),
4944                offset_type: TrailingOffsetType::BasisPoints,
4945                activation_price: activation,
4946            },
4947            gtc(),
4948            MarginSideEffect::NoBorrow,
4949        )
4950        .expect("build");
4951        assert_eq!(trailing_stop.r#type.as_str(), "STOP_LOSS");
4952        assert_eq!(trailing_stop.trailing_delta, Some(25));
4953        assert_eq!(trailing_stop.stop_price, activation);
4954        assert_eq!(trailing_stop.price, None);
4955
4956        for activation_price in [None, activation] {
4957            let fixed_limit = params(
4958                Side::Sell,
4959                Some(Decimal::from(49_000)),
4960                OrderKind::TrailingStopFixedLimit {
4961                    offset: Decimal::new(15, 1),
4962                    offset_type: TrailingOffsetType::Percentage,
4963                    activation_price,
4964                },
4965                TimeInForce::ImmediateOrCancel,
4966                MarginSideEffect::NoBorrow,
4967            )
4968            .expect("build");
4969            assert_eq!(fixed_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4970            assert_eq!(fixed_limit.trailing_delta, Some(150));
4971            assert_eq!(fixed_limit.price, Some(Decimal::from(49_000)));
4972            assert_eq!(fixed_limit.stop_price, activation_price);
4973            assert_eq!(
4974                fixed_limit.time_in_force.as_ref().map(|t| t.as_str()),
4975                Some("IOC")
4976            );
4977        }
4978
4979        let absolute = params(
4980            Side::Sell,
4981            Some(Decimal::from(49_000)),
4982            OrderKind::TrailingStopFixedLimit {
4983                offset: Decimal::from(100),
4984                offset_type: TrailingOffsetType::Absolute,
4985                activation_price: None,
4986            },
4987            gtc(),
4988            MarginSideEffect::NoBorrow,
4989        );
4990        assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
4991
4992        // Refused unsent: a fixed limit without its price, and an activation price of zero.
4993        let no_limit = params(
4994            Side::Sell,
4995            None,
4996            OrderKind::TrailingStopFixedLimit {
4997                offset: Decimal::from(25),
4998                offset_type: TrailingOffsetType::BasisPoints,
4999                activation_price: None,
5000            },
5001            gtc(),
5002            MarginSideEffect::NoBorrow,
5003        );
5004        assert!(matches!(
5005            no_limit,
5006            Err(BuildOrderError::Trailing(TrailingOrderError::LimitPrice(
5007                None
5008            )))
5009        ));
5010        let zero_activation = params(
5011            Side::Sell,
5012            None,
5013            OrderKind::TrailingStop {
5014                offset: Decimal::from(25),
5015                offset_type: TrailingOffsetType::BasisPoints,
5016                activation_price: Some(Decimal::ZERO),
5017            },
5018            gtc(),
5019            MarginSideEffect::NoBorrow,
5020        );
5021        assert!(matches!(
5022            zero_activation,
5023            Err(BuildOrderError::Trailing(
5024                TrailingOrderError::NonPositiveActivation(_)
5025            ))
5026        ));
5027    }
5028
5029    #[test]
5030    fn absolute_trailing_offsets_and_trailing_stop_limits_are_unsupported() {
5031        let absolute = params(
5032            Side::Sell,
5033            None,
5034            OrderKind::TrailingStop {
5035                offset: Decimal::from(100),
5036                offset_type: TrailingOffsetType::Absolute,
5037                activation_price: None,
5038            },
5039            gtc(),
5040            MarginSideEffect::AutoBorrowRepay,
5041        );
5042        assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
5043
5044        let trailing_stop_limit = params(
5045            Side::Sell,
5046            Some(Decimal::from(100)),
5047            OrderKind::TrailingStopLimit {
5048                offset: Decimal::from(100),
5049                offset_type: TrailingOffsetType::BasisPoints,
5050                stop_price: Decimal::from(95),
5051                limit_offset: Decimal::from(10),
5052            },
5053            gtc(),
5054            MarginSideEffect::AutoBorrowRepay,
5055        );
5056        assert!(matches!(
5057            trailing_stop_limit,
5058            Err(BuildOrderError::Unsupported)
5059        ));
5060    }
5061
5062    /// An offset that is not a whole number of basis points is refused, not rounded.
5063    #[test]
5064    fn a_fractional_trailing_offset_is_refused() {
5065        let fractional = params(
5066            Side::Sell,
5067            None,
5068            OrderKind::TrailingStop {
5069                offset: Decimal::new(155, 3),
5070                offset_type: TrailingOffsetType::Percentage,
5071                activation_price: None,
5072            },
5073            gtc(),
5074            MarginSideEffect::AutoBorrowRepay,
5075        );
5076        assert!(matches!(
5077            fractional,
5078            Err(BuildOrderError::Trailing(TrailingOrderError::Delta(
5079                crate::client::binance::shared::TrailingDeltaError::Invalid(_)
5080            )))
5081        ));
5082    }
5083
5084    #[test]
5085    fn tif_variants_map_to_margin_enum() {
5086        for (tif, expected) in [
5087            (TimeInForce::ImmediateOrCancel, "IOC"),
5088            (TimeInForce::FillOrKill, "FOK"),
5089            (gtc(), "GTC"),
5090        ] {
5091            let p = params(
5092                Side::Buy,
5093                Some(Decimal::from(1)),
5094                OrderKind::Limit,
5095                tif,
5096                MarginSideEffect::AutoBorrowRepay,
5097            )
5098            .expect("build");
5099            assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
5100        }
5101    }
5102
5103    // -----------------------------------------------------------------------
5104    // Account query converters (17.5.x)
5105    // -----------------------------------------------------------------------
5106
5107    fn user_asset(
5108        asset: &str,
5109        free: &str,
5110        locked: &str,
5111        borrowed: &str,
5112        interest: &str,
5113    ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
5114        let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
5115        a.asset = Some(asset.to_string());
5116        a.free = Some(free.to_string());
5117        a.locked = Some(locked.to_string());
5118        a.borrowed = Some(borrowed.to_string());
5119        a.interest = Some(interest.to_string());
5120        a
5121    }
5122
5123    #[test]
5124    fn margin_balance_entry_maps_debt() {
5125        // free=10, locked=2 → total=12; borrowed=3, interest=0.5 → net_asset = total - borrowed = 9.
5126        let ab =
5127            convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
5128                .expect("convert");
5129        assert_eq!(ab.asset.name().as_str(), "USDT");
5130        assert_eq!(ab.balance.total, Decimal::from(12));
5131        assert_eq!(ab.balance.free, Decimal::from(10));
5132        assert!(
5133            ab.balance.margin.is_some(),
5134            "REST snapshot must populate margin"
5135        );
5136        // net_asset deducts borrowed principal (interest is tracked separately, not deducted here).
5137        assert_eq!(ab.balance.net_asset(), Decimal::from(9));
5138    }
5139
5140    #[test]
5141    fn margin_balance_short_is_negative_net() {
5142        // A short borrows more base than it holds: total=1, borrowed=5 → net_asset = -4.
5143        let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
5144            .expect("convert");
5145        assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
5146    }
5147
5148    #[test]
5149    fn margin_balance_missing_debt_defaults_to_zero() {
5150        // A userAsset with no borrowed/interest is a real no-debt position, not corrupt data:
5151        // still emit a margin Balance (carrying zero debt) so the REST snapshot always sets margin.
5152        let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
5153        a.asset = Some("ETH".to_string());
5154        a.free = Some("4".to_string());
5155        a.locked = Some("0".to_string());
5156        let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
5157        assert!(ab.balance.margin.is_some());
5158        assert_eq!(ab.balance.net_asset(), Decimal::from(4));
5159    }
5160
5161    #[test]
5162    fn margin_balance_missing_free_is_dropped() {
5163        // Missing free/locked is corrupt (not no-debt) → drop the asset rather than guess.
5164        let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
5165        a.asset = Some("USDT".to_string());
5166        a.locked = Some("0".to_string());
5167        assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
5168    }
5169
5170    #[test]
5171    fn margin_balance_filtering() {
5172        let assets = vec![
5173            user_asset("USDT", "10", "0", "0", "0"),
5174            user_asset("BTC", "1", "0", "0", "0"),
5175            user_asset("ETH", "5", "0", "0", "0"),
5176        ];
5177        // Empty filter → all assets.
5178        assert_eq!(
5179            filter_and_convert_margin_balances(assets.clone(), &[]).len(),
5180            3
5181        );
5182        // Subset filter → only requested assets.
5183        let filtered = filter_and_convert_margin_balances(
5184            assets,
5185            &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
5186        );
5187        assert_eq!(filtered.len(), 2);
5188        assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
5189    }
5190
5191    #[test]
5192    fn margin_balance_filtering_large_slice_uses_hashset_branch() {
5193        // >16 requested assets takes the HashSet path (vs the ≤16 linear scan) — exercise it.
5194        let assets = vec![
5195            user_asset("BTC", "1", "0", "0", "0"),
5196            user_asset("ETH", "5", "0", "0", "0"),
5197            user_asset("DOGE", "9", "0", "0", "0"), // present in account, not requested → dropped
5198        ];
5199        // 17 requested assets (A00..A16) forces the HashSet branch; BTC and ETH also requested.
5200        let mut requested: Vec<AssetNameExchange> = (0..17)
5201            .map(|i| AssetNameExchange::new(format!("A{i:02}")))
5202            .collect();
5203        requested.push(AssetNameExchange::new("BTC"));
5204        requested.push(AssetNameExchange::new("ETH"));
5205        assert!(
5206            requested.len() > 16,
5207            "must exceed the linear-scan threshold"
5208        );
5209
5210        let filtered = filter_and_convert_margin_balances(assets, &requested);
5211        assert_eq!(filtered.len(), 2);
5212        assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
5213    }
5214
5215    // -----------------------------------------------------------------------
5216    // Isolated account snapshot converters + query scoping
5217    // -----------------------------------------------------------------------
5218
5219    use binance_sdk::margin_trading::rest_api::{
5220        QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
5221        QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
5222    };
5223
5224    fn d(s: &str) -> Decimal {
5225        Decimal::from_str(s).unwrap()
5226    }
5227
5228    fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
5229        let mut b = IsoBase::new();
5230        b.asset = Some(asset.to_string());
5231        b.free = Some(free.to_string());
5232        b.locked = Some(locked.to_string());
5233        b.borrowed = Some(borrowed.to_string());
5234        b.interest = Some(interest.to_string());
5235        b
5236    }
5237
5238    fn iso_quote(
5239        asset: &str,
5240        free: &str,
5241        locked: &str,
5242        borrowed: &str,
5243        interest: &str,
5244    ) -> IsoQuote {
5245        let mut q = IsoQuote::new();
5246        q.asset = Some(asset.to_string());
5247        q.free = Some(free.to_string());
5248        q.locked = Some(locked.to_string());
5249        q.borrowed = Some(borrowed.to_string());
5250        q.interest = Some(interest.to_string());
5251        q
5252    }
5253
5254    fn iso_entry(
5255        symbol: &str,
5256        base: IsoBase,
5257        quote: IsoQuote,
5258        margin_level: Option<&str>,
5259        margin_ratio: Option<&str>,
5260        liquidate_price: Option<&str>,
5261    ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
5262        let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
5263        e.symbol = Some(symbol.to_string());
5264        e.base_asset = Some(Box::new(base));
5265        e.quote_asset = Some(Box::new(quote));
5266        e.margin_level = margin_level.map(str::to_string);
5267        e.margin_ratio = margin_ratio.map(str::to_string);
5268        e.liquidate_price = liquidate_price.map(str::to_string);
5269        e
5270    }
5271
5272    fn isolated_client(symbols: &[&str]) -> BinanceMargin {
5273        let config = BinanceMarginConfig::isolated(
5274            "k".to_string(),
5275            "s".to_string(),
5276            symbols
5277                .iter()
5278                .map(|s| InstrumentNameExchange::new(*s))
5279                .collect(),
5280        );
5281        BinanceMargin::new(config)
5282    }
5283
5284    #[test]
5285    fn isolated_assets_map_base_quote_and_risk() {
5286        // base: free 0.5 + locked 0.1 = total 0.6, borrowed 0.2 → net 0.4.
5287        // quote: free 1000 + locked 50 = total 1050, borrowed 300 → net 750.
5288        let entry = iso_entry(
5289            "BTCUSDT",
5290            iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
5291            iso_quote("USDT", "1000", "50", "300", "1.5"),
5292            Some("3.5"),
5293            Some("0.12"),
5294            Some("48000"),
5295        );
5296        let map = convert_isolated_margin_assets(vec![entry]);
5297        let state = map
5298            .get(&InstrumentNameExchange::new("BTCUSDT"))
5299            .expect("entry present");
5300
5301        assert_eq!(state.base.asset.name().as_str(), "BTC");
5302        assert_eq!(state.base.balance.total, d("0.6"));
5303        assert_eq!(state.base.balance.free, d("0.5"));
5304        assert_eq!(state.base.balance.net_asset(), d("0.4"));
5305
5306        assert_eq!(state.quote.asset.name().as_str(), "USDT");
5307        assert_eq!(state.quote.balance.total, d("1050"));
5308        assert_eq!(state.quote.balance.net_asset(), d("750"));
5309
5310        assert_eq!(state.risk.margin_level, Some(d("3.5")));
5311        assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
5312        assert_eq!(state.risk.liquidation_price, Some(d("48000")));
5313    }
5314
5315    #[test]
5316    fn isolated_base_short_is_negative_net() {
5317        // A short on the base side: total 0, borrowed 1.5 → net_asset = -1.5.
5318        let entry = iso_entry(
5319            "BTCUSDT",
5320            iso_base("BTC", "0", "0", "1.5", "0.001"),
5321            iso_quote("USDT", "100", "0", "0", "0"),
5322            None,
5323            None,
5324            None,
5325        );
5326        let map = convert_isolated_margin_assets(vec![entry]);
5327        let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
5328        assert_eq!(state.base.balance.net_asset(), d("-1.5"));
5329    }
5330
5331    #[test]
5332    fn isolated_missing_debt_defaults_zero_and_risk_optional() {
5333        // No borrowed/interest = real no-debt position → margin still populated (zero debt), not
5334        // dropped. Absent risk fields → None, but the snapshot is still produced.
5335        let mut base = IsoBase::new();
5336        base.asset = Some("BTC".to_string());
5337        base.free = Some("0.5".to_string());
5338        base.locked = Some("0".to_string());
5339        let mut quote = IsoQuote::new();
5340        quote.asset = Some("USDT".to_string());
5341        quote.free = Some("100".to_string());
5342        quote.locked = Some("0".to_string());
5343
5344        let map = convert_isolated_margin_assets(vec![iso_entry(
5345            "BTCUSDT", base, quote, None, None, None,
5346        )]);
5347        let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
5348        assert!(state.base.balance.margin.is_some());
5349        assert_eq!(state.base.balance.net_asset(), d("0.5"));
5350        assert_eq!(state.risk.margin_level, None);
5351        assert_eq!(state.risk.liquidation_price, None);
5352    }
5353
5354    #[test]
5355    fn isolated_unparseable_risk_is_none_but_entry_kept() {
5356        let entry = iso_entry(
5357            "BTCUSDT",
5358            iso_base("BTC", "1", "0", "0", "0"),
5359            iso_quote("USDT", "1", "0", "0", "0"),
5360            Some("not_a_number"),
5361            None,
5362            None,
5363        );
5364        let map = convert_isolated_margin_assets(vec![entry]);
5365        let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
5366        assert_eq!(state.risk.margin_level, None);
5367    }
5368
5369    #[test]
5370    fn isolated_missing_base_free_drops_entry() {
5371        // Missing free/locked is corrupt (not no-debt) → drop the whole pair entry.
5372        let mut base = IsoBase::new();
5373        base.asset = Some("BTC".to_string());
5374        base.locked = Some("0".to_string()); // no free
5375        let entry = iso_entry(
5376            "BTCUSDT",
5377            base,
5378            iso_quote("USDT", "100", "0", "0", "0"),
5379            None,
5380            None,
5381            None,
5382        );
5383        assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
5384    }
5385
5386    #[test]
5387    fn isolated_missing_symbol_drops_entry() {
5388        let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
5389        e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
5390        e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
5391        // no symbol set
5392        assert!(convert_isolated_margin_assets(vec![e]).is_empty());
5393    }
5394
5395    #[test]
5396    fn chunk_symbols_batches_by_five() {
5397        let syms: Vec<InstrumentNameExchange> = (0..12)
5398            .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
5399            .collect();
5400        let chunks = chunk_symbols(&syms);
5401        assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
5402        assert_eq!(chunks[0].split(',').count(), 5);
5403        assert_eq!(chunks[1].split(',').count(), 5);
5404        assert_eq!(chunks[2].split(',').count(), 2);
5405
5406        // ≤5 → a single chunk.
5407        let three: Vec<_> = (0..3)
5408            .map(|i| InstrumentNameExchange::new(format!("S{i}")))
5409            .collect();
5410        assert_eq!(chunk_symbols(&three).len(), 1);
5411
5412        // exactly 5 → a single chunk of 5.
5413        let five: Vec<_> = (0..5)
5414            .map(|i| InstrumentNameExchange::new(format!("S{i}")))
5415            .collect();
5416        let c = chunk_symbols(&five);
5417        assert_eq!(c.len(), 1);
5418        assert_eq!(c[0].split(',').count(), 5);
5419
5420        // empty → no chunks (never a no-symbol call).
5421        assert!(chunk_symbols(&[]).is_empty());
5422    }
5423
5424    #[test]
5425    fn effective_isolated_set_empty_returns_all_configured() {
5426        let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
5427        assert_eq!(
5428            client.effective_isolated_set(&[]),
5429            vec![
5430                InstrumentNameExchange::new("BTCUSDT"),
5431                InstrumentNameExchange::new("ETHUSDT"),
5432            ]
5433        );
5434    }
5435
5436    #[test]
5437    fn effective_isolated_set_intersects_requested() {
5438        let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
5439        assert_eq!(
5440            client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
5441            vec![InstrumentNameExchange::new("ETHUSDT")]
5442        );
5443    }
5444
5445    #[test]
5446    fn effective_isolated_set_skips_out_of_set() {
5447        let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
5448        // DOGEUSDT is not configured → skipped (warn), only BTCUSDT survives.
5449        assert_eq!(
5450            client.effective_isolated_set(&[
5451                InstrumentNameExchange::new("BTCUSDT"),
5452                InstrumentNameExchange::new("DOGEUSDT"),
5453            ]),
5454            vec![InstrumentNameExchange::new("BTCUSDT")]
5455        );
5456    }
5457
5458    #[tokio::test]
5459    async fn fetch_balances_isolated_returns_empty() {
5460        // Isolated balances are per-pair (on account_snapshot); the asset-keyed fetch returns empty
5461        // without a network call.
5462        let client = isolated_client(&["BTCUSDT"]);
5463        assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
5464    }
5465
5466    fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
5467        let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
5468        o.order_id = order_id;
5469        o.client_order_id = Some("cid-9".to_string());
5470        o.side = Some(side.to_string());
5471        o.price = Some("50000".to_string());
5472        o.orig_qty = Some("2".to_string());
5473        o.executed_qty = Some("0.5".to_string());
5474        o.r#type = Some("LIMIT".to_string());
5475        o.time_in_force = Some("GTC".to_string());
5476        o.time = Some(1_700_000_000_000);
5477        o.status = Some("PARTIALLY_FILLED".to_string());
5478        o
5479    }
5480
5481    #[test]
5482    fn margin_open_order_converts() {
5483        let inst = InstrumentNameExchange::new("BTCUSDT");
5484        let order = convert_open_order(
5485            &open_order(Some(42), "BUY"),
5486            ExchangeId::BinanceMargin,
5487            &inst,
5488        )
5489        .expect("convert");
5490        assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
5491        assert_eq!(
5492            order.state.id.assigned().map(|id| id.0.as_str()),
5493            Some("42")
5494        );
5495        assert_eq!(order.key.cid.0.as_str(), "cid-9");
5496        assert_eq!(order.side, Side::Buy);
5497        assert_eq!(order.price, Some(Decimal::from(50_000)));
5498        assert_eq!(order.quantity, Decimal::from(2));
5499        assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
5500        assert_eq!(order.kind, OrderKind::Limit);
5501    }
5502
5503    /// Margin shares the converter, so it shares the guard: a cancelled order that had partly
5504    /// filled must not come back as `Open`.
5505    #[test]
5506    fn margin_open_order_that_is_not_live_is_dropped() {
5507        let inst = InstrumentNameExchange::new("BTCUSDT");
5508        let mut o = open_order(Some(42), "BUY");
5509        o.status = Some("CANCELED".to_string());
5510        assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst).is_none());
5511    }
5512
5513    #[test]
5514    fn margin_open_order_missing_order_id_is_dropped() {
5515        let inst = InstrumentNameExchange::new("BTCUSDT");
5516        assert!(
5517            convert_open_order(&open_order(None, "BUY"), ExchangeId::BinanceMargin, &inst)
5518                .is_none()
5519        );
5520    }
5521
5522    /// A margin REST snapshot must be stamped with when the order last *changed*, not when it was
5523    /// created. The engine orders an order's states by `Open::time_exchange` and discards any
5524    /// snapshot older than the state it already tracks, so a creation stamp -- identical across
5525    /// every snapshot of one order -- is discarded the moment anything advances that order past
5526    /// creation. That is exactly the partially-filled order a reconciliation fetch exists to repair.
5527    #[test]
5528    fn margin_open_order_prefers_update_time_over_creation_time() {
5529        let inst = InstrumentNameExchange::new("BTCUSDT");
5530
5531        let mut updated = open_order(Some(42), "BUY");
5532        updated.update_time = Some(1_700_000_005_000);
5533        let order =
5534            convert_open_order(&updated, ExchangeId::BinanceMargin, &inst).expect("convert");
5535        assert_eq!(
5536            order.state.time_exchange,
5537            Utc.timestamp_millis_opt(1_700_000_005_000)
5538                .single()
5539                .unwrap(),
5540            "update_time must win over time"
5541        );
5542    }
5543
5544    /// Falls back to `time` when the venue omits `update_time`, rather than to `now` -- which would
5545    /// be worse than creation time, since it is not a venue-reported instant at all.
5546    #[test]
5547    fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
5548        let inst = InstrumentNameExchange::new("BTCUSDT");
5549
5550        let base = open_order(Some(42), "BUY");
5551        assert!(base.update_time.is_none(), "fixture must omit update_time");
5552        let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst).expect("convert");
5553        assert_eq!(
5554            order.state.time_exchange,
5555            Utc.timestamp_millis_opt(1_700_000_000_000)
5556                .single()
5557                .unwrap(),
5558            "absent update_time falls back to time"
5559        );
5560    }
5561
5562    #[test]
5563    fn margin_open_order_owned_symbol_recovers_instrument() {
5564        // The no-symbol "return all" path derives the instrument from each order's own `symbol`.
5565        let mut o = open_order(Some(42), "SELL");
5566        o.symbol = Some("ETHUSDT".to_string());
5567        let order =
5568            convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).expect("convert");
5569        assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
5570        assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
5571        assert_eq!(order.side, Side::Sell);
5572    }
5573
5574    #[test]
5575    fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
5576        // open_order() leaves `symbol` unset — the return-all path must drop it rather than guess.
5577        let o = open_order(Some(42), "BUY");
5578        assert!(o.symbol.is_none());
5579        assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).is_none());
5580    }
5581
5582    fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
5583        let mut t = QueryMarginAccountsTradeListResponseInner::new();
5584        t.id = id;
5585        t.order_id = Some(7);
5586        t.is_buyer = is_buyer;
5587        t.price = Some("48000".to_string());
5588        t.qty = Some("0.25".to_string());
5589        t.commission = Some("0.001".to_string());
5590        t.commission_asset = Some("BNB".to_string());
5591        t.time = Some(1_700_000_000_000);
5592        t
5593    }
5594
5595    #[test]
5596    fn margin_trade_converts() {
5597        let inst = InstrumentNameExchange::new("BTCUSDT");
5598        let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
5599        assert_eq!(tr.id.0.as_str(), "11");
5600        assert_eq!(tr.order_id.0.as_str(), "7");
5601        assert_eq!(tr.side, Side::Sell);
5602        assert_eq!(tr.price, Decimal::from(48_000));
5603        assert_eq!(tr.quantity, Decimal::new(25, 2));
5604        // Third-party fee asset (BNB) preserved verbatim; fees_quote left for the indexer.
5605        assert_eq!(tr.fees.asset.name().as_str(), "BNB");
5606        assert_eq!(tr.fees.fees, Decimal::new(1, 3));
5607    }
5608
5609    #[test]
5610    fn margin_trade_missing_fields_are_dropped() {
5611        let inst = InstrumentNameExchange::new("BTCUSDT");
5612        // Missing id and missing isBuyer both drop the trade rather than guess.
5613        assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
5614        assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
5615    }
5616
5617    // -- User-data stream: token renewal -------------------------------------------------------
5618
5619    #[test]
5620    fn token_renew_after_applies_margin_and_floor() {
5621        let now_ms = Utc::now().timestamp_millis();
5622        // ~24h out: renewal waits roughly (24h − 5min), bounded below 24h.
5623        let far = token_renew_after(now_ms + 86_400_000).as_secs();
5624        assert!(
5625            far > 80_000 && far <= 86_400,
5626            "expected ~24h-minus-margin, got {far}"
5627        );
5628        // Already expired → clamped to the floor, never zero.
5629        assert_eq!(
5630            token_renew_after(now_ms - 10_000).as_secs(),
5631            TOKEN_MIN_LIFETIME_SECS
5632        );
5633        // Within the safety margin → also the floor (renew now-ish, not at the deadline).
5634        assert_eq!(
5635            token_renew_after(now_ms + 60_000).as_secs(),
5636            TOKEN_MIN_LIFETIME_SECS
5637        );
5638    }
5639
5640    // -- User-data stream: frame discrimination + event conversion -----------------------------
5641
5642    /// Wrap an inner user-data `event` object in the WS-API push envelope observed live,
5643    /// serialized to the on-wire JSON string the converter parses.
5644    fn push(event: serde_json::Value) -> String {
5645        serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
5646    }
5647
5648    #[test]
5649    fn margin_ws_rpc_ack_yields_no_events() {
5650        // An RPC response (e.g. the subscribe ack) carries a top-level `id` and is not user data.
5651        let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
5652        let mut buf = Vec::new();
5653        assert!(!convert_margin_user_data_events(&frame, &mut buf));
5654        assert!(buf.is_empty());
5655    }
5656
5657    /// A bare event, with no envelope, or an envelope whose event has no `e` tag, is not a shape
5658    /// the subscription delivers: nothing is converted and the stream is not terminated.
5659    #[test]
5660    fn margin_ws_unrecognised_frames_yield_no_events() {
5661        for frame in [
5662            r#"{"e":"eventStreamTerminated","E":1}"#,
5663            r#"{"subscriptionId":1,"event":{"E":1}}"#,
5664        ] {
5665            let mut buf = Vec::new();
5666            assert!(!convert_margin_user_data_events(frame, &mut buf), "{frame}");
5667            assert!(buf.is_empty(), "{frame}");
5668        }
5669    }
5670
5671    /// A trailing order's report decodes with `d` as Binance documents it, a number, which
5672    /// binance-sdk's margin model declares a string, and as a string too.
5673    #[test]
5674    fn margin_ws_trailing_stop_report_decodes_as_a_trailing_stop() {
5675        for trailing_delta in [serde_json::json!(25), serde_json::json!("25")] {
5676            let frame = push(serde_json::json!({
5677                "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "STOP_LOSS",
5678                "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "cid-1", "t": -1_i64,
5679                "p": "0.00000000", "P": "0.00000000", "d": trailing_delta, "D": 1_700_000_000_000_i64,
5680                "q": "2", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
5681            }));
5682            let mut buf = Vec::new();
5683            assert!(!convert_margin_user_data_events(&frame, &mut buf));
5684            let [event] = buf.as_slice() else {
5685                panic!("one snapshot expected for {frame}, got {buf:?}");
5686            };
5687            let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
5688                panic!("expected an order snapshot, got {event:?}");
5689            };
5690            assert_eq!(
5691                snapshot.0.kind,
5692                OrderKind::TrailingStop {
5693                    offset: Decimal::from(25),
5694                    offset_type: TrailingOffsetType::BasisPoints,
5695                    activation_price: None,
5696                }
5697            );
5698            assert_eq!(snapshot.0.price, None);
5699        }
5700    }
5701
5702    /// A partial fill carries two facts, and both must reach the consumer: the execution print
5703    /// (`l`/`L`) and the order's new cumulative filled quantity (`z`).
5704    ///
5705    /// The execution alone never moves the order -- `Orders::update_from_fill` writes only
5706    /// `filled_quantity` and needs the order already tracked -- so without the paired snapshot a
5707    /// partially filled margin order reads as having nothing filled until REST reconciliation
5708    /// refreshes it.
5709    #[test]
5710    fn margin_ws_execution_report_trade_maps_to_trade_and_order_snapshot() {
5711        let frame = push(serde_json::json!({
5712            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5713            "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
5714            "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
5715            "p": "48000", "q": "2", "f": "GTC",
5716            "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
5717        }));
5718        let mut buf = Vec::new();
5719        assert!(!convert_margin_user_data_events(&frame, &mut buf));
5720        assert_eq!(
5721            buf.len(),
5722            2,
5723            "a fill must produce the execution and the order snapshot, got: {buf:?}"
5724        );
5725        assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
5726        match &buf[0].kind {
5727            AccountEventKind::Trade(t) => {
5728                assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
5729                assert_eq!(t.side, Side::Buy);
5730                assert_eq!(t.price, Decimal::from(48_000));
5731                assert_eq!(t.quantity, Decimal::new(5, 1));
5732                assert_eq!(t.fees.asset.name().as_str(), "BNB"); // 3rd-party fee asset preserved
5733                assert_eq!(t.fees.fees, Decimal::new(1, 3));
5734                assert_eq!(t.client_order_id, Some(ClientOrderId::new("cid-1")));
5735            }
5736            other => panic!("expected Trade, got {other:?}"),
5737        }
5738        assert_eq!(buf[1].exchange, ExchangeId::BinanceMargin);
5739        match &buf[1].kind {
5740            AccountEventKind::OrderSnapshot(snap) => {
5741                assert_eq!(
5742                    snap.0.quantity,
5743                    Decimal::from(2),
5744                    "the order's total quantity"
5745                );
5746                match &snap.0.state {
5747                    OrderState::Active(ActiveOrderState::Open(open)) => {
5748                        assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("12345"));
5749                        assert_eq!(
5750                            open.filled_quantity,
5751                            Decimal::new(5, 1),
5752                            "the snapshot must carry the order's cumulative filled quantity (z)"
5753                        );
5754                        assert_eq!(
5755                            open.time_exchange.timestamp_millis(),
5756                            1_700_000_000_000,
5757                            "stamped with the execution's transaction time (T), which is what \
5758                             gives the engine's staleness gate an ordering key"
5759                        );
5760                    }
5761                    other => panic!("expected an Open order, got {other:?}"),
5762                }
5763            }
5764            other => panic!("expected OrderSnapshot, got {other:?}"),
5765        }
5766    }
5767
5768    /// The mirror of the case above: a `TRADE` whose order status says the order is no longer
5769    /// working must emit the execution *without* a snapshot, or it resurrects an order the
5770    /// engine has already retired as a resting one.
5771    #[test]
5772    fn margin_trade_for_an_order_no_longer_working_emits_no_snapshot() {
5773        for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
5774            let frame = push(serde_json::json!({
5775                "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5776                "x": "TRADE", "X": status, "i": 12_345_i64, "c": "cid-1",
5777                "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
5778                "p": "48000", "q": "2", "f": "GTC", "T": 1_700_000_000_000_i64,
5779            }));
5780            let mut buf = Vec::new();
5781            assert!(!convert_margin_user_data_events(&frame, &mut buf));
5782            assert_eq!(
5783                buf.len(),
5784                1,
5785                "status {status} must produce the execution alone, got: {buf:?}"
5786            );
5787            assert!(
5788                matches!(buf[0].kind, AccountEventKind::Trade(_)),
5789                "the execution must still be reported for status {status}"
5790            );
5791        }
5792    }
5793
5794    #[test]
5795    fn margin_ws_new_report_maps_to_active_order_snapshot() {
5796        let frame = push(serde_json::json!({
5797            "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
5798            "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
5799            "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
5800        }));
5801        let mut buf = Vec::new();
5802        assert!(!convert_margin_user_data_events(&frame, &mut buf));
5803        assert_eq!(buf.len(), 1);
5804        match &buf[0].kind {
5805            AccountEventKind::OrderSnapshot(snap) => {
5806                assert_eq!(snap.0.side, Side::Sell);
5807                assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
5808                assert_eq!(snap.0.quantity, Decimal::from(1));
5809                assert!(
5810                    matches!(snap.0.state, OrderState::Active(_)),
5811                    "NEW should be an active (resting) order"
5812                );
5813            }
5814            other => panic!("expected OrderSnapshot, got {other:?}"),
5815        }
5816    }
5817
5818    #[test]
5819    fn margin_ws_canceled_report_maps_to_order_cancelled() {
5820        let frame = push(serde_json::json!({
5821            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5822            "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
5823            "z": "0", "T": 1_700_000_000_000_i64,
5824        }));
5825        let mut buf = Vec::new();
5826        assert!(!convert_margin_user_data_events(&frame, &mut buf));
5827        assert_eq!(buf.len(), 1);
5828        match &buf[0].kind {
5829            AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
5830            other => panic!("expected OrderCancelled, got {other:?}"),
5831        }
5832    }
5833
5834    /// A report without `c` gives its fill no client order id, rather than the exchange id the
5835    /// order snapshot falls back to.
5836    #[test]
5837    fn margin_ws_trade_without_a_client_order_id_reports_none() {
5838        let frame = push(serde_json::json!({
5839            "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "MARKET",
5840            "x": "TRADE", "X": "FILLED", "i": 12_345_i64,
5841            "t": 99_i64, "l": "1", "L": "48000", "z": "1",
5842            "q": "1", "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5843        }));
5844        let mut buf = Vec::new();
5845        assert!(!convert_margin_user_data_events(&frame, &mut buf));
5846        let AccountEventKind::Trade(trade) = &buf[0].kind else {
5847            panic!("expected Trade, got {:?}", buf[0].kind);
5848        };
5849        assert_eq!(trade.order_id.0.as_str(), "12345");
5850        assert_eq!(trade.client_order_id, None);
5851    }
5852
5853    #[test]
5854    fn margin_ws_execution_report_missing_symbol_is_dropped() {
5855        // Symbol absent → defensively dropped (observable warn), never a half-built event.
5856        let frame = push(serde_json::json!({
5857            "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
5858            "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
5859        }));
5860        let mut buf = Vec::new();
5861        assert!(!convert_margin_user_data_events(&frame, &mut buf));
5862        assert!(buf.is_empty());
5863    }
5864
5865    #[test]
5866    fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
5867        let frame = push(serde_json::json!({
5868            "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5869            "B": [
5870                { "a": "USDT", "f": "100.0", "l": "5.0" },
5871                { "a": "BTC", "f": "0.5", "l": "0" },
5872            ],
5873        }));
5874        let mut buf = Vec::new();
5875        assert!(!convert_margin_user_data_events(&frame, &mut buf));
5876        assert_eq!(buf.len(), 2);
5877        // WS partial → BalanceStreamUpdate (free/locked), never BalanceSnapshot (no debt clobber).
5878        for ev in &buf {
5879            assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
5880        }
5881    }
5882
5883    #[test]
5884    fn margin_ws_stream_terminated_signals_reconnect() {
5885        let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
5886        let mut buf = Vec::new();
5887        assert!(
5888            convert_margin_user_data_events(&frame, &mut buf),
5889            "eventStreamTerminated must signal reconnect"
5890        );
5891        assert!(buf.is_empty());
5892    }
5893
5894    #[test]
5895    fn margin_ws_liability_change_is_observable_only() {
5896        // USER_LIABILITY_CHANGE is logged, NOT forwarded as an account event and NOT signalled as
5897        // a reconnect: observable, not accumulated.
5898        let mut buf = Vec::new();
5899        assert!(!convert_margin_user_data_events(
5900            &push(documented_liability_change("USER_LIABILITY_CHANGE")),
5901            &mut buf
5902        ));
5903        assert!(buf.is_empty());
5904    }
5905
5906    /// The notice a converter pushed, which must be its only event.
5907    fn only_notice(buf: &[UnindexedAccountEvent]) -> &AccountNotice<InstrumentNameExchange> {
5908        match buf {
5909            [
5910                UnindexedAccountEvent {
5911                    exchange: ExchangeId::BinanceMargin,
5912                    kind: AccountEventKind::Notice(notice),
5913                },
5914            ] => notice,
5915            other => panic!("expected one BinanceMargin notice, got {other:?}"),
5916        }
5917    }
5918
5919    #[test]
5920    fn margin_ws_margin_level_change_is_forwarded_as_a_notice() {
5921        let mut buf = Vec::new();
5922        assert!(!convert_margin_user_data_events(
5923            &push(documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE")),
5924            &mut buf
5925        ));
5926        assert_eq!(
5927            only_notice(&buf),
5928            &AccountNotice::new(
5929                NoticeKind::MarginCall,
5930                None,
5931                Utc.timestamp_millis_opt(1_573_200_697_110).unwrap(),
5932                Some(Decimal::new(125, 2)),
5933                SmolStr::new("MARGIN_CALL"),
5934            ),
5935            "cross names no instrument"
5936        );
5937    }
5938
5939    #[test]
5940    fn margin_level_statuses_map_to_notice_kinds() {
5941        for (status, kind) in [
5942            ("MARGIN_CALL", NoticeKind::MarginCall),
5943            ("PRE_LIQUIDATION", NoticeKind::LiquidationWarning),
5944            ("FORCE_LIQUIDATION", NoticeKind::Liquidation),
5945            ("NORMAL", NoticeKind::MarginRestored),
5946            ("EXCESSIVE", NoticeKind::MarginRestored),
5947            ("SOME_FUTURE_STATUS", NoticeKind::Other),
5948        ] {
5949            let mut buf = Vec::new();
5950            let frame = push(serde_json::json!({
5951                "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": 1_700_000_000_000_i64, "l": "1.5",
5952                "s": status,
5953            }));
5954            convert_margin_user_data_events(&frame, &mut buf);
5955            let notice = only_notice(&buf);
5956            assert_eq!(notice.kind, kind, "{status}");
5957            assert_eq!(notice.status, status, "the raw status is kept");
5958        }
5959    }
5960
5961    #[test]
5962    fn a_margin_level_change_missing_parts_is_still_delivered_where_it_can_be() {
5963        // No readable margin level: delivered without one.
5964        let mut buf = Vec::new();
5965        convert_margin_user_data_events(
5966            &push(serde_json::json!({
5967                "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": 1_700_000_000_000_i64, "l": "n/a",
5968                "s": "FORCE_LIQUIDATION",
5969            })),
5970            &mut buf,
5971        );
5972        let notice = only_notice(&buf);
5973        assert_eq!(notice.kind, NoticeKind::Liquidation);
5974        assert_eq!(notice.margin_level, None);
5975
5976        // No time: stamped now.
5977        let before = Utc::now();
5978        let mut buf = Vec::new();
5979        convert_margin_user_data_events(
5980            &push(serde_json::json!({ "e": "MARGIN_LEVEL_STATUS_CHANGE", "s": "MARGIN_CALL" })),
5981            &mut buf,
5982        );
5983        assert!(only_notice(&buf).time_exchange >= before);
5984
5985        // No status: nothing to classify, dropped.
5986        let mut buf = Vec::new();
5987        convert_margin_user_data_events(
5988            &push(serde_json::json!({
5989                "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": 1_700_000_000_000_i64, "l": "1.5",
5990            })),
5991            &mut buf,
5992        );
5993        assert!(buf.is_empty());
5994    }
5995
5996    /// `USER_LIABILITY_CHANGE` as Binance documents it, under event name `e`.
5997    fn documented_liability_change(e: &str) -> serde_json::Value {
5998        serde_json::json!({
5999            "e": e, "E": 1_573_200_697_110_i64, "a": "BTC", "t": "BORROW",
6000            "p": "1.03453430", "i": "0",
6001        })
6002    }
6003
6004    /// `MARGIN_LEVEL_STATUS_CHANGE` as Binance documents it, under event name `e`.
6005    fn documented_margin_level_change(e: &str) -> serde_json::Value {
6006        serde_json::json!({
6007            "e": e, "E": 1_573_200_697_110_i64, "l": "1.25", "s": "MARGIN_CALL",
6008        })
6009    }
6010
6011    #[test]
6012    fn margin_ws_margin_specific_events_match_their_documented_names() {
6013        // Regression (#547): the arms matched only camelCase names, which Binance does not
6014        // document, so documented frames fell to the catch-all and were dropped at trace. Each
6015        // frame must reach its own arm and decode there: only that arm logs its message, and a
6016        // decode failure would log "undeserializable" instead.
6017        for (e, frame, expected) in [
6018            (
6019                "USER_LIABILITY_CHANGE",
6020                documented_liability_change("USER_LIABILITY_CHANGE"),
6021                "USER_LIABILITY_CHANGE (observable",
6022            ),
6023            (
6024                "MARGIN_LEVEL_STATUS_CHANGE",
6025                documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE"),
6026                "MARGIN_LEVEL_STATUS_CHANGE received",
6027            ),
6028            // Accepted until a live frame settles which spelling the userListenToken stream sends.
6029            (
6030                "userLiabilityChange",
6031                documented_liability_change("userLiabilityChange"),
6032                "USER_LIABILITY_CHANGE (observable",
6033            ),
6034            (
6035                "marginLevelStatusChange",
6036                documented_margin_level_change("marginLevelStatusChange"),
6037                "MARGIN_LEVEL_STATUS_CHANGE received",
6038            ),
6039        ] {
6040            let mut buf = Vec::new();
6041            let (terminated, messages) =
6042                log_messages(|| convert_margin_user_data_events(&push(frame), &mut buf));
6043            assert!(!terminated, "{e}");
6044            // Only the margin-level arm forwards anything: its notice.
6045            assert_eq!(
6046                buf.len(),
6047                usize::from(expected.starts_with("MARGIN")),
6048                "{e}"
6049            );
6050            assert!(
6051                messages.iter().any(|m| m.contains(expected)),
6052                "{e}: expected its own arm's log, got {messages:?}"
6053            );
6054            assert!(
6055                !messages.iter().any(|m| m.contains("undeserializable")
6056                    || m.contains("unhandled")
6057                    || m.contains("does not handle")),
6058                "{e}: {messages:?}"
6059            );
6060        }
6061    }
6062
6063    /// Run `f`, returning its value and the message of every log event it emitted on this thread,
6064    /// at any level. These arms report only through logs, so asserting on the return value alone
6065    /// could not tell which arm handled a frame.
6066    fn log_messages<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
6067        use std::sync::{Arc, Mutex};
6068        use tracing_subscriber::layer::SubscriberExt;
6069
6070        #[derive(Default)]
6071        struct Messages(Arc<Mutex<Vec<String>>>);
6072
6073        struct Message<'a>(&'a mut String);
6074
6075        impl tracing::field::Visit for Message<'_> {
6076            fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
6077                if field.name() == "message" {
6078                    *self.0 = format!("{value:?}");
6079                }
6080            }
6081        }
6082
6083        impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Messages {
6084            fn on_event(
6085                &self,
6086                event: &tracing::Event<'_>,
6087                _: tracing_subscriber::layer::Context<'_, S>,
6088            ) {
6089                let mut message = String::new();
6090                event.record(&mut Message(&mut message));
6091                self.0.lock().unwrap().push(message);
6092            }
6093        }
6094
6095        let layer = Messages::default();
6096        let messages = Arc::clone(&layer.0);
6097        // Thread-local, so other tests running in parallel are not captured.
6098        let value =
6099            tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), f);
6100        let messages = messages.lock().unwrap().clone();
6101        (value, messages)
6102    }
6103
6104    #[test]
6105    fn margin_ws_unhandled_event_types_are_ignored_without_teardown() {
6106        // listStatus is ignored knowingly; a type with no arm goes to log_unhandled_event. Neither
6107        // pushes events or signals a reconnect.
6108        for (e, expected) in [
6109            ("listStatus", "ignoring listStatus"),
6110            // First of its type, so the loud path: a warning naming the unhandled type.
6111            ("someFutureEvent", "of a type this client does not handle"),
6112        ] {
6113            let mut buf = Vec::new();
6114            let frame = push(serde_json::json!({ "e": e, "E": 1_700_000_000_000_i64 }));
6115            let (terminated, messages) =
6116                log_messages(|| convert_margin_user_data_events(&frame, &mut buf));
6117            assert!(!terminated, "{e}");
6118            assert!(buf.is_empty(), "{e}");
6119            assert!(
6120                messages.iter().any(|m| m.contains(expected)),
6121                "{e}: {messages:?}"
6122            );
6123        }
6124    }
6125
6126    // -- User-data stream: dedup -----------------------------------------------------------------
6127
6128    #[test]
6129    fn margin_trade_event_dedup_by_key() {
6130        // A recovered fill and its live WS counterpart share (instrument, trade_id, kind) → the
6131        // dedup cache must drop the second occurrence (mirrors the reconnect fill-recovery path).
6132        let frame = push(serde_json::json!({
6133            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6134            "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
6135            "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
6136            "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
6137        }));
6138        let mut buf = Vec::new();
6139        convert_margin_user_data_events(&frame, &mut buf);
6140        // Index rather than pop: a fill appends the execution *then* its order snapshot, and
6141        // popping would take the snapshot and silently test the wrong event's key.
6142        let event = buf.first().expect("a Trade event");
6143        assert!(
6144            matches!(event.kind, AccountEventKind::Trade(_)),
6145            "the execution is the first of the two events a fill produces"
6146        );
6147
6148        let cache = new_dedup_cache();
6149        // DedupKey isn't Clone; re-derive it from the same event (deterministic) for the 2nd check.
6150        let first = dedup_key_from_event(event).expect("Trade events have a dedup key");
6151        assert!(!is_duplicate(&cache, first), "first sighting is fresh");
6152        let second = dedup_key_from_event(event).expect("Trade events have a dedup key");
6153        assert!(
6154            is_duplicate(&cache, second),
6155            "second sighting is a duplicate"
6156        );
6157    }
6158
6159    /// Drive frames through the same two stages the live WebSocket callback does: convert, then
6160    /// apply the dedup gate. Returns what a consumer would actually receive.
6161    ///
6162    /// Every other user-data test in this file calls the converter directly. That is the wrong
6163    /// altitude to prove a fill reaches anyone: the converter can be perfectly correct while the
6164    /// gate downstream of it discards what the converter produced. Margin's gate is structurally
6165    /// identical to spot's, so it needs the same end-to-end coverage.
6166    fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
6167        let cache = new_dedup_cache();
6168        let mut delivered = Vec::new();
6169        let mut buf = Vec::new();
6170        for frame in frames {
6171            let _ = convert_margin_user_data_events(frame, &mut buf);
6172            for ev in buf.drain(..) {
6173                if let Some(key) = dedup_key_from_event(&ev)
6174                    && is_duplicate(&cache, key)
6175                {
6176                    continue;
6177                }
6178                delivered.push(ev);
6179            }
6180        }
6181        delivered
6182    }
6183
6184    fn ws_new(cumulative_filled: &str) -> String {
6185        push(serde_json::json!({
6186            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6187            "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "client-1",
6188            "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
6189            "T": 1_700_000_000_000_i64,
6190        }))
6191    }
6192
6193    fn ws_partial_fill(cumulative_filled: &str) -> String {
6194        push(serde_json::json!({
6195            "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6196            "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "client-1",
6197            "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
6198            "l": "1", "L": "100", "t": 555_i64, "n": "0.1", "N": "USDT",
6199            "T": 1_700_000_001_000_i64,
6200        }))
6201    }
6202
6203    fn open_filled_quantities(events: &[UnindexedAccountEvent]) -> Vec<Decimal> {
6204        events
6205            .iter()
6206            .filter_map(|ev| match &ev.kind {
6207                AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
6208                    OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
6209                    _ => None,
6210                },
6211                _ => None,
6212            })
6213            .collect()
6214    }
6215
6216    /// A fill's order snapshot must survive the dedup gate that sits between the converter and
6217    /// the consumer.
6218    ///
6219    /// The acknowledgement and the fill describe one order id, so a key built from the id alone
6220    /// would make the second look like a replay of the first. `Open::filled_quantity` would then
6221    /// never leave the `0` the acknowledgement carried -- the defect fixed in the converter,
6222    /// undone one stage later.
6223    #[test]
6224    fn margin_ws_fill_snapshot_survives_the_dedup_gate() {
6225        let delivered = drive_ws_pipeline(&[&ws_new("0"), &ws_partial_fill("1")]);
6226
6227        assert_eq!(
6228            delivered.len(),
6229            3,
6230            "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
6231        );
6232        assert_eq!(
6233            open_filled_quantities(&delivered),
6234            vec![Decimal::ZERO, Decimal::ONE],
6235            "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
6236        );
6237    }
6238
6239    /// The gate must still collapse a genuinely re-delivered frame, because a replayed
6240    /// acknowledgement for a retired order is re-inserted as a live resting order by the engine.
6241    #[test]
6242    fn margin_ws_replayed_frame_is_still_deduplicated() {
6243        let delivered = drive_ws_pipeline(&[
6244            &ws_new("0"),
6245            &ws_partial_fill("1"),
6246            &ws_new("0"),
6247            &ws_partial_fill("1"),
6248        ]);
6249
6250        assert_eq!(
6251            delivered.len(),
6252            3,
6253            "re-delivering both frames must add nothing, got: {delivered:?}"
6254        );
6255    }
6256
6257    /// An acknowledgement that already carries a partial fill, followed by the `TRADE` reporting
6258    /// that same fill, describes one order state twice -- so the gate collapses the second
6259    /// snapshot. That is correct, not a loss: the dedup key includes `filled_quantity`, the two
6260    /// snapshots agree on it, and the execution itself carries a distinct trade id and is
6261    /// delivered either way.
6262    #[test]
6263    fn margin_ws_an_ack_and_its_fill_reporting_one_state_deliver_that_state_once() {
6264        let delivered = drive_ws_pipeline(&[&ws_new("1"), &ws_partial_fill("1")]);
6265
6266        assert_eq!(
6267            delivered.len(),
6268            2,
6269            "the ack snapshot and the execution, with the fill's duplicate snapshot collapsed, \
6270             got: {delivered:?}"
6271        );
6272        assert!(
6273            delivered
6274                .iter()
6275                .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
6276            "the execution must reach the consumer regardless"
6277        );
6278        assert_eq!(
6279            open_filled_quantities(&delivered),
6280            vec![Decimal::ONE],
6281            "one snapshot, carrying the state both reports agree on"
6282        );
6283    }
6284
6285    // -- Isolated user-data stream: token params, base/quote map, balance routing ---------------
6286
6287    /// Wrap an inner `event` in the WS-API push envelope with an explicit `subscriptionId` (the
6288    /// isolated routing key for symbol-less `outboundAccountPosition` frames).
6289    fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
6290        serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
6291    }
6292
6293    /// Build the isolated balance handler (closes over the routing maps) for driving
6294    /// `convert_margin_user_data_events_with` in tests.
6295    fn isolated_handler(
6296        sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
6297        base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
6298    ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
6299        move |position, subscription_id, buf| {
6300            route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
6301        }
6302    }
6303
6304    fn btcusdt() -> InstrumentNameExchange {
6305        InstrumentNameExchange::new("BTCUSDT")
6306    }
6307
6308    fn token(expiration_time_ms: i64) -> UserListenToken {
6309        UserListenToken {
6310            token: "t".to_string(),
6311            expiration_time_ms,
6312        }
6313    }
6314
6315    #[test]
6316    fn listen_token_query_cross_vs_isolated() {
6317        // Cross → no params (the signed POST sends an empty query, as it did before isolated).
6318        assert!(build_listen_token_query(None).is_empty());
6319
6320        // Isolated → isIsolated=TRUE & symbol=<sym> (the per-symbol scoping).
6321        let q = build_listen_token_query(Some(&btcusdt()));
6322        assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
6323        assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
6324        assert_eq!(q.len(), 2);
6325    }
6326
6327    #[test]
6328    fn earliest_token_expiry_picks_min() {
6329        let tokens = vec![
6330            (btcusdt(), token(3_000)),
6331            (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
6332            (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
6333        ];
6334        assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
6335        // Empty set → sentinel (never happens for isolated, which always has ≥1 symbol).
6336        assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
6337    }
6338
6339    #[test]
6340    fn base_quote_map_extracts_base_and_quote() {
6341        let entries = vec![
6342            iso_entry(
6343                "BTCUSDT",
6344                iso_base("BTC", "0", "0", "0", "0"),
6345                iso_quote("USDT", "0", "0", "0", "0"),
6346                None,
6347                None,
6348                None,
6349            ),
6350            iso_entry(
6351                "ETHBTC",
6352                iso_base("ETH", "0", "0", "0", "0"),
6353                iso_quote("BTC", "0", "0", "0", "0"),
6354                None,
6355                None,
6356                None,
6357            ),
6358        ];
6359        let map = build_base_quote_map(&entries);
6360        assert_eq!(
6361            map.get(&btcusdt())
6362                .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
6363            Some(("BTC", "USDT"))
6364        );
6365        // ETHBTC: prefix/suffix string-matching would be ambiguous (BTC is the quote here, base
6366        // elsewhere) — the authoritative map resolves it unambiguously to base=ETH, quote=BTC.
6367        assert_eq!(
6368            map.get(&InstrumentNameExchange::new("ETHBTC"))
6369                .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
6370            Some(("ETH", "BTC"))
6371        );
6372    }
6373
6374    #[test]
6375    fn isolated_margin_level_change_names_its_subscriptions_pair_or_none() {
6376        let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
6377        let notice_on = |subscription_id: i64| {
6378            let mut buf = Vec::new();
6379            let frame = push_with_sub(
6380                subscription_id,
6381                documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE"),
6382            );
6383            assert!(!convert_margin_user_data_events_with(
6384                &frame,
6385                &mut buf,
6386                &mut cross_account_position_handler,
6387                &mut |id| isolated_notice_instrument(id, &sub_map),
6388            ));
6389            only_notice(&buf).instrument.clone()
6390        };
6391
6392        assert_eq!(notice_on(7), Some(btcusdt()));
6393        // Unmapped: still delivered, unattributed, rather than dropped like a balance frame.
6394        assert_eq!(notice_on(99), None);
6395        // No subscriptionId at all.
6396        assert_eq!(isolated_notice_instrument(None, &sub_map), None);
6397    }
6398
6399    #[test]
6400    fn the_two_streams_key_one_notice_alike() {
6401        // The same change, as each stream frames it, must collide in the shared cache.
6402        use crate::client::binance::margin_risk::{RiskFrame, convert_risk_frame};
6403
6404        let change = documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE");
6405        let mut from_user_data = Vec::new();
6406        convert_margin_user_data_events(&push(change.clone()), &mut from_user_data);
6407        let mut from_risk = Vec::new();
6408        let risk_frame = serde_json::json!({ "stream": "listen-key", "data": change });
6409        assert_eq!(
6410            convert_risk_frame(&risk_frame.to_string(), "listen-key", &mut from_risk),
6411            RiskFrame::Continue
6412        );
6413
6414        let dedup = new_dedup_cache();
6415        let key = |events: &[UnindexedAccountEvent]| dedup_key_from_event(&events[0]).unwrap();
6416        assert!(!is_duplicate(&dedup, key(&from_user_data)));
6417        assert!(
6418            is_duplicate(&dedup, key(&from_risk)),
6419            "the risk data stream's copy is dropped"
6420        );
6421    }
6422
6423    #[test]
6424    fn a_notice_from_both_streams_is_delivered_once() {
6425        // The user-data stream and the risk data stream key a notice alike (status + event time),
6426        // so the shared cache drops the second copy whichever arrives first. A later change with
6427        // the same status is a new notice.
6428        let dedup = new_dedup_cache();
6429        let notice = |time_ms: i64| {
6430            let mut buf = Vec::new();
6431            convert_margin_user_data_events(
6432                &push(serde_json::json!({
6433                    "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": time_ms, "l": "1.1",
6434                    "s": "MARGIN_CALL",
6435                })),
6436                &mut buf,
6437            );
6438            buf.pop().unwrap()
6439        };
6440        let is_new = |event: &UnindexedAccountEvent| {
6441            !is_duplicate(&dedup, dedup_key_from_event(event).unwrap())
6442        };
6443
6444        assert!(is_new(&notice(1_700_000_000_000)));
6445        assert!(!is_new(&notice(1_700_000_000_000)), "second copy dropped");
6446        assert!(is_new(&notice(1_700_000_060_000)), "a later notice is new");
6447    }
6448
6449    #[test]
6450    fn isolated_outbound_position_routes_to_instrument_balance_update() {
6451        let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
6452        let base_quote = Arc::new(HashMap::from([(
6453            btcusdt(),
6454            (
6455                AssetNameExchange::new("BTC"),
6456                AssetNameExchange::new("USDT"),
6457            ),
6458        )]));
6459        let mut handler = isolated_handler(sub_map, base_quote);
6460
6461        // Quote listed before base in `B` → assignment is by asset-name equality, not position.
6462        let frame = push_with_sub(
6463            7,
6464            serde_json::json!({
6465                "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
6466                "B": [
6467                    { "a": "USDT", "f": "1000.0", "l": "50.0" },
6468                    { "a": "BTC", "f": "0.5", "l": "0.1" },
6469                ],
6470            }),
6471        );
6472        let mut buf = Vec::new();
6473        assert!(!convert_margin_user_data_events_with(
6474            &frame,
6475            &mut buf,
6476            &mut handler,
6477            &mut cross_notice_instrument,
6478        ));
6479        assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
6480        match &buf[0].kind {
6481            AccountEventKind::InstrumentBalanceUpdate(ibu) => {
6482                assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
6483                assert_eq!(ibu.base.asset.name().as_str(), "BTC");
6484                assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
6485                assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
6486                assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
6487                assert_eq!(ibu.quote.update.free, Decimal::from(1000));
6488                assert_eq!(ibu.quote.update.locked, Decimal::from(50));
6489            }
6490            other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
6491        }
6492        // Crucially NOT the asset-keyed BalanceStreamUpdate (which would corrupt AssetStates).
6493        assert!(!matches!(
6494            buf[0].kind,
6495            AccountEventKind::BalanceStreamUpdate(_)
6496        ));
6497    }
6498
6499    #[test]
6500    fn isolated_outbound_position_unknown_subscription_dropped() {
6501        // subscriptionId 99 is not in the map → dropped (observable warn), nothing emitted.
6502        let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
6503        let base_quote = Arc::new(HashMap::from([(
6504            btcusdt(),
6505            (
6506                AssetNameExchange::new("BTC"),
6507                AssetNameExchange::new("USDT"),
6508            ),
6509        )]));
6510        let mut handler = isolated_handler(sub_map, base_quote);
6511
6512        let frame = push_with_sub(
6513            99,
6514            serde_json::json!({
6515                "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
6516                "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
6517            }),
6518        );
6519        let mut buf = Vec::new();
6520        assert!(!convert_margin_user_data_events_with(
6521            &frame,
6522            &mut buf,
6523            &mut handler,
6524            &mut cross_notice_instrument,
6525        ));
6526        assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
6527    }
6528
6529    #[test]
6530    fn isolated_outbound_position_missing_side_dropped() {
6531        // Only the base side present → no partial InstrumentBalanceUpdate (would fabricate a zero
6532        // quote, silently mis-reporting free/locked). Dropped with a warn instead.
6533        let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
6534        let base_quote = Arc::new(HashMap::from([(
6535            btcusdt(),
6536            (
6537                AssetNameExchange::new("BTC"),
6538                AssetNameExchange::new("USDT"),
6539            ),
6540        )]));
6541        let mut handler = isolated_handler(sub_map, base_quote);
6542
6543        let frame = push_with_sub(
6544            7,
6545            serde_json::json!({
6546                "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
6547                "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
6548            }),
6549        );
6550        let mut buf = Vec::new();
6551        assert!(!convert_margin_user_data_events_with(
6552            &frame,
6553            &mut buf,
6554            &mut handler,
6555            &mut cross_notice_instrument,
6556        ));
6557        assert!(buf.is_empty(), "missing quote side → frame dropped");
6558    }
6559
6560    #[test]
6561    fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
6562        // Fills self-identify via inner `s` and route regardless of the subscriptionId map (which
6563        // is empty here) — only balance frames need the map. Confirms fills are unaffected if the
6564        // subscriptionId→symbol routing ever fails live.
6565        let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
6566        let base_quote = Arc::new(HashMap::new());
6567        let mut handler = isolated_handler(sub_map, base_quote);
6568
6569        let frame = push_with_sub(
6570            42,
6571            serde_json::json!({
6572                "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6573                "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
6574                "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
6575                "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
6576            }),
6577        );
6578        let mut buf = Vec::new();
6579        assert!(!convert_margin_user_data_events_with(
6580            &frame,
6581            &mut buf,
6582            &mut handler,
6583            &mut cross_notice_instrument,
6584        ));
6585        assert_eq!(buf.len(), 2, "the execution and its order snapshot");
6586        match &buf[0].kind {
6587            AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
6588            other => panic!("expected Trade, got {other:?}"),
6589        }
6590        match &buf[1].kind {
6591            AccountEventKind::OrderSnapshot(snap) => {
6592                assert_eq!(snap.0.key.instrument.name().as_str(), "BTCUSDT");
6593            }
6594            other => panic!("expected OrderSnapshot, got {other:?}"),
6595        }
6596    }
6597
6598    /// A margin `myTrades` venue that enforces Binance's documented 24-hour rules: a
6599    /// `startTime`..`endTime` span of 24 hours or more is rejected, and a query by time without
6600    /// `fromId` returns at most 24 hours of trades. A `fromId` query returns trades from that id
6601    /// onwards whatever their time, and `orderId` narrows any query to one order. Pages hold at
6602    /// most `limit` trades, in ascending id.
6603    ///
6604    /// Two readings here are assumptions, not observed venue behaviour. A bare `startTime` is taken
6605    /// to mean the 24 hours from `startTime`; the pager under test never sends one, so only the
6606    /// previous pager's failure depends on it. `endTime` is inclusive unless `end_exclusive`, so a
6607    /// test can run under either reading.
6608    struct MarginMyTradesVenue {
6609        /// `(id, time_ms, order_id)`, in ascending id and time.
6610        trades: Vec<(i64, i64, i64)>,
6611        end_exclusive: bool,
6612    }
6613
6614    impl wiremock::Respond for MarginMyTradesVenue {
6615        fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
6616            const DAY_MS: i64 = 24 * 60 * 60 * 1000;
6617            let query: std::collections::HashMap<String, String> =
6618                request.url.query_pairs().into_owned().collect();
6619            let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
6620            let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
6621            let order = param("orderId");
6622
6623            let mut selected: Vec<&(i64, i64, i64)> = self
6624                .trades
6625                .iter()
6626                .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
6627                .collect();
6628            if let Some(from_id) = param("fromId") {
6629                selected.retain(|(id, _, _)| *id >= from_id);
6630            } else if order.is_none() {
6631                let start = param("startTime").expect("a query by time sends startTime");
6632                let end = match param("endTime") {
6633                    Some(end) if end - start >= DAY_MS => {
6634                        return wiremock::ResponseTemplate::new(400).set_body_json(
6635                            serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
6636                        );
6637                    }
6638                    Some(end) if self.end_exclusive => end - 1,
6639                    Some(end) => end,
6640                    None => start + DAY_MS - 1,
6641                };
6642                selected.retain(|(_, time, _)| (start..=end).contains(time));
6643            }
6644
6645            let page: Vec<serde_json::Value> = selected
6646                .into_iter()
6647                .take(limit)
6648                .map(|(id, time, order_id)| {
6649                    serde_json::json!({
6650                        "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
6651                        "price": "50000", "qty": "0.01", "commission": "0",
6652                        "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
6653                        "isBestMatch": true, "isIsolated": true,
6654                    })
6655                })
6656                .collect();
6657            wiremock::ResponseTemplate::new(200).set_body_json(page)
6658        }
6659    }
6660
6661    /// A walk by time from `start` with no end: every trade from `start` on.
6662    fn since(start: i64) -> MyTradesFrom {
6663        MyTradesFrom::Span {
6664            start,
6665            end: i64::MAX,
6666        }
6667    }
6668
6669    /// Run `paginate_margin_my_trades` from `from` against `venue`, returning the ids read and the
6670    /// query parameters of each request sent.
6671    async fn read_margin_trades(
6672        venue: MarginMyTradesVenue,
6673        from: MyTradesFrom,
6674    ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
6675        let server = wiremock::MockServer::start().await;
6676        wiremock::Mock::given(wiremock::matchers::method("GET"))
6677            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6678            .respond_with(venue)
6679            .mount(&server)
6680            .await;
6681        let rest = Arc::new(MarginTradingRestApi::from_config(
6682            ConfigurationRestApi::builder()
6683                .api_key("key")
6684                .api_secret("secret")
6685                .base_path(server.uri())
6686                .build()
6687                .unwrap(),
6688        ));
6689
6690        let read = paginate_margin_my_trades(
6691            &rest,
6692            &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6693            &InstrumentNameExchange::new("BTCUSDT"),
6694            from,
6695            true,
6696            RequestKind::Query,
6697        )
6698        .await
6699        .unwrap();
6700
6701        let requests = server
6702            .received_requests()
6703            .await
6704            .unwrap()
6705            .iter()
6706            .map(|request| request.url.query_pairs().into_owned().collect())
6707            .collect();
6708        (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
6709    }
6710
6711    /// Recovery reads each recovered fill's order once, in the account it trades, for the client
6712    /// order id `myTrades` lacks. An order Binance no longer knows leaves its fill without one.
6713    #[tokio::test]
6714    async fn recovered_margin_fills_carry_their_orders_client_order_ids() {
6715        recovered_margin_fills_carry_client_order_ids(true).await;
6716        recovered_margin_fills_carry_client_order_ids(false).await;
6717    }
6718
6719    async fn recovered_margin_fills_carry_client_order_ids(is_isolated: bool) {
6720        let disconnect = Utc::now() - chrono::Duration::minutes(10);
6721        let reconnect = Utc::now() - chrono::Duration::minutes(5);
6722        let fill_time = (disconnect + chrono::Duration::minutes(1)).timestamp_millis();
6723        let server = wiremock::MockServer::start().await;
6724        wiremock::Mock::given(wiremock::matchers::method("GET"))
6725            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6726            .respond_with(MarginMyTradesVenue {
6727                trades: vec![(1, fill_time, 1), (2, fill_time, 2)],
6728                end_exclusive: false,
6729            })
6730            .mount(&server)
6731            .await;
6732        wiremock::Mock::given(wiremock::matchers::method("GET"))
6733            .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6734            .and(wiremock::matchers::query_param("orderId", "1"))
6735            .and(wiremock::matchers::query_param(
6736                "isIsolated",
6737                if is_isolated { "TRUE" } else { "FALSE" },
6738            ))
6739            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6740                serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "repay-1"}),
6741            ))
6742            .mount(&server)
6743            .await;
6744        wiremock::Mock::given(wiremock::matchers::method("GET"))
6745            .and(wiremock::matchers::path("/sapi/v1/margin/order"))
6746            .and(wiremock::matchers::query_param("orderId", "2"))
6747            .respond_with(
6748                wiremock::ResponseTemplate::new(400).set_body_json(
6749                    serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
6750                ),
6751            )
6752            .mount(&server)
6753            .await;
6754        let rest = Arc::new(MarginTradingRestApi::from_config(
6755            ConfigurationRestApi::builder()
6756                .api_key("key")
6757                .api_secret("secret")
6758                .base_path(server.uri())
6759                .build()
6760                .unwrap(),
6761        ));
6762        let (tx, mut rx) = mpsc::unbounded_channel();
6763        let mut unrecovered = UnrecoveredFills::default();
6764        unrecovered.open(
6765            &[InstrumentNameExchange::new("BTCUSDT")],
6766            disconnect,
6767            reconnect,
6768        );
6769
6770        recover_margin_fills(
6771            &rest,
6772            &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6773            &mut unrecovered,
6774            &tx,
6775            &new_dedup_cache(),
6776            &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
6777            is_isolated,
6778        )
6779        .await;
6780
6781        let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
6782            .filter_map(|event| match event.kind {
6783                AccountEventKind::Trade(trade) => Some((
6784                    trade.id.0.to_string(),
6785                    trade.client_order_id,
6786                    trade.order_filled_quantity.is_some(),
6787                )),
6788                _ => None,
6789            })
6790            .collect();
6791        cids.sort();
6792        // The cumulatives from the walks, read first, are untouched by the reads after them.
6793        assert_eq!(
6794            cids,
6795            [
6796                ("1".to_string(), Some(ClientOrderId::new("repay-1")), true),
6797                ("2".to_string(), None, true),
6798            ],
6799            "is_isolated: {is_isolated}"
6800        );
6801        let order_reads = server
6802            .received_requests()
6803            .await
6804            .unwrap()
6805            .iter()
6806            .filter(|request| request.url.path() == "/sapi/v1/margin/order")
6807            .count();
6808        assert_eq!(order_reads, 2, "one read per order");
6809    }
6810
6811    /// A venue whose trades each belong to an order of the same id.
6812    fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
6813        MarginMyTradesVenue {
6814            trades: trades
6815                .into_iter()
6816                .map(|(id, time)| (id, time, id))
6817                .collect(),
6818            end_exclusive: false,
6819        }
6820    }
6821
6822    const HOUR_MS: i64 = 60 * 60 * 1000;
6823
6824    /// The only fill lies on day 3 of a five-day lookback. A bare `startTime` query sees only the
6825    /// first 24 hours, finds nothing and stops; the windows must reach day 3.
6826    #[tokio::test]
6827    async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
6828        let now = Utc::now().timestamp_millis();
6829        let (ids, _) = read_margin_trades(
6830            venue(vec![(7, now - 72 * HOUR_MS)]),
6831            since(now - 120 * HOUR_MS),
6832        )
6833        .await;
6834
6835        assert_eq!(ids, vec![7]);
6836    }
6837
6838    /// Fills in separate days, with empty days between and a short page in the first window
6839    /// that holds one: every fill is read once, in order.
6840    #[tokio::test]
6841    async fn margin_trades_read_fills_across_several_windows_once_each() {
6842        let now = Utc::now().timestamp_millis();
6843        let trades = vec![
6844            (1, now - 119 * HOUR_MS),
6845            (2, now - 72 * HOUR_MS),
6846            (3, now - HOUR_MS),
6847        ];
6848        let (ids, _) = read_margin_trades(venue(trades), since(now - 144 * HOUR_MS)).await;
6849
6850        assert_eq!(ids, vec![1, 2, 3]);
6851    }
6852
6853    /// A trade stamped exactly on the end of the first window is read once, whether the venue
6854    /// treats `endTime` as inclusive or exclusive.
6855    #[tokio::test]
6856    async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
6857        for end_exclusive in [false, true] {
6858            let now = Utc::now().timestamp_millis();
6859            let from = now - 48 * HOUR_MS;
6860            let venue = MarginMyTradesVenue {
6861                end_exclusive,
6862                ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
6863            };
6864            let (ids, _) = read_margin_trades(venue, since(from)).await;
6865
6866            assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
6867        }
6868    }
6869
6870    /// A window holding more than one page of fills pages on by `fromId`.
6871    #[tokio::test]
6872    async fn margin_trades_page_on_by_id_past_a_full_window() {
6873        let now = Utc::now().timestamp_millis();
6874        let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
6875        let trades = (1..=count)
6876            .map(|id| (id, now - 48 * HOUR_MS + id))
6877            .collect();
6878        let (ids, _) = read_margin_trades(venue(trades), since(now - 50 * HOUR_MS)).await;
6879
6880        assert_eq!(ids, (1..=count).collect::<Vec<_>>());
6881    }
6882
6883    /// A final window holding more than one page still pages on by `fromId`: the early stop
6884    /// after the final window applies only to a short page.
6885    #[tokio::test]
6886    async fn margin_trades_page_on_past_a_full_final_window() {
6887        let now = Utc::now().timestamp_millis();
6888        let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
6889        let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
6890        let (ids, requests) = read_margin_trades(venue(trades), since(now - 2 * HOUR_MS)).await;
6891
6892        assert_eq!(ids, (1..=count).collect::<Vec<_>>());
6893        assert_eq!(requests.len(), 2);
6894    }
6895
6896    /// A lookback with no fills costs one request per window, and every window is accepted
6897    /// (the venue rejects a span of 24 hours or more, which would fail the read).
6898    #[tokio::test]
6899    async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
6900        let now = Utc::now().timestamp_millis();
6901        let (ids, requests) =
6902            read_margin_trades(venue(Vec::new()), since(now - 72 * HOUR_MS)).await;
6903
6904        assert!(ids.is_empty());
6905        // Windows start at 0, 23, 46 and 69 hours into the 72-hour lookback.
6906        assert_eq!(requests.len(), 4);
6907    }
6908
6909    /// An ordinary reconnect gap holding a fill is one request: the only window reaches the
6910    /// call's start and reads short, so there is nothing to page on to.
6911    #[tokio::test]
6912    async fn margin_trades_read_a_short_gap_in_one_request() {
6913        let now = Utc::now().timestamp_millis();
6914        let (ids, requests) =
6915            read_margin_trades(venue(vec![(9, now - HOUR_MS)]), since(now - 2 * HOUR_MS)).await;
6916
6917        assert_eq!(ids, vec![9]);
6918        assert_eq!(requests.len(), 1);
6919    }
6920
6921    /// A walk from a time after the call's start has nothing to read and sends nothing.
6922    #[tokio::test]
6923    async fn margin_trades_from_the_future_send_no_request() {
6924        let now = Utc::now().timestamp_millis();
6925        let (ids, requests) = read_margin_trades(venue(Vec::new()), since(now + HOUR_MS)).await;
6926
6927        assert!(ids.is_empty());
6928        assert!(requests.is_empty());
6929    }
6930
6931    /// A walk by order pages that order's executions by `fromId`, never by time, and reads no
6932    /// other order's.
6933    #[tokio::test]
6934    async fn margin_trades_by_order_page_by_id_without_time_bounds() {
6935        let now = Utc::now().timestamp_millis();
6936        let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
6937        // Order 42's executions, interleaved with another order's.
6938        let trades = (1..=2 * count)
6939            .map(|id| {
6940                (
6941                    id,
6942                    now - 48 * HOUR_MS + id,
6943                    if id % 2 == 0 { 42 } else { 7 },
6944                )
6945            })
6946            .collect();
6947        let venue = MarginMyTradesVenue {
6948            trades,
6949            end_exclusive: false,
6950        };
6951        let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
6952
6953        assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
6954        assert_eq!(requests.len(), 2);
6955        assert!(
6956            requests.iter().all(
6957                |query| query.get("orderId").map(String::as_str) == Some("42")
6958                    && !query.contains_key("startTime")
6959                    && !query.contains_key("endTime")
6960            )
6961        );
6962    }
6963
6964    /// Run `paginate_margin_my_trades` against a venue that answers every request with
6965    /// `response`, returning the error it fails with.
6966    async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
6967        let server = wiremock::MockServer::start().await;
6968        wiremock::Mock::given(wiremock::matchers::method("GET"))
6969            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
6970            .respond_with(response)
6971            .mount(&server)
6972            .await;
6973        let rest = Arc::new(MarginTradingRestApi::from_config(
6974            ConfigurationRestApi::builder()
6975                .api_key("key")
6976                .api_secret("secret")
6977                .base_path(server.uri())
6978                .build()
6979                .unwrap(),
6980        ));
6981
6982        paginate_margin_my_trades(
6983            &rest,
6984            &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
6985            &InstrumentNameExchange::new("BTCUSDT"),
6986            since(Utc::now().timestamp_millis() - HOUR_MS),
6987            true,
6988            RequestKind::Query,
6989        )
6990        .await
6991        .unwrap_err()
6992    }
6993
6994    /// A venue rejection of a query is reported as such, with its code, and not as a transient
6995    /// connectivity failure a caller would retry forever.
6996    #[tokio::test]
6997    async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
6998        let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
6999            serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
7000        ))
7001        .await;
7002
7003        let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
7004            panic!("expected RequestRejected, got {err:?}");
7005        };
7006        assert!(msg.contains("-1127"), "code missing from {msg:?}");
7007        assert!(!err.is_transient());
7008    }
7009
7010    /// A 2xx body that does not fit the SDK's model fails identically on every retry.
7011    #[tokio::test]
7012    async fn margin_trades_report_an_undecodable_response_as_internal() {
7013        let err = margin_trades_error(
7014            wiremock::ResponseTemplate::new(200)
7015                .set_body_json(serde_json::json!({"unexpected": 1})),
7016        )
7017        .await;
7018
7019        assert!(
7020            matches!(err, UnindexedClientError::Internal(_)),
7021            "got {err:?}"
7022        );
7023        assert!(!err.is_transient());
7024    }
7025
7026    /// A `/sapi` response reporting `x-sapi-used-ip-weight-1m` at 90% of the documented IP limit
7027    /// pauses later queries but not orders; under it, or unreadable, nothing pauses.
7028    #[tokio::test]
7029    async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
7030        for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
7031            let server = wiremock::MockServer::start().await;
7032            wiremock::Mock::given(wiremock::matchers::method("GET"))
7033                .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
7034                .respond_with(
7035                    wiremock::ResponseTemplate::new(200)
7036                        .insert_header("x-sapi-used-ip-weight-1m", used)
7037                        .set_body_json(serde_json::json!([])),
7038                )
7039                .mount(&server)
7040                .await;
7041            let rest = Arc::new(MarginTradingRestApi::from_config(
7042                ConfigurationRestApi::builder()
7043                    .api_key("key")
7044                    .api_secret("secret")
7045                    .base_path(server.uri())
7046                    .build()
7047                    .unwrap(),
7048            ));
7049            let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7050
7051            fetch_margin_all_open_orders(rest, Arc::clone(&tracker), false)
7052                .await
7053                .unwrap();
7054
7055            let query_waits = tokio::time::timeout(
7056                Duration::from_millis(50),
7057                tracker.wait_if_blocked(RequestKind::Query),
7058            )
7059            .await
7060            .is_err();
7061            assert_eq!(query_waits, pauses, "used IP weight {used}");
7062            tokio::time::timeout(
7063                Duration::from_millis(50),
7064                tracker.wait_if_blocked(RequestKind::Order),
7065            )
7066            .await
7067            .expect("orders never wait on the pause");
7068        }
7069    }
7070
7071    /// Margin fill recovery does not wait for the pause near the weight limit either.
7072    #[tokio::test]
7073    async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
7074        let server = wiremock::MockServer::start().await;
7075        wiremock::Mock::given(wiremock::matchers::method("GET"))
7076            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7077            // One trade, so recovery also looks its order up: both reads run under the pause.
7078            .respond_with(
7079                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
7080                    "symbol": "BTCUSDT",
7081                    "id": 1,
7082                    "orderId": 7,
7083                    "price": "100",
7084                    "qty": "1",
7085                    "commission": "0",
7086                    "commissionAsset": "USDT",
7087                    "time": Utc::now().timestamp_millis(),
7088                    "isBuyer": true,
7089                    "isMaker": false,
7090                }])),
7091            )
7092            .expect(2)
7093            .mount(&server)
7094            .await;
7095        let rest = Arc::new(MarginTradingRestApi::from_config(
7096            ConfigurationRestApi::builder()
7097                .api_key("key")
7098                .api_secret("secret")
7099                .base_path(server.uri())
7100                .build()
7101                .unwrap(),
7102        ));
7103        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7104        tracker.throttle(Duration::from_secs(60));
7105        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
7106        let mut unrecovered = UnrecoveredFills::default();
7107        unrecovered.open(
7108            &[InstrumentNameExchange::new("BTCUSDT")],
7109            Utc::now() - chrono::Duration::minutes(1),
7110            Utc::now(),
7111        );
7112
7113        // Far under the 60 s pause set above, and ample for two local round trips.
7114        tokio::time::timeout(
7115            Duration::from_millis(800),
7116            recover_margin_fills(
7117                &rest,
7118                &tracker,
7119                &mut unrecovered,
7120                &tx,
7121                &new_dedup_cache(),
7122                &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7123                false,
7124            ),
7125        )
7126        .await
7127        .expect("recovery does not wait for the pause");
7128    }
7129
7130    /// A walk over a span reads only the trades inside it, stopping at the first page by id that
7131    /// reaches past its end.
7132    #[tokio::test]
7133    async fn margin_trades_read_over_a_span_stop_at_its_end() {
7134        let now = Utc::now().timestamp_millis();
7135        let trades = vec![
7136            (1, now - 50 * HOUR_MS),
7137            (2, now - 49 * HOUR_MS),
7138            (3, now - 10 * HOUR_MS),
7139        ];
7140        let (ids, _) = read_margin_trades(
7141            venue(trades),
7142            MyTradesFrom::Span {
7143                start: now - 60 * HOUR_MS,
7144                end: now - 20 * HOUR_MS,
7145            },
7146        )
7147        .await;
7148
7149        assert_eq!(ids, vec![1, 2]);
7150    }
7151
7152    /// A margin gap whose every read fails is given up, and reported on the stream with the last read's
7153    /// error.
7154    #[tokio::test]
7155    async fn a_margin_gap_whose_every_read_fails_is_reported_when_given_up() {
7156        let disconnect = Utc::now() - chrono::Duration::minutes(10);
7157        let reconnect = Utc::now() - chrono::Duration::minutes(5);
7158        let server = wiremock::MockServer::start().await;
7159        wiremock::Mock::given(wiremock::matchers::method("GET"))
7160            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7161            .respond_with(
7162                wiremock::ResponseTemplate::new(400)
7163                    .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
7164            )
7165            .mount(&server)
7166            .await;
7167        let rest = Arc::new(MarginTradingRestApi::from_config(
7168            ConfigurationRestApi::builder()
7169                .api_key("key")
7170                .api_secret("secret")
7171                .base_path(server.uri())
7172                .build()
7173                .expect("valid config"),
7174        ));
7175        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7176        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
7177        let dedup = new_dedup_cache();
7178        let btc = InstrumentNameExchange::new("BTCUSDT");
7179        let mut unrecovered = UnrecoveredFills::default();
7180        unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
7181
7182        for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
7183            recover_margin_fills(
7184                &rest,
7185                &tracker,
7186                &mut unrecovered,
7187                &tx,
7188                &dedup,
7189                &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7190                false,
7191            )
7192            .await;
7193            unrecovered.make_due();
7194        }
7195
7196        assert!(unrecovered.is_empty(), "the gap is given up");
7197        let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7198        let [event] = forwarded.as_slice() else {
7199            panic!("only the give-up is sent: {forwarded:?}");
7200        };
7201        let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
7202            panic!("expected FillRecoveryGaveUp, got {event:?}");
7203        };
7204        assert_eq!(event.exchange, ExchangeId::BinanceMargin);
7205        assert_eq!(
7206            gave_up.scope,
7207            crate::FillRecoveryScope::Instruments(vec![btc])
7208        );
7209        assert_eq!(
7210            gave_up.start.timestamp_millis(),
7211            disconnect.timestamp_millis()
7212        );
7213        assert_eq!(
7214            gave_up.attempts,
7215            crate::client::order_recovery::MAX_GAP_RETRIES + 1
7216        );
7217        assert!(
7218            matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
7219            "the last read's error: {:?}",
7220            gave_up.reason
7221        );
7222    }
7223
7224    /// A margin recovery that fails keeps its gap, closed at its own start; the retry reads only
7225    /// that span, forwards only the fill inside it, and clears it.
7226    #[tokio::test]
7227    async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
7228        let disconnect = Utc::now() - chrono::Duration::minutes(10);
7229        let reconnect = Utc::now() - chrono::Duration::minutes(5);
7230        let server = wiremock::MockServer::start().await;
7231        wiremock::Mock::given(wiremock::matchers::method("GET"))
7232            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7233            .respond_with(
7234                wiremock::ResponseTemplate::new(400)
7235                    .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
7236            )
7237            .up_to_n_times(1)
7238            .with_priority(1)
7239            .mount(&server)
7240            .await;
7241        wiremock::Mock::given(wiremock::matchers::method("GET"))
7242            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7243            .respond_with(venue(vec![
7244                (
7245                    1,
7246                    (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
7247                ),
7248                (
7249                    2,
7250                    (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
7251                ),
7252            ]))
7253            .mount(&server)
7254            .await;
7255        let rest = Arc::new(MarginTradingRestApi::from_config(
7256            ConfigurationRestApi::builder()
7257                .api_key("key")
7258                .api_secret("secret")
7259                .base_path(server.uri())
7260                .build()
7261                .unwrap(),
7262        ));
7263        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7264        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
7265        let dedup = new_dedup_cache();
7266        let mut unrecovered = UnrecoveredFills::default();
7267        unrecovered.open(
7268            &[InstrumentNameExchange::new("BTCUSDT")],
7269            disconnect,
7270            reconnect,
7271        );
7272
7273        recover_margin_fills(
7274            &rest,
7275            &tracker,
7276            &mut unrecovered,
7277            &tx,
7278            &dedup,
7279            &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7280            false,
7281        )
7282        .await;
7283        assert!(!unrecovered.is_empty(), "the failed gap is kept");
7284        assert!(rx.try_recv().is_err(), "nothing forwarded");
7285
7286        unrecovered.make_due();
7287        recover_margin_fills(
7288            &rest,
7289            &tracker,
7290            &mut unrecovered,
7291            &tx,
7292            &dedup,
7293            &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7294            false,
7295        )
7296        .await;
7297        assert!(unrecovered.is_empty(), "the retry recovered it");
7298        let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7299        assert_eq!(
7300            forwarded.len(),
7301            1,
7302            "only the fill inside the gap: {forwarded:?}"
7303        );
7304    }
7305
7306    /// When margin recovery times out, a gap whose read started has failed and waits for its retry, but
7307    /// one never started (eight are read at a time) is not charged and stays due.
7308    #[tokio::test]
7309    async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
7310        let server = wiremock::MockServer::start().await;
7311        wiremock::Mock::given(wiremock::matchers::method("GET"))
7312            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7313            .respond_with(
7314                wiremock::ResponseTemplate::new(200)
7315                    .set_body_json(serde_json::json!([]))
7316                    .set_delay(Duration::from_secs(3_600)),
7317            )
7318            .mount(&server)
7319            .await;
7320        let rest = Arc::new(MarginTradingRestApi::from_config(
7321            ConfigurationRestApi::builder()
7322                .api_key("key")
7323                .api_secret("secret")
7324                .base_path(server.uri())
7325                .timeout(3_600_000_u64)
7326                .retries(0_u32)
7327                .build()
7328                .unwrap(),
7329        ));
7330        let instruments: Vec<_> = (0..9)
7331            .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
7332            .collect();
7333        let mut unrecovered = UnrecoveredFills::default();
7334        unrecovered.open(
7335            &instruments,
7336            Utc::now() - chrono::Duration::minutes(10),
7337            Utc::now(),
7338        );
7339        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
7340        // Paused: once every read is waiting, the clock jumps to the recovery timeout.
7341        tokio::time::pause();
7342
7343        recover_margin_fills(
7344            &rest,
7345            &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7346            &mut unrecovered,
7347            &tx,
7348            &new_dedup_cache(),
7349            &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7350            false,
7351        )
7352        .await;
7353
7354        let now = tokio::time::Instant::now();
7355        assert_eq!(
7356            unrecovered.due(now).len(),
7357            1,
7358            "the gap never started stays due"
7359        );
7360        assert_eq!(
7361            unrecovered
7362                .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
7363                .len(),
7364            9,
7365            "the eight started wait for their first retry"
7366        );
7367    }
7368
7369    // -----------------------------------------------------------------------
7370    // How orders ended: fetch_ended_orders, recover_margin_ended_orders, the known-live feed
7371    // -----------------------------------------------------------------------
7372
7373    /// A margin order row for client order id `cid` in `status`: 1 of 2 filled, for 105 quote.
7374    fn margin_order_row(cid: &str, status: &str) -> serde_json::Value {
7375        serde_json::json!({
7376            "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
7377            "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
7378            "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "isIsolated": false,
7379            "time": 1_700_000_000_000_i64, "updateTime": 1_700_000_060_000_i64,
7380        })
7381    }
7382
7383    fn margin_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
7384        OrderKey::new(
7385            ExchangeId::BinanceMargin,
7386            InstrumentNameExchange::new(instrument),
7387            StrategyId::new("strategy"),
7388            ClientOrderId::new(cid),
7389        )
7390    }
7391
7392    #[test]
7393    fn a_margin_order_row_says_how_the_order_ended() {
7394        use crate::order::state::InactiveOrderState;
7395        use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOrderResponse;
7396        let key = margin_key("BTCUSDT", "a");
7397        let ended = |row: serde_json::Value| {
7398            let row: QueryMarginAccountsOrderResponse = serde_json::from_value(row).unwrap();
7399            convert_ended_order(&row, ExchangeId::BinanceMargin, &key, None)
7400                .map(|order| order.state)
7401        };
7402
7403        let mut filled = margin_order_row("a", "FILLED");
7404        filled["executedQty"] = "2".into();
7405        filled["cummulativeQuoteQty"] = "210".into();
7406        let Some(InactiveOrderState::FullyFilled(filled)) = ended(filled) else {
7407            panic!("FILLED is fully filled");
7408        };
7409        assert_eq!(filled.avg_price, Some(Decimal::from(105)));
7410        assert!(matches!(
7411            ended(margin_order_row("a", "CANCELED")),
7412            Some(InactiveOrderState::Cancelled(cancelled)) if cancelled.filled_quantity == Some(Decimal::ONE)
7413        ));
7414        assert_eq!(ended(margin_order_row("a", "NEW")), None);
7415
7416        // Binance's sign that it does not have the quote traded for a historical order.
7417        let mut unknown_quote = margin_order_row("a", "FILLED");
7418        unknown_quote["executedQty"] = "2".into();
7419        unknown_quote["cummulativeQuoteQty"] = "-1".into();
7420        let Some(InactiveOrderState::FullyFilled(filled)) = ended(unknown_quote) else {
7421            panic!("still fully filled");
7422        };
7423        assert_eq!(filled.avg_price, None, "no negative average price");
7424    }
7425
7426    fn margin_rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
7427        Arc::new(MarginTradingRestApi::from_config(
7428            ConfigurationRestApi::builder()
7429                .api_key("key")
7430                .api_secret("secret")
7431                .base_path(server.uri())
7432                .build()
7433                .unwrap(),
7434        ))
7435    }
7436
7437    /// A client of the cross account, or of the isolated BTCUSDT one, talking to `server`.
7438    pub(super) fn margin_client_at(
7439        server: &wiremock::MockServer,
7440        is_isolated: bool,
7441    ) -> BinanceMargin {
7442        let config = if is_isolated {
7443            BinanceMarginConfig::isolated(
7444                "key".into(),
7445                "secret".into(),
7446                vec![InstrumentNameExchange::new("BTCUSDT")],
7447            )
7448        } else {
7449            BinanceMarginConfig::cross_margin("key".into(), "secret".into())
7450        };
7451        let mut client = <BinanceMargin as ExecutionClient>::new(config);
7452        client.rest = margin_rest_at(server);
7453        client
7454    }
7455
7456    /// Answer `GET /sapi/v1/margin/order` for client order id `cid` with `response`.
7457    async fn mount_margin_order(
7458        server: &wiremock::MockServer,
7459        cid: &str,
7460        response: wiremock::ResponseTemplate,
7461    ) {
7462        wiremock::Mock::given(wiremock::matchers::method("GET"))
7463            .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7464            .and(wiremock::matchers::query_param("origClientOrderId", cid))
7465            .respond_with(response)
7466            .mount(server)
7467            .await;
7468    }
7469
7470    fn margin_venue_error(code: i64) -> wiremock::ResponseTemplate {
7471        wiremock::ResponseTemplate::new(400)
7472            .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
7473    }
7474
7475    fn margin_row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
7476        wiremock::ResponseTemplate::new(200).set_body_json(margin_order_row(cid, status))
7477    }
7478
7479    /// The path and `isIsolated` of every request `server` received.
7480    async fn margin_requests(server: &wiremock::MockServer) -> Vec<(String, Option<String>)> {
7481        server
7482            .received_requests()
7483            .await
7484            .unwrap()
7485            .iter()
7486            .map(|request| {
7487                let isolated = request
7488                    .url
7489                    .query_pairs()
7490                    .find(|(key, _)| key == "isIsolated")
7491                    .map(|(_, value)| value.into_owned());
7492                (request.url.path().to_owned(), isolated)
7493            })
7494            .collect()
7495    }
7496
7497    #[tokio::test]
7498    async fn margin_fetch_ended_orders_asks_the_configured_account_and_omits_the_rest() {
7499        use crate::order::state::InactiveOrderState;
7500        for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
7501            let server = wiremock::MockServer::start().await;
7502            mount_margin_order(&server, "filled", margin_row_response("filled", "FILLED")).await;
7503            mount_margin_order(&server, "live", margin_row_response("live", "NEW")).await;
7504            mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
7505            mount_margin_order(&server, "no-symbol", margin_venue_error(-1121)).await;
7506            let client = margin_client_at(&server, is_isolated);
7507            let keys = ["filled", "live", "unknown"].map(|cid| margin_key("BTCUSDT", cid));
7508
7509            let ended = client
7510                .fetch_ended_orders(
7511                    &[keys.to_vec(), vec![margin_key("NOSUCH", "no-symbol")]].concat(),
7512                )
7513                .await
7514                .unwrap();
7515
7516            let [order] = ended.as_slice() else {
7517                panic!("one order ended: {ended:?}");
7518            };
7519            assert_eq!(order.key, keys[0], "the strategy asked survives");
7520            assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
7521            let requests = margin_requests(&server).await;
7522            assert_eq!(requests.len(), 4);
7523            for (path, isolated) in requests {
7524                assert_eq!(path, "/sapi/v1/margin/order");
7525                assert_eq!(
7526                    isolated.as_deref(),
7527                    Some(flag),
7528                    "is_isolated = {is_isolated}"
7529                );
7530            }
7531        }
7532    }
7533
7534    #[tokio::test]
7535    async fn margin_fetch_ended_orders_fails_when_any_lookup_fails() {
7536        let server = wiremock::MockServer::start().await;
7537        mount_margin_order(
7538            &server,
7539            "cancelled",
7540            margin_row_response("cancelled", "CANCELED"),
7541        )
7542        .await;
7543        mount_margin_order(&server, "refused", margin_venue_error(-1100)).await;
7544        let client = margin_client_at(&server, false);
7545
7546        let result = client
7547            .fetch_ended_orders(&[
7548                margin_key("BTCUSDT", "cancelled"),
7549                margin_key("BTCUSDT", "refused"),
7550            ])
7551            .await;
7552
7553        assert!(
7554            matches!(
7555                result,
7556                Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
7557            ),
7558            "{result:?}"
7559        );
7560    }
7561
7562    /// `live`, `gone` and `unknown`, all held as live on BTCUSDT.
7563    fn held_margin_orders() -> SharedKnownLiveOrders {
7564        let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7565        for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
7566            known.lock().live(
7567                &margin_key("BTCUSDT", cid),
7568                Decimal::TWO,
7569                OrderKind::Limit,
7570                &Open::new(
7571                    VenueOrderId::Assigned(OrderId::new(n.to_string())),
7572                    Utc::now(),
7573                    Decimal::ZERO,
7574                ),
7575            );
7576        }
7577        known
7578    }
7579
7580    /// A margin trailing stop's row cannot say that it trailed (#541), and carries no positive
7581    /// `stopPrice` when it was placed without an activation price, yet how it ended is still
7582    /// reported, with the kind the client holds it with: by a reconnect's check and by
7583    /// `fetch_ended_orders`.
7584    #[tokio::test]
7585    async fn an_ended_margin_trailing_stop_is_reported_with_the_kind_it_was_held_with() {
7586        let server = wiremock::MockServer::start().await;
7587        wiremock::Mock::given(wiremock::matchers::method("GET"))
7588            .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
7589            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
7590            .mount(&server)
7591            .await;
7592        let mut row = margin_order_row("trailing", "CANCELED");
7593        row["type"] = "STOP_LOSS".into();
7594        row["price"] = "0.00000000".into();
7595        row["stopPrice"] = "0.00000000".into();
7596        mount_margin_order(
7597            &server,
7598            "trailing",
7599            wiremock::ResponseTemplate::new(200).set_body_json(row),
7600        )
7601        .await;
7602        let trailing_stop = OrderKind::TrailingStop {
7603            offset: Decimal::from(25),
7604            offset_type: TrailingOffsetType::BasisPoints,
7605            activation_price: None,
7606        };
7607        let hold = |known: &SharedKnownLiveOrders| {
7608            known.lock().live(
7609                &margin_key("BTCUSDT", "trailing"),
7610                Decimal::TWO,
7611                trailing_stop,
7612                &Open::new(
7613                    VenueOrderId::Assigned(OrderId::new("7")),
7614                    Utc::now(),
7615                    Decimal::ZERO,
7616                ),
7617            );
7618        };
7619
7620        let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7621        hold(&known);
7622        let mut unchecked = UncheckedOrders::default();
7623        unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
7624        let (tx, mut rx) = mpsc::unbounded_channel();
7625        recover_margin_ended_orders(
7626            &margin_rest_at(&server),
7627            &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7628            &known,
7629            &mut unchecked,
7630            &UnrecoveredFills::default(),
7631            &tx,
7632            false,
7633        )
7634        .await;
7635        let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7636        let [event] = sent.as_slice() else {
7637            panic!("the trailing stop ended: {sent:?}");
7638        };
7639        let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
7640            panic!("an order snapshot: {event:?}");
7641        };
7642        assert_eq!(snapshot.0.kind, trailing_stop);
7643        assert!(matches!(
7644            snapshot.0.state,
7645            OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
7646        ));
7647        assert!(!known.lock().contains(&ClientOrderId::new("trailing")));
7648
7649        let client = margin_client_at(&server, false);
7650        hold(&client.known_live);
7651        let ended = client
7652            .fetch_ended_orders(&[margin_key("BTCUSDT", "trailing")])
7653            .await
7654            .expect("lookup");
7655        let [order] = ended.as_slice() else {
7656            panic!("the trailing stop ended: {ended:?}");
7657        };
7658        assert_eq!(order.kind, trailing_stop);
7659    }
7660
7661    #[tokio::test]
7662    async fn a_margin_reconnect_reports_how_an_unlisted_order_ended_in_the_configured_account() {
7663        for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
7664            let server = wiremock::MockServer::start().await;
7665            wiremock::Mock::given(wiremock::matchers::method("GET"))
7666                .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
7667                .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
7668                .respond_with(
7669                    wiremock::ResponseTemplate::new(200)
7670                        .set_body_json(serde_json::json!([margin_order_row("live", "NEW")])),
7671                )
7672                .mount(&server)
7673                .await;
7674            mount_margin_order(&server, "gone", margin_row_response("gone", "CANCELED")).await;
7675            mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
7676            let known = held_margin_orders();
7677            let mut unchecked = UncheckedOrders::default();
7678            unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
7679            let (tx, mut rx) = mpsc::unbounded_channel();
7680
7681            recover_margin_ended_orders(
7682                &margin_rest_at(&server),
7683                &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7684                &known,
7685                &mut unchecked,
7686                &UnrecoveredFills::default(),
7687                &tx,
7688                is_isolated,
7689            )
7690            .await;
7691
7692            let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7693            let [event] = sent.as_slice() else {
7694                panic!("one order ended: {sent:?}");
7695            };
7696            assert_eq!(event.exchange, ExchangeId::BinanceMargin);
7697            let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
7698                panic!("an order snapshot: {event:?}");
7699            };
7700            assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
7701            assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
7702            assert!(matches!(
7703                snapshot.0.state,
7704                OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
7705            ));
7706            let mut requests = margin_requests(&server).await;
7707            requests.sort();
7708            let expected = |path: &str| (path.to_owned(), Some(flag.to_owned()));
7709            assert_eq!(
7710                requests,
7711                [
7712                    expected("/sapi/v1/margin/openOrders"),
7713                    expected("/sapi/v1/margin/order"),
7714                    expected("/sapi/v1/margin/order"),
7715                ],
7716                "one listing, and a lookup of each unlisted order only"
7717            );
7718            let known = known.lock();
7719            assert!(known.contains(&ClientOrderId::new("live")));
7720            assert!(!known.contains(&ClientOrderId::new("gone")));
7721            assert!(
7722                !known.contains(&ClientOrderId::new("unknown")),
7723                "asking again cannot help"
7724            );
7725            assert!(unchecked.is_empty());
7726        }
7727    }
7728
7729    fn margin_open_request<'a>(
7730        instrument: &'a InstrumentNameExchange,
7731        cid: &str,
7732    ) -> OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange> {
7733        OrderRequestOpen {
7734            key: OrderKey::new(
7735                ExchangeId::BinanceMargin,
7736                instrument,
7737                StrategyId::new("strategy"),
7738                ClientOrderId::new(cid),
7739            ),
7740            state: crate::order::request::RequestOpen {
7741                side: Side::Buy,
7742                price: Some(Decimal::from(100)),
7743                quantity: Decimal::TWO,
7744                kind: OrderKind::Limit,
7745                time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
7746                position_id: None,
7747                reduce_only: false,
7748                market: None,
7749            },
7750        }
7751    }
7752
7753    /// A margin `TrailingStop` reaches Binance as a `STOP_LOSS` with `trailingDelta`, and an
7754    /// offset Binance cannot take is refused without a request.
7755    #[tokio::test]
7756    async fn a_margin_trailing_stop_is_sent_with_trailing_delta() {
7757        let server = wiremock::MockServer::start().await;
7758        let sent_as_trailing_stop = |request: &wiremock::Request| {
7759            // The SDK may send the params in the query string or the form body; read both.
7760            let mut body_as_query = request.url.clone();
7761            body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
7762            let params: Vec<(String, String)> = request
7763                .url
7764                .query_pairs()
7765                .chain(body_as_query.query_pairs())
7766                .map(|(k, v)| (k.into_owned(), v.into_owned()))
7767                .collect();
7768            let has = |key: &str, value: &str| params.iter().any(|(k, v)| k == key && v == value);
7769            has("type", "STOP_LOSS")
7770                && has("trailingDelta", "25")
7771                && has("sideEffectType", "AUTO_BORROW_REPAY")
7772                && !params.iter().any(|(k, _)| k == "stopPrice" || k == "price")
7773        };
7774        wiremock::Mock::given(wiremock::matchers::method("POST"))
7775            .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7776            .and(sent_as_trailing_stop)
7777            .respond_with(
7778                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
7779                    "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "trailing",
7780                    "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "2",
7781                    "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
7782                    "timeInForce": "GTC", "type": "STOP_LOSS", "side": "SELL",
7783                })),
7784            )
7785            .expect(1)
7786            .mount(&server)
7787            .await;
7788        let client = margin_client_at(&server, false);
7789        let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7790        let trailing_request = |cid, offset| {
7791            let mut request = margin_open_request(&btcusdt, cid);
7792            request.state.side = Side::Sell;
7793            request.state.price = None;
7794            request.state.kind = OrderKind::TrailingStop {
7795                offset,
7796                offset_type: TrailingOffsetType::BasisPoints,
7797                activation_price: None,
7798            };
7799            request
7800        };
7801
7802        let placed = client
7803            .open_order(trailing_request("trailing", Decimal::from(25)))
7804            .await;
7805        assert!(
7806            matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
7807            "{placed:?}"
7808        );
7809
7810        let refused = client
7811            .open_order(trailing_request("fractional", Decimal::new(25, 1)))
7812            .await;
7813        assert!(
7814            matches!(
7815                refused.state,
7816                OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
7817                    OrderError::InvalidPrecision(_)
7818                ))
7819            ),
7820            "{refused:?}"
7821        );
7822    }
7823
7824    #[tokio::test]
7825    async fn placing_listing_and_cancelling_margin_orders_feed_the_known_live_set() {
7826        let server = wiremock::MockServer::start().await;
7827        wiremock::Mock::given(wiremock::matchers::method("POST"))
7828            .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7829            .respond_with(
7830                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
7831                    "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "placed",
7832                    "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
7833                    "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
7834                    "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
7835                })),
7836            )
7837            .mount(&server)
7838            .await;
7839        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7840            .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7841            .respond_with(
7842                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
7843                    "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
7844                    "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0",
7845                    "cummulativeQuoteQty": "0", "status": "CANCELED", "timeInForce": "GTC",
7846                    "type": "LIMIT", "side": "BUY",
7847                })),
7848            )
7849            .mount(&server)
7850            .await;
7851        wiremock::Mock::given(wiremock::matchers::method("GET"))
7852            .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
7853            .respond_with(
7854                wiremock::ResponseTemplate::new(200)
7855                    .set_body_json(serde_json::json!([margin_order_row("listed", "NEW")])),
7856            )
7857            .mount(&server)
7858            .await;
7859        let client = margin_client_at(&server, false);
7860        let btcusdt = InstrumentNameExchange::new("BTCUSDT");
7861
7862        let placed = client
7863            .open_order(margin_open_request(&btcusdt, "placed"))
7864            .await;
7865        assert!(
7866            matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
7867            "{placed:?}"
7868        );
7869        assert!(
7870            client
7871                .known_live
7872                .lock()
7873                .contains(&ClientOrderId::new("placed"))
7874        );
7875
7876        client.fetch_open_orders(&[]).await.unwrap();
7877        assert!(
7878            client
7879                .known_live
7880                .lock()
7881                .contains(&ClientOrderId::new("listed"))
7882        );
7883
7884        let cancelled = client
7885            .cancel_order(OrderRequestCancel {
7886                key: OrderKey::new(
7887                    ExchangeId::BinanceMargin,
7888                    &btcusdt,
7889                    StrategyId::new("strategy"),
7890                    ClientOrderId::new("placed"),
7891                ),
7892                state: crate::order::request::RequestCancel {
7893                    id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7894                },
7895            })
7896            .await;
7897        assert!(cancelled.state.is_ok(), "{cancelled:?}");
7898        assert!(
7899            !client
7900                .known_live
7901                .lock()
7902                .contains(&ClientOrderId::new("placed"))
7903        );
7904    }
7905
7906    /// A cancel response that does not say what filled reports the fill unknown, not zero.
7907    #[tokio::test]
7908    async fn a_cancel_response_without_executed_qty_reports_the_fill_unknown() {
7909        let server = wiremock::MockServer::start().await;
7910        wiremock::Mock::given(wiremock::matchers::method("DELETE"))
7911            .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7912            .respond_with(
7913                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
7914                    "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
7915                    "clientOrderId": "cancel", "price": "100", "origQty": "2",
7916                    "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
7917                })),
7918            )
7919            .mount(&server)
7920            .await;
7921        let client = margin_client_at(&server, false);
7922
7923        let cancelled = client
7924            .cancel_order(OrderRequestCancel {
7925                key: OrderKey::new(
7926                    ExchangeId::BinanceMargin,
7927                    &InstrumentNameExchange::new("BTCUSDT"),
7928                    StrategyId::new("strategy"),
7929                    ClientOrderId::new("placed"),
7930                ),
7931                state: crate::order::request::RequestCancel {
7932                    id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
7933                },
7934            })
7935            .await;
7936        let Ok(cancelled) = cancelled.state else {
7937            panic!("expected Ok, got {cancelled:?}");
7938        };
7939        assert_eq!(cancelled.filled_quantity, None);
7940    }
7941
7942    #[test]
7943    fn a_reconnect_opens_a_gap_on_each_stream_instrument_and_checks_those_with_orders_held() {
7944        let [btc, eth, xrp] = ["BTCUSDT", "ETHUSDT", "XRPUSDT"].map(InstrumentNameExchange::new);
7945        let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7946        for (instrument, cid) in [(&btc, "btc"), (&xrp, "xrp")] {
7947            known.lock().live(
7948                &margin_key(instrument.name(), cid),
7949                Decimal::TWO,
7950                OrderKind::Limit,
7951                &Open::new(
7952                    VenueOrderId::Assigned(OrderId::new(cid)),
7953                    Utc::now(),
7954                    Decimal::ZERO,
7955                ),
7956            );
7957        }
7958        let mut unrecovered = UnrecoveredFills::default();
7959        let mut unchecked = UncheckedOrders::default();
7960
7961        open_reconnect_recovery(
7962            &mut unrecovered,
7963            &mut unchecked,
7964            &known,
7965            &[btc.clone(), eth.clone()],
7966            Utc::now() - chrono::Duration::minutes(1),
7967        );
7968
7969        assert!(unrecovered.covers(&btc) && unrecovered.covers(&eth));
7970        assert!(!unrecovered.covers(&xrp), "not the stream's");
7971        assert!(unchecked.contains(&btc));
7972        assert!(!unchecked.contains(&eth), "no order held");
7973        assert!(!unchecked.contains(&xrp), "its fills are not recovered");
7974    }
7975
7976    /// Margin fill recovery ends a held order that a recovered fill completes, and keeps one it
7977    /// only part-fills, so a later check does not report the completed one again.
7978    #[tokio::test]
7979    async fn a_recovered_margin_fill_that_completes_a_held_order_ends_it() {
7980        let disconnect = Utc::now() - chrono::Duration::minutes(10);
7981        let server = wiremock::MockServer::start().await;
7982        wiremock::Mock::given(wiremock::matchers::method("GET"))
7983            .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7984            .respond_with(venue(vec![
7985                (
7986                    1,
7987                    (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
7988                ),
7989                (
7990                    2,
7991                    (disconnect + chrono::Duration::minutes(2)).timestamp_millis(),
7992                ),
7993            ]))
7994            .mount(&server)
7995            .await;
7996        // Each venue trade is 0.01 of an order of its own id: order 1 is complete at 0.01, order 2
7997        // is of 0.02 and part-filled.
7998        let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
7999        for (cid, order_id, quantity) in [("one", "1", "0.01"), ("two", "2", "0.02")] {
8000            known.lock().live(
8001                &margin_key("BTCUSDT", cid),
8002                Decimal::from_str(quantity).unwrap(),
8003                OrderKind::Limit,
8004                &Open::new(
8005                    VenueOrderId::Assigned(OrderId::new(order_id)),
8006                    Utc::now(),
8007                    Decimal::ZERO,
8008                ),
8009            );
8010        }
8011        let mut unrecovered = UnrecoveredFills::default();
8012        unrecovered.open(
8013            &[InstrumentNameExchange::new("BTCUSDT")],
8014            disconnect,
8015            Utc::now(),
8016        );
8017        let (tx, mut rx) = mpsc::unbounded_channel();
8018
8019        recover_margin_fills(
8020            &margin_rest_at(&server),
8021            &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
8022            &mut unrecovered,
8023            &tx,
8024            &new_dedup_cache(),
8025            &known,
8026            false,
8027        )
8028        .await;
8029
8030        assert_eq!(
8031            std::iter::from_fn(|| rx.try_recv().ok()).count(),
8032            2,
8033            "both fills"
8034        );
8035        let known = known.lock();
8036        assert!(!known.contains(&ClientOrderId::new("one")), "completed");
8037        assert!(known.contains(&ClientOrderId::new("two")), "part-filled");
8038    }
8039
8040    /// An IOC order that ended in its placement response is reported as expired with what
8041    /// filled, and is not held as live, so a late report of it as live cannot bring it back.
8042    #[tokio::test]
8043    async fn a_margin_order_that_expired_on_placement_is_not_reported_or_held_open() {
8044        use rust_decimal_macros::dec;
8045
8046        for (cid, filled, quote) in [("unfilled", "0", "0"), ("partly", "1", "100")] {
8047            let server = wiremock::MockServer::start().await;
8048            wiremock::Mock::given(wiremock::matchers::method("POST"))
8049                .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8050                .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
8051                    serde_json::json!({
8052                        "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
8053                        "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
8054                        "executedQty": filled, "cummulativeQuoteQty": quote,
8055                        "status": "EXPIRED", "timeInForce": "IOC", "type": "LIMIT", "side": "BUY",
8056                    }),
8057                ))
8058                .mount(&server)
8059                .await;
8060            let client = margin_client_at(&server, false);
8061            let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8062
8063            let placed = client.open_order(margin_open_request(&btcusdt, cid)).await;
8064
8065            let OrderState::Inactive(crate::order::state::InactiveOrderState::Expired(expired)) =
8066                &placed.state
8067            else {
8068                panic!("{cid}: expired, not open: {placed:?}");
8069            };
8070            assert_eq!(expired.filled_quantity, Decimal::from_str(filled).ok());
8071            let late = Open::new(
8072                VenueOrderId::Assigned(OrderId::new("7")),
8073                Utc::now(),
8074                Decimal::ZERO,
8075            );
8076            let mut known = client.known_live.lock();
8077            known.live(&placed.key, dec!(2), OrderKind::Limit, &late);
8078            assert!(
8079                !known.contains(&ClientOrderId::new(cid)),
8080                "{cid}: not held, even after a late live report"
8081            );
8082        }
8083    }
8084
8085    /// Answer the requests behind `fetch_shortability` and `fetch_borrow_capacity`: the BTCUSDT
8086    /// pair on cross and isolated margin, the base asset, its hourly rate and its max borrow.
8087    async fn mount_borrow_terms(
8088        server: &wiremock::MockServer,
8089        pair: serde_json::Value,
8090        asset: serde_json::Value,
8091        rate: serde_json::Value,
8092        max_borrow: serde_json::Value,
8093    ) {
8094        use wiremock::{
8095            Mock, ResponseTemplate,
8096            matchers::{method, path},
8097        };
8098        for (route, body) in [
8099            ("/sapi/v1/margin/allPairs", pair.clone()),
8100            ("/sapi/v1/margin/isolated/allPairs", pair),
8101            ("/sapi/v1/margin/allAssets", asset),
8102            ("/sapi/v1/margin/next-hourly-interest-rate", rate),
8103            ("/sapi/v1/margin/maxBorrowable", max_borrow),
8104        ] {
8105            Mock::given(method("GET"))
8106                .and(path(route))
8107                .respond_with(ResponseTemplate::new(200).set_body_json(body))
8108                .mount(server)
8109                .await;
8110        }
8111    }
8112
8113    fn btcusdt_pair(is_margin_trade: bool, is_sell_allowed: bool) -> serde_json::Value {
8114        serde_json::json!([{
8115            "base": "BTC", "quote": "USDT", "symbol": "BTCUSDT",
8116            "isMarginTrade": is_margin_trade, "isSellAllowed": is_sell_allowed, "isBuyAllowed": true,
8117        }])
8118    }
8119
8120    fn btc_asset(is_borrowable: bool) -> serde_json::Value {
8121        serde_json::json!([{ "assetName": "BTC", "isBorrowable": is_borrowable }])
8122    }
8123
8124    fn btc_rate(rate: &str) -> serde_json::Value {
8125        serde_json::json!([{ "asset": "BTC", "nextHourlyInterestRate": rate }])
8126    }
8127
8128    /// The value of `key` in the query of the request `server` received on `route`.
8129    async fn query_value(server: &wiremock::MockServer, route: &str, key: &str) -> Option<String> {
8130        server
8131            .received_requests()
8132            .await
8133            .unwrap()
8134            .iter()
8135            .find(|request| request.url.path() == route)
8136            .and_then(|request| {
8137                request
8138                    .url
8139                    .query_pairs()
8140                    .find(|(name, _)| name == key)
8141                    .map(|(_, value)| value.into_owned())
8142            })
8143    }
8144
8145    #[tokio::test]
8146    async fn margin_shortability_reads_the_pair_the_asset_and_the_annualised_rate() {
8147        use crate::client::ShortabilityClient;
8148        use rust_decimal_macros::dec;
8149        for (is_isolated, pairs_route, flag) in [
8150            (false, "/sapi/v1/margin/allPairs", "FALSE"),
8151            (true, "/sapi/v1/margin/isolated/allPairs", "TRUE"),
8152        ] {
8153            let server = wiremock::MockServer::start().await;
8154            mount_borrow_terms(
8155                &server,
8156                btcusdt_pair(true, true),
8157                btc_asset(true),
8158                btc_rate("0.00000571"),
8159                serde_json::json!({}),
8160            )
8161            .await;
8162
8163            let shortability = margin_client_at(&server, is_isolated)
8164                .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
8165                .await
8166                .unwrap();
8167
8168            assert_eq!(
8169                shortability,
8170                Shortability::new()
8171                    .with_shortable(true)
8172                    .with_fee_rate(dec!(0.00000571) * dec!(8760)),
8173                "isolated: {is_isolated}"
8174            );
8175            assert_eq!(
8176                query_value(&server, pairs_route, "symbol").await.as_deref(),
8177                Some("BTCUSDT"),
8178                "the pair list is asked for the one symbol"
8179            );
8180            assert_eq!(
8181                query_value(&server, "/sapi/v1/margin/allAssets", "asset")
8182                    .await
8183                    .as_deref(),
8184                Some("BTC"),
8185                "the base asset is the one borrowed"
8186            );
8187            let rate = "/sapi/v1/margin/next-hourly-interest-rate";
8188            assert_eq!(
8189                query_value(&server, rate, "assets").await.as_deref(),
8190                Some("BTC")
8191            );
8192            assert_eq!(
8193                query_value(&server, rate, "isIsolated").await.as_deref(),
8194                Some(flag)
8195            );
8196        }
8197    }
8198
8199    #[tokio::test]
8200    async fn margin_shortability_is_refused_by_any_flag_and_unknown_without_them() {
8201        use crate::client::ShortabilityClient;
8202        let cases = [
8203            (btcusdt_pair(false, true), btc_asset(true), Some(false)),
8204            (btcusdt_pair(true, false), btc_asset(true), Some(false)),
8205            (btcusdt_pair(true, true), btc_asset(false), Some(false)),
8206            // Binance does not list the asset, so whether it lends it is unknown.
8207            (btcusdt_pair(true, true), serde_json::json!([]), None),
8208        ];
8209        for (pair, asset, shortable) in cases {
8210            let server = wiremock::MockServer::start().await;
8211            mount_borrow_terms(
8212                &server,
8213                pair.clone(),
8214                asset.clone(),
8215                serde_json::json!([]),
8216                serde_json::json!({}),
8217            )
8218            .await;
8219
8220            let shortability = margin_client_at(&server, false)
8221                .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
8222                .await
8223                .unwrap();
8224
8225            assert_eq!(shortability.shortable, shortable, "{pair} {asset}");
8226            assert_eq!(
8227                shortability.fee_rate, None,
8228                "no rate returned is an unknown fee"
8229            );
8230            assert_eq!(shortability.easy_to_borrow, None);
8231            assert_eq!(shortability.available, None);
8232        }
8233    }
8234
8235    #[tokio::test]
8236    async fn margin_shortability_of_an_unlisted_pair_is_an_invalid_instrument() {
8237        use crate::client::ShortabilityClient;
8238        let server = wiremock::MockServer::start().await;
8239        mount_borrow_terms(
8240            &server,
8241            serde_json::json!([]),
8242            btc_asset(true),
8243            btc_rate("0.00000571"),
8244            serde_json::json!({}),
8245        )
8246        .await;
8247
8248        let result = margin_client_at(&server, false)
8249            .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
8250            .await;
8251
8252        assert!(
8253            matches!(
8254                result,
8255                Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
8256                    if instrument.name().as_str() == "BTCUSDT"
8257            ),
8258            "{result:?}"
8259        );
8260    }
8261
8262    #[tokio::test]
8263    async fn margin_borrow_capacity_asks_for_the_asset_the_side_borrows() {
8264        use crate::client::BorrowCapacityClient;
8265        use rust_decimal_macros::dec;
8266        for (is_isolated, side, asset, isolated_symbol) in [
8267            (false, Side::Sell, "BTC", None),
8268            (false, Side::Buy, "USDT", None),
8269            (true, Side::Sell, "BTC", Some("BTCUSDT")),
8270        ] {
8271            let server = wiremock::MockServer::start().await;
8272            mount_borrow_terms(
8273                &server,
8274                btcusdt_pair(true, true),
8275                btc_asset(true),
8276                btc_rate("0.00000571"),
8277                serde_json::json!({ "amount": "1.69248805", "borrowLimit": "60" }),
8278            )
8279            .await;
8280
8281            let capacity = margin_client_at(&server, is_isolated)
8282                .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), side)
8283                .await
8284                .unwrap();
8285
8286            assert_eq!(
8287                capacity,
8288                BorrowCapacity::new(AssetNameExchange::new(asset))
8289                    .with_borrowable_now(dec!(1.69248805))
8290                    .with_account_limit(dec!(60)),
8291                "{side:?} isolated: {is_isolated}"
8292            );
8293            let max_borrow = "/sapi/v1/margin/maxBorrowable";
8294            assert_eq!(
8295                query_value(&server, max_borrow, "asset").await.as_deref(),
8296                Some(asset)
8297            );
8298            assert_eq!(
8299                query_value(&server, max_borrow, "isolatedSymbol")
8300                    .await
8301                    .as_deref(),
8302                isolated_symbol,
8303                "only isolated margin names the pair's account"
8304            );
8305        }
8306    }
8307
8308    #[tokio::test]
8309    async fn margin_borrow_capacity_keeps_an_omitted_amount_unknown_and_fails_a_malformed_one() {
8310        use crate::client::BorrowCapacityClient;
8311        use rust_decimal_macros::dec;
8312        let instrument = InstrumentNameExchange::new("BTCUSDT");
8313
8314        let server = wiremock::MockServer::start().await;
8315        mount_borrow_terms(
8316            &server,
8317            btcusdt_pair(true, true),
8318            btc_asset(true),
8319            btc_rate("0"),
8320            serde_json::json!({ "borrowLimit": "60" }),
8321        )
8322        .await;
8323        let capacity = margin_client_at(&server, false)
8324            .fetch_borrow_capacity(&instrument, Side::Sell)
8325            .await
8326            .unwrap();
8327        assert_eq!(capacity.borrowable_now, None);
8328        assert_eq!(capacity.account_limit, Some(dec!(60)));
8329
8330        let server = wiremock::MockServer::start().await;
8331        mount_borrow_terms(
8332            &server,
8333            btcusdt_pair(true, true),
8334            btc_asset(true),
8335            btc_rate("0"),
8336            serde_json::json!({ "amount": "lots", "borrowLimit": "60" }),
8337        )
8338        .await;
8339        let result = margin_client_at(&server, false)
8340            .fetch_borrow_capacity(&instrument, Side::Sell)
8341            .await;
8342        assert!(
8343            matches!(result, Err(UnindexedClientError::Internal(ref message)) if message.contains("amount")),
8344            "{result:?}"
8345        );
8346    }
8347
8348    #[tokio::test]
8349    async fn margin_borrow_capacity_without_an_isolated_account_names_the_pair() {
8350        use crate::client::BorrowCapacityClient;
8351        use wiremock::{
8352            Mock,
8353            matchers::{method, path},
8354        };
8355        let server = wiremock::MockServer::start().await;
8356        Mock::given(method("GET"))
8357            .and(path("/sapi/v1/margin/maxBorrowable"))
8358            .respond_with(
8359                wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
8360                    "code": -11001, "msg": "Isolated margin account does not exist."
8361                })),
8362            )
8363            .mount(&server)
8364            .await;
8365        mount_borrow_terms(
8366            &server,
8367            btcusdt_pair(true, true),
8368            btc_asset(true),
8369            btc_rate("0"),
8370            serde_json::json!({}),
8371        )
8372        .await;
8373
8374        let err = margin_client_at(&server, true)
8375            .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), Side::Sell)
8376            .await
8377            .unwrap_err();
8378
8379        assert_eq!(
8380            err.to_string(),
8381            "API: request rejected: BTCUSDT: -11001 Isolated margin account does not exist."
8382        );
8383        assert!(!err.is_transient());
8384    }
8385}