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