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