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