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