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