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