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