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