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