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