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