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