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