Skip to main content

rustrade_execution/client/binance/
spot.rs

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