Skip to main content

rustrade_execution/client/binance/
spot.rs

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