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