Skip to main content

rustrade_execution/client/binance/
spot.rs

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