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