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