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 (Retry-After header is not accessible through the SDK's anyhow::Error
17//   chain, so computed delays are used), blocks further REST calls until cooldown expires,
18//   and does not open a WS-API session during it. Separately, pauses REST queries until the
19//   next minute once responses report >= 90% of the request-weight limit used
20// - Reconnection: account_stream auto-reconnects on WS disconnect/error with
21//   exponential backoff (1s → 30s, max 10 attempts)
22// - Heartbeat monitoring: tracks WS activity via AtomicBool flag; forces reconnect
23//   if no activity (messages, ping, pong) for 30 seconds
24// - Fill recovery: on reconnect, fetches missed trades via REST since disconnect
25//   timestamp, sends through dedup cache to avoid duplicates
26//
27// Known limitations:
28// - balanceUpdate events (deposits/withdrawals) are silently ignored. The crypto
29//   repo wrapper should call fetch_balances or account_snapshot periodically to
30//   reconcile balances after external transfers.
31// - A TRADE report whose order status (X) is neither PARTIALLY_FILLED nor FILLED emits
32//   the execution but no order snapshot, so a fill arriving after its order's terminal
33//   report cannot resurrect a retired order as a resting one. That order's filled
34//   quantity is settled instead by the terminal report's own z, or by fetch_open_orders.
35
36use super::shared::{
37    AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
38    CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
39    MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing, RateLimitTracker, RequestKind,
40    SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, UnrecoveredFills, UserDataFrame, WeightPool,
41    classify_order_kind_tif, classify_rest_query_error, classify_ws_order_error,
42    convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
43    dedup_key_from_event, drop_after, gap_failed, gap_time, is_duplicate, is_handshake_rate_limit,
44    log_unrecognised_frame, new_dedup_cache, parse_user_data_frame, recovered_order_totals,
45    response_decode_error, rest_call_with_retry, unix_ms,
46};
47use crate::{
48    AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
49    UnindexedAccountSnapshot,
50    balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
51    client::ExecutionClient,
52    emit_stream_terminated,
53    error::{
54        ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
55        UnindexedOrderError,
56    },
57    order::{
58        Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
59        id::{OrderId, StrategyId, VenueOrderId},
60        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
61        state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
62    },
63    parse_env_bool,
64    position::PositionReport,
65    trade::{AssetFees, Trade, TradeId},
66};
67use binance_sdk::{
68    common::{
69        config::{ConfigurationRestApi, ConfigurationWebsocketApi},
70        models::WebsocketEvent,
71    },
72    spot::{
73        SpotRestApi, SpotWsApi,
74        rest_api::{GetAccountParams, GetOpenOrdersParams, MyTradesParams, RestApi},
75        websocket_api::{
76            OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
77            OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
78            WebsocketApiHandle,
79        },
80    },
81};
82use chrono::{DateTime, TimeZone, Utc};
83use futures::stream::BoxStream;
84use rust_decimal::Decimal;
85use rustrade_instrument::{
86    Side,
87    asset::name::AssetNameExchange,
88    exchange::ExchangeId,
89    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
90};
91use serde::Deserialize;
92use smol_str::format_smolstr;
93use std::{
94    str::FromStr,
95    sync::{
96        Arc,
97        atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
98    },
99    time::Duration,
100};
101use tokio::sync::{RwLock, mpsc, oneshot};
102use tracing::{debug, error, info, trace, warn};
103
104// ---------------------------------------------------------------------------
105// Configuration
106// ---------------------------------------------------------------------------
107
108/// Configuration for the BinanceSpot execution client.
109// Serialize intentionally omitted — would expose secret_key in plaintext
110#[derive(Clone, Deserialize)]
111pub struct BinanceSpotConfig {
112    // not pub — prevents accidental credential exposure via struct access.
113    // Use BinanceSpotConfig::new() to construct, or deserialize from config file.
114    api_key: String,
115    secret_key: String,
116
117    /// Use testnet endpoints instead of production.
118    #[serde(default = "default_testnet")]
119    pub testnet: bool,
120}
121
122/// Serde default for [`BinanceSpotConfig::testnet`]: an absent `testnet` field deserializes to the
123/// **safe** testnet environment (`true`).
124///
125/// `#[serde(default = "…")]` requires a named function (it cannot take a literal), so this exists
126/// purely to supply that default to the derive.
127fn default_testnet() -> bool {
128    true
129}
130
131// custom Debug to avoid leaking credentials in logs
132impl std::fmt::Debug for BinanceSpotConfig {
133    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134        f.debug_struct("BinanceSpotConfig")
135            .field("api_key", &"***")
136            .field("secret_key", &"***")
137            .field("testnet", &self.testnet)
138            .finish()
139    }
140}
141
142impl BinanceSpotConfig {
143    /// Create a new config using the **safe** testnet endpoints (alias for
144    /// [`testnet`](Self::testnet)).
145    pub fn new(api_key: String, secret_key: String) -> Self {
146        Self::testnet(api_key, secret_key)
147    }
148
149    /// Create a config targeting Binance Spot's **testnet** endpoints (simulated funds).
150    pub fn testnet(api_key: String, secret_key: String) -> Self {
151        Self {
152            api_key,
153            secret_key,
154            testnet: true,
155        }
156    }
157
158    /// Create a config targeting Binance Spot's **production** endpoints.
159    ///
160    /// ⚠️ Production trades execute against the live account with **real funds**. Prefer
161    /// [`testnet`](Self::testnet) unless you explicitly intend live execution.
162    pub fn production(api_key: String, secret_key: String) -> Self {
163        Self {
164            api_key,
165            secret_key,
166            testnet: false,
167        }
168    }
169
170    /// Build a config from environment variables.
171    ///
172    /// Reads:
173    /// - `BINANCE_API_KEY` (required) — API key.
174    /// - `BINANCE_SECRET_KEY` (required) — API secret.
175    /// - `BINANCE_TESTNET` (optional) — `"true"`/`"false"` (case-insensitive). **Absent ⇒ the safe
176    ///   testnet environment.** Set `BINANCE_TESTNET=false` to target production (real funds).
177    ///
178    /// # Errors
179    ///
180    /// Returns [`BinanceSpotConfigError`] (never panics):
181    /// - a required credential var is unset
182    ///   ([`MissingApiKey`](BinanceSpotConfigError::MissingApiKey) /
183    ///   [`MissingSecretKey`](BinanceSpotConfigError::MissingSecretKey)) or holds non-UTF-8
184    ///   ([`InvalidApiKey`](BinanceSpotConfigError::InvalidApiKey) /
185    ///   [`InvalidSecretKey`](BinanceSpotConfigError::InvalidSecretKey));
186    /// - `BINANCE_TESTNET` is neither `true` nor `false`, or holds non-UTF-8
187    ///   ([`InvalidTestnet`](BinanceSpotConfigError::InvalidTestnet)).
188    pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
189        let api_key = match std::env::var("BINANCE_API_KEY") {
190            Ok(value) => value,
191            Err(std::env::VarError::NotPresent) => {
192                return Err(BinanceSpotConfigError::MissingApiKey);
193            }
194            Err(std::env::VarError::NotUnicode(_)) => {
195                return Err(BinanceSpotConfigError::InvalidApiKey);
196            }
197        };
198        let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
199            Ok(value) => value,
200            Err(std::env::VarError::NotPresent) => {
201                return Err(BinanceSpotConfigError::MissingSecretKey);
202            }
203            Err(std::env::VarError::NotUnicode(_)) => {
204                return Err(BinanceSpotConfigError::InvalidSecretKey);
205            }
206        };
207        let testnet = match std::env::var("BINANCE_TESTNET") {
208            Ok(value) => {
209                parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
210            }
211            Err(std::env::VarError::NotPresent) => true,
212            // The toggle value is not secret, so echo it (lossily) like the parse-failure arm above —
213            // an actionable "got X" beats a hardcoded sentinel.
214            Err(std::env::VarError::NotUnicode(value)) => {
215                return Err(BinanceSpotConfigError::InvalidTestnet(
216                    value.to_string_lossy().into_owned(),
217                ));
218            }
219        };
220
221        if testnet {
222            Ok(Self::testnet(api_key, secret_key))
223        } else {
224            Ok(Self::production(api_key, secret_key))
225        }
226    }
227
228    /// Read-only access to the API key (e.g. for logging or header construction).
229    pub fn api_key(&self) -> &str {
230        &self.api_key
231    }
232}
233
234#[derive(Debug, PartialEq, thiserror::Error)]
235pub enum BinanceSpotConfigError {
236    #[error("BINANCE_API_KEY environment variable not set")]
237    MissingApiKey,
238
239    // No payload: the raw value is secret-key material, so it must never be echoed into an error
240    // message or log. The variant name already identifies which credential var is non-UTF-8.
241    #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
242    InvalidApiKey,
243
244    #[error("BINANCE_SECRET_KEY environment variable not set")]
245    MissingSecretKey,
246
247    #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
248    InvalidSecretKey,
249
250    #[error("BINANCE_TESTNET must be true or false, got {0}")]
251    InvalidTestnet(String),
252}
253
254// ---------------------------------------------------------------------------
255// BinanceSpot client
256// ---------------------------------------------------------------------------
257
258/// Why [`BinanceSpot::get_ws_api`] has no WS-API session to hand out. Either way, nothing was
259/// sent.
260#[derive(Debug)]
261enum WsApiUnavailable {
262    /// A rate-limit cooldown is active, or Binance refused the handshake with 429 or 418.
263    RateLimited(String),
264    /// Any other connect failure.
265    Connect(String),
266}
267
268impl WsApiUnavailable {
269    /// The error an order or cancel that could not be sent fails with. A rate limit is a
270    /// rejection, which tells the caller the request was not sent and to back off.
271    fn into_order_error(self) -> UnindexedOrderError {
272        match self {
273            Self::RateLimited(msg) => {
274                warn!(%msg, "BinanceSpot WS-API rate-limited, request not sent");
275                UnindexedOrderError::Rejected(ApiError::RateLimit)
276            }
277            Self::Connect(msg) => UnindexedOrderError::Connectivity(ConnectivityError::Socket(msg)),
278        }
279    }
280}
281
282/// BinanceSpot execution client using the official binance-sdk.
283///
284/// - REST API: account snapshot, balance/order/trade queries (startup/cold paths)
285/// - WebSocket API: order placement, order cancellation, user data stream (hot paths)
286///
287/// # Rate limits
288/// REST and the WebSocket API share one per-IP request-weight limit per minute. Its value comes
289/// from each WebSocket API response, and is Binance's documented 6000 until the first one. Once
290/// a response reports at least 90% of it used, REST queries wait for the next minute, so the
291/// rest is left for orders and cancels, which never wait for it. A query, including a
292/// [`fetch_open_orders`](Self::fetch_open_orders) after a reconnect, can therefore take up to
293/// about a minute longer. A reconnect's fill recovery does not wait for it either, since a pause
294/// could outlast the recovery's 30 s and delay its fills to a retry. After Binance answers with a
295/// rate-limit error, REST calls wait out a cooldown, and an order or cancel that would have to
296/// open a new WebSocket API session fails with [`ApiError::RateLimit`] instead, unsent.
297#[derive(Clone)]
298pub struct BinanceSpot {
299    config: Arc<BinanceSpotConfig>,
300    rest: Arc<RestApi>,
301    // Factory handle (cheap Clone) used to create WS connections.
302    ws_handle: WebsocketApiHandle,
303    // shared WS session for order operations (order.place, order.cancel).
304    // Distinct from the account_stream WS session created in connection_manager.
305    // Lazily connected on the first open_order / cancel_order call; cleared on
306    // connectivity errors so the next call reconnects. All clones share the same
307    // session via Arc<RwLock<...>>.
308    ws_api: Arc<RwLock<Option<WebsocketApi>>>,
309    // shared rate-limit tracker across all REST calls
310    rate_limiter: Arc<RateLimitTracker>,
311}
312
313impl std::fmt::Debug for BinanceSpot {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.debug_struct("BinanceSpot")
316            .field("testnet", &self.config.testnet)
317            .finish_non_exhaustive()
318    }
319}
320
321impl BinanceSpot {
322    /// # Panics
323    /// Panics if the binance-sdk configuration builder fails (invalid credentials format).
324    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
325    fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
326        let rest_config = ConfigurationRestApi::builder()
327            .api_key(config.api_key.clone())
328            .api_secret(config.secret_key.clone())
329            .build()
330            .expect("failed to build Binance REST configuration");
331
332        Arc::new(if config.testnet {
333            SpotRestApi::testnet(rest_config)
334        } else {
335            SpotRestApi::production(rest_config)
336        })
337    }
338
339    /// # Panics
340    /// Panics if the binance-sdk configuration builder fails (invalid credentials format).
341    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
342    fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
343        let ws_config = ConfigurationWebsocketApi::builder()
344            .api_key(config.api_key.clone())
345            .api_secret(config.secret_key.clone())
346            .build()
347            .expect("failed to build Binance WebSocket configuration");
348
349        if config.testnet {
350            SpotWsApi::testnet(ws_config)
351        } else {
352            SpotWsApi::production(ws_config)
353        }
354    }
355
356    /// Returns the shared WebSocket session, connecting on the first call.
357    /// If the previous session was cleared (due to a connectivity error),
358    /// establishes a new connection.
359    ///
360    /// Does not connect during a rate-limit cooldown: a handshake then counts against the limit
361    /// that set it, and repeated refusals can escalate to an IP ban (418). A handshake Binance
362    /// refuses with 429 or 418 starts a cooldown, so REST calls back off too. An existing session
363    /// is handed out regardless, since orders do not wait on the cooldown.
364    async fn get_ws_api(&self) -> Result<WebsocketApi, WsApiUnavailable> {
365        // Fast path: read lock to check if already connected
366        {
367            let guard = self.ws_api.read().await;
368            if let Some(ref ws) = *guard {
369                return Ok(ws.clone());
370            }
371        }
372        // Slow path: write lock to connect.
373        // The write lock is held across connect().await (TCP+TLS handshake). Concurrent
374        // open_order / cancel_order callers that also reach get_ws_api will block for the
375        // connection duration. The timeout below bounds the worst case to CONNECT_TIMEOUT_SECS
376        // instead of the OS TCP timeout (75–127 s); on timeout, the write lock is released
377        // immediately and callers receive an error.
378        let mut guard = self.ws_api.write().await;
379        // Double-check after acquiring write lock (another task may have connected)
380        if let Some(ref ws) = *guard {
381            return Ok(ws.clone());
382        }
383        if self.rate_limiter.is_blocked() {
384            return Err(WsApiUnavailable::RateLimited(
385                "rate-limit cooldown active, not connecting the WS-API session".into(),
386            ));
387        }
388        let ws = match tokio::time::timeout(
389            Duration::from_secs(CONNECT_TIMEOUT_SECS),
390            self.ws_handle.connect(),
391        )
392        .await
393        {
394            Ok(Ok(ws)) => ws,
395            Ok(Err(e)) if is_handshake_rate_limit(&e) => {
396                self.rate_limiter.on_rate_limited(None);
397                return Err(WsApiUnavailable::RateLimited(format!("{e:#}")));
398            }
399            Ok(Err(e)) => return Err(WsApiUnavailable::Connect(format!("{e:#}"))),
400            Err(_) => {
401                return Err(WsApiUnavailable::Connect(format!(
402                    "BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s"
403                )));
404            }
405        };
406        *guard = Some(ws.clone());
407        Ok(ws)
408    }
409
410    /// Clear the cached WS session after a connectivity error, so the next
411    /// `get_ws_api()` call establishes a fresh connection.
412    async fn clear_ws_api(&self) {
413        // release the write lock before awaiting disconnect. Taking `ws` under
414        // the lock then dropping the lock means `get_ws_api()` can establish a new
415        // session concurrently while the old TCP connection is being torn down (two-
416        // connection window). This is safe: each `WebsocketApi` holds fully independent
417        // connection state (separate auth sessions, separate TCP sockets). No duplicate
418        // events are received on both connections because the user data stream
419        // subscription lives only on the connection_manager WS session, not on the
420        // ws_api order session managed here.
421        // Awaiting inline (rather than spawning) prevents unbounded task accumulation
422        // under rapid retries during a network partition.
423        let ws = {
424            let mut guard = self.ws_api.write().await;
425            guard.take()
426        };
427        if let Some(ws) = ws {
428            match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
429                Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
430                Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
431                Ok(Ok(())) => {}
432            }
433        }
434    }
435}
436
437/// Fetch open orders for a single instrument with rate-limit retry.
438///
439/// Returns `(instrument, orders)` so callers can associate results with their symbol.
440/// Used by both `account_snapshot` (wraps in `OrderState::active()`) and
441/// `fetch_open_orders` (returns `Open` directly), eliminating the duplicated
442/// ~40-line REST + concurrency pattern.
443async fn fetch_open_orders_for_instrument(
444    rest: Arc<RestApi>,
445    rate_limiter: Arc<RateLimitTracker>,
446    instrument: InstrumentNameExchange,
447) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
448    // Convert once before the retry closure to avoid a String allocation on every retry.
449    let symbol_str = instrument.name().to_string();
450    let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
451        let sym = symbol_str.clone();
452        Box::pin(async move {
453            let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
454            rest.get_open_orders(params).await
455        })
456    })
457    .await
458    .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
459
460    let orders_data = response.data().await.map_err(response_decode_error)?;
461
462    let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceSpot, &instrument);
463
464    Ok((instrument, listing))
465}
466
467/// Fetch *all* open orders in a single no-symbol `GET /api/v3/openOrders` call. Backs the
468/// [`fetch_open_orders`](BinanceSpot::fetch_open_orders) "return all" sentinel: with no symbol the
469/// venue returns orders across every instrument, so each order's instrument is recovered from its
470/// own `symbol` field (orders missing it are dropped).
471async fn fetch_all_open_orders(
472    rest: Arc<RestApi>,
473    rate_limiter: Arc<RateLimitTracker>,
474) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
475    let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
476        Box::pin(async move {
477            let params = GetOpenOrdersParams::builder().build()?;
478            rest.get_open_orders(params).await
479        })
480    })
481    .await
482    .map_err(|e| classify_rest_query_error(&e, None))?;
483
484    let orders_data = response.data().await.map_err(response_decode_error)?;
485
486    let orders = orders_data
487        .into_iter()
488        .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot))
489        .collect();
490
491    Ok(orders)
492}
493
494/// Paginate `GET /api/v3/myTrades` for a single instrument, from `from`.
495///
496/// Uses cursor-based pagination: the first page queries by `start_time`, or by `order_id` for a
497/// single order's executions; subsequent pages use `from_id = last_id + 1` (Binance ignores
498/// `start_time` when `from_id` is set), keeping `order_id` alongside it, a combination Binance
499/// documents as supported. A [`MyTradesFrom::Span`] stops at the first page reaching past its end
500/// and drops the executions after it. Trade IDs are monotonically increasing per symbol, so this produces a
501/// gapless result.
502///
503/// Returns raw response items. Callers decide how to handle `Err` (propagate vs. log-skip).
504async fn paginate_my_trades(
505    rest: &Arc<RestApi>,
506    rate_limiter: &Arc<RateLimitTracker>,
507    instrument: &InstrumentNameExchange,
508    from: MyTradesFrom,
509    kind: RequestKind,
510) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
511    // Convert once before the retry closure to avoid a String allocation on every retry.
512    let symbol_str = instrument.name().to_string();
513    // cursor-based pagination — first page uses start_time; subsequent pages use
514    // from_id (Binance ignores start_time when from_id is set). Trade IDs are
515    // monotonically increasing per symbol, so from_id = last_id + 1 continues exactly
516    // where the previous page left off with no overlap or gap.
517    let mut all_pages = Vec::new();
518    let mut cursor: Option<i64> = None;
519    loop {
520        let fid = cursor; // Option<i64> is Copy
521        let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
522            let sym = symbol_str.clone();
523            Box::pin(async move {
524                // const_assert! above guarantees BINANCE_MAX_TRADES fits in i32
525                #[allow(clippy::cast_possible_truncation)]
526                let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
527                let params = match (from, fid) {
528                    (
529                        MyTradesFrom::Time(start_time_ms)
530                        | MyTradesFrom::Span {
531                            start: start_time_ms,
532                            ..
533                        },
534                        None,
535                    ) => builder.start_time(start_time_ms),
536                    (MyTradesFrom::Time(_) | MyTradesFrom::Span { .. }, Some(id)) => {
537                        builder.from_id(id)
538                    }
539                    (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
540                    (MyTradesFrom::Order(order_id), Some(id)) => {
541                        builder.order_id(order_id).from_id(id)
542                    }
543                }
544                .build()?;
545                rest.my_trades(params).await
546            })
547        })
548        .await
549        .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
550
551        let mut page = response.data().await.map_err(response_decode_error)?;
552
553        let page_len = page.len();
554        let last_id = page.last().and_then(|t| t.id);
555        // A span ends once a page reaches past it.
556        let past_end = match from {
557            MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
558            MyTradesFrom::Time(_) | MyTradesFrom::Order(_) => false,
559        };
560        all_pages.extend(page);
561
562        if past_end || page_len < BINANCE_MAX_TRADES {
563            break;
564        }
565        match last_id {
566            Some(id) => {
567                debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
568                match id.checked_add(1) {
569                    Some(next) => cursor = Some(next),
570                    None => break, // saturated at i64::MAX; no further pages possible
571                }
572            }
573            None => {
574                warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
575                break;
576            }
577        }
578    }
579    Ok(all_pages)
580}
581
582// ---------------------------------------------------------------------------
583// ExecutionClient implementation
584// ---------------------------------------------------------------------------
585
586impl ExecutionClient for BinanceSpot {
587    const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
588
589    // Binance Spot trades spot pairs only; its derivatives live on separate USD-M/COIN-M venues
590    // with their own APIs, which this client does not speak.
591    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
592        &[InstrumentKindDiscriminant::Spot];
593    type Config = BinanceSpotConfig;
594    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
595
596    /// # Panics
597    ///
598    /// Panics if the binance-sdk REST or WebSocket configuration builder fails
599    /// (e.g. empty or malformed API key/secret).
600    fn new(config: Self::Config) -> Self {
601        let rest = Self::build_rest(&config);
602        let ws_handle = Self::build_ws_handle(&config);
603        Self {
604            config: Arc::new(config),
605            rest,
606            ws_handle,
607            ws_api: Arc::new(RwLock::new(None)),
608            rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Spot)),
609        }
610    }
611
612    async fn account_snapshot(
613        &self,
614        assets: &[AssetNameExchange],
615        instruments: &[InstrumentNameExchange],
616    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
617        // Fetch account info via REST (with rate-limit retry)
618        let response =
619            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
620                Box::pin(async move {
621                    let params = GetAccountParams::builder().build()?;
622                    rest.get_account(params).await
623                })
624            })
625            .await
626            .map_err(|e| classify_rest_query_error(&e, None))?;
627
628        let account = response.data().await.map_err(response_decode_error)?;
629
630        // Convert balances, filtering to requested assets
631        let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
632
633        // Fetch open orders for all instruments concurrently (with retry)
634        // limit concurrency to avoid bursting Binance's request weight limits
635        // (each GET /api/v3/openOrders costs 3 weight; 8 concurrent = 24 weight).
636        // account_snapshot wraps Open orders in OrderState::active(); fetch_open_orders
637        // returns them without the wrapper — both use fetch_open_orders_for_instrument.
638        use futures::{StreamExt as _, TryStreamExt};
639        let instrument_snapshots: Vec<_> =
640            futures::stream::iter(instruments.iter().cloned().map(|instrument| {
641                fetch_open_orders_for_instrument(
642                    self.rest.clone(),
643                    self.rate_limiter.clone(),
644                    instrument,
645                )
646            }))
647            .buffer_unordered(8)
648            .map(|result| {
649                let (inst, listing) = result?;
650                let wrapped = listing
651                    .orders
652                    .into_iter()
653                    .map(|o| Order {
654                        key: o.key,
655                        side: o.side,
656                        price: o.price,
657                        quantity: o.quantity,
658                        kind: o.kind,
659                        time_in_force: o.time_in_force,
660                        state: OrderState::active(o.state),
661                    })
662                    .collect();
663                Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
664                    inst,
665                    wrapped,
666                    listing.complete,
667                    PositionReport::Unreported,
668                    None,
669                ))
670            })
671            .try_collect()
672            .await?;
673
674        Ok(AccountSnapshot::new(
675            ExchangeId::BinanceSpot,
676            balances,
677            instrument_snapshots,
678        ))
679    }
680
681    /// Returns a live stream of account events (fills, order updates, balance changes).
682    ///
683    /// # Startup Race Window
684    ///
685    /// There is a brief window between the initial WS subscribe response and the
686    /// internal event listener being registered inside the spawned connection manager.
687    /// TRADE fills arriving in this gap are silently dropped. Callers that require fill
688    /// completeness at startup MUST call [`ExecutionClient::fetch_trades`] with a
689    /// 1–2 second lookback **unconditionally after `account_stream` returns**, before
690    /// processing any events. Do not use the first event's arrival as a readiness
691    /// signal — the first event may be a recovered fill sent before live WS events
692    /// start. The dedup cache absorbs any duplicates from the overlapping time window.
693    /// Callers MUST also call [`ExecutionClient::fetch_open_orders`] after each
694    /// reconnect to reconcile open-order state — order lifecycle events (NEW, CANCELED)
695    /// are not recovered after a WS disconnect, only TRADE fills are.
696    ///
697    /// # A recovery that does not finish
698    ///
699    /// Recovery after a reconnect is bounded by a 30 s timeout, and an instrument's query can
700    /// fail. Each instrument's gap, from the disconnect to just after the recovery began, is kept
701    /// until its fills are forwarded. A gap not read is retried after 1, 2, 4, 8 and 16 minutes,
702    /// whether the stream stays connected or reconnects in between. A retry reads only the gap,
703    /// not fills the stream delivered live after it; the few seconds at its end that overlap live
704    /// delivery are deduplicated. Each failed read is logged at `warn` with the instrument and the
705    /// gap. After five failed retries the gap is given up, logged at `error`, and its fills are not
706    /// delivered: read them with [`ExecutionClient::fetch_trades`].
707    ///
708    /// A recovered gap counts as read even when a fill in it could not be converted, which is
709    /// logged, or its order's cumulative could not be looked up (see below).
710    ///
711    /// # A recovered fill advances the order too
712    ///
713    /// A fill that arrives live over the WebSocket carries the order's cumulative filled
714    /// quantity in [`Trade::order_filled_quantity`] (`executionReport`'s `z`), so it advances
715    /// the order by itself. A fill recovered after a disconnect comes from REST `myTrades`,
716    /// which reports executions only, so recovery rebuilds the same figure by reading each
717    /// recovered order's executions from its first: one extra request per order (another per
718    /// further 1,000 executions), up to four orders at a time per instrument.
719    ///
720    /// Those lookups have their own time budget inside the recovery timeout, so they can never
721    /// cost a fill. A fill whose order was not looked up in time, or whose lookup failed, goes
722    /// out with `order_filled_quantity: None`, logged at `warn`. It then advances the position
723    /// but not the order, which keeps whatever `filled_quantity` it held before the gap until
724    /// the order's state is learned some other way.
725    async fn account_stream(
726        &self,
727        // _assets is intentionally ignored — Binance pushes outboundAccountPosition
728        // for all account assets regardless of any filter. Client-side filtering would hide
729        // balance updates for assets not in the initial list. See account_snapshot for filtering.
730        _assets: &[AssetNameExchange],
731        instruments: &[InstrumentNameExchange],
732    ) -> Result<Self::AccountStream, UnindexedClientError> {
733        // Resilient account stream with auto-reconnection, heartbeat monitoring,
734        // fill recovery, and event deduplication.
735        //
736        // Architecture: a persistent unbounded mpsc channel bridges events to the
737        // consumer. A "connection manager" task owns the reconnection loop — on WS
738        // disconnect or heartbeat timeout it tears down the old connection, backs off,
739        // reconnects, recovers missed fills via REST, and re-subscribes. The consumer
740        // sees a seamless BoxStream that only terminates when the consumer drops it or
741        // max reconnect attempts are exhausted.
742
743        // unbounded channel — memory grows if the consumer is slow, but events
744        // are never silently dropped. Silent data loss (corrupted position state) is a
745        // worse failure mode than observable memory pressure. The WS callback uses
746        // the synchronous send() which only fails if the receiver is dropped.
747        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
748        let dedup = new_dedup_cache();
749        let ws_handle = self.ws_handle.clone();
750        let rest = self.rest.clone();
751        let rate_limiter = self.rate_limiter.clone();
752        let instruments = instruments.to_vec();
753        // all current Binance Spot symbols are ≤22 bytes (within SmolStr's 23-byte
754        // inline limit), making clone() a stack memcpy with no heap allocation. Guard
755        // this implicit invariant so future symbols that exceed the limit are caught early.
756        debug_assert!(
757            instruments.iter().all(|i| i.name().len() <= 23),
758            "instrument name exceeds SmolStr inline capacity: {:?}",
759            instruments.iter().find(|i| i.name().len() > 23)
760        );
761
762        // Verify initial connection succeeds before returning the stream.
763        // This lets the caller distinguish "can't connect at all" from "connected
764        // but later disconnected" (the latter is handled by auto-reconnect).
765        let initial_ws = ws_handle.connect().await.map_err(|e| {
766            UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
767        })?;
768
769        #[allow(clippy::expect_used)] // Builder has no required fields; infallible
770        let params = UserDataStreamSubscribeSignatureParams::builder()
771            .build()
772            .expect("UserDataStreamSubscribeSignatureParams has no required fields");
773
774        match initial_ws
775            .user_data_stream_subscribe_signature(params)
776            .await
777        {
778            Ok(_) => {}
779            Err(e) => {
780                // binance-sdk has no Drop impl for TCP close — must disconnect
781                // explicitly to avoid leaking the connection on subscribe failure.
782                // Awaiting inline (rather than spawning) ensures the socket is cleaned
783                // up before returning Err, matching the pattern used in clear_ws_api.
784                match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
785                    Ok(Err(de)) => {
786                        warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
787                    }
788                    Err(_) => {
789                        warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
790                    }
791                    Ok(Ok(())) => {}
792                }
793                return Err(UnindexedClientError::Internal(e.to_string()));
794            }
795        }
796
797        // race window — events arriving between the subscribe response above
798        // and subscribe_on_ws_events() being called inside connection_manager are
799        // silently dropped. The gap is bounded by Tokio scheduler latency; typically
800        // milliseconds under load (no sub-millisecond guarantee).
801        // account_snapshot reconciles open-order state, but TRADE fills in this window
802        // are not recoverable without an explicit fetch_trades lookback. Callers that
803        // require fill completeness at startup should call fetch_trades with a ~1s
804        // lookback after account_stream returns.
805
806        // Spawn the connection manager task.
807        // `initial_ws` is passed in so the first iteration skips the connect step.
808        let cm_handle = tokio::spawn(connection_manager(
809            tx,
810            dedup,
811            ws_handle,
812            rest,
813            rate_limiter,
814            instruments,
815            Some(initial_ws),
816        ));
817
818        // wrap the stream to abort connection_manager on drop, ensuring the
819        // WS subscription and TCP connection are cleaned up even if the consumer
820        // drops the stream without waiting for graceful shutdown.
821        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
822        let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
823        Ok(futures::StreamExt::boxed(guarded_stream))
824    }
825
826    async fn cancel_order(
827        &self,
828        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
829    ) -> Option<UnindexedOrderResponseCancel> {
830        let instrument = request.key.instrument.clone();
831        let key = OrderKey {
832            exchange: request.key.exchange,
833            instrument: instrument.clone(),
834            strategy: request.key.strategy.clone(),
835            cid: request.key.cid.clone(),
836        };
837
838        let ws = match self.get_ws_api().await {
839            Ok(ws) => ws,
840            Err(unavailable) => {
841                return Some(UnindexedOrderResponseCancel {
842                    key,
843                    state: Err(unavailable.into_order_error()),
844                });
845            }
846        };
847
848        // SDK constraint — OrderCancelParams::builder takes String, not &str.
849        // Allocates one String for the symbol; unavoidable without SDK changes.
850        let mut params_builder =
851            OrderCancelParams::builder(request.key.instrument.name().to_string());
852
853        // Use exchange order ID if available and parseable, otherwise use client order ID
854        if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
855            if let Ok(id) = order_id.0.parse::<i64>() {
856                params_builder = params_builder.order_id(id);
857            } else {
858                // exchange order ID exists but isn't a valid i64 — fall back to cid.
859                // This is unexpected; Binance orderId should always be numeric.
860                // error! not warn!: corrupted order state may result in cancelling the wrong
861                // order (if the clientOrderId doesn't match) or a silent no-op cancel.
862                error!(
863                    order_id = %order_id.0,
864                    "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
865                );
866                params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
867            }
868        } else {
869            params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
870        }
871
872        let params = match params_builder.build() {
873            Ok(p) => p,
874            Err(e) => {
875                error!(%e, "BinanceSpot failed to build cancel order params");
876                return Some(UnindexedOrderResponseCancel {
877                    key,
878                    state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
879                        e.to_string(),
880                    ))),
881                });
882            }
883        };
884
885        let sent_ms = unix_ms();
886        let result = ws.order_cancel(params).await;
887        // A WS-API response reports the shared spot pool's weight limit and usage.
888        if let Ok(response) = &result {
889            self.rate_limiter
890                .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
891        }
892        match result {
893            Ok(response) => match response.data() {
894                Ok(data) => {
895                    let time_exchange = data
896                        .transact_time
897                        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
898                        .unwrap_or_else(Utc::now);
899
900                    let exchange_order_id = match data.order_id {
901                        Some(id) => OrderId(format_smolstr!("{id}")),
902                        None => {
903                            error!("BinanceSpot cancel response missing orderId");
904                            return Some(UnindexedOrderResponseCancel {
905                                key,
906                                state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
907                                    "cancel response missing orderId".into(),
908                                ))),
909                            });
910                        }
911                    };
912
913                    let filled_qty = data
914                        .executed_qty
915                        .as_deref()
916                        .and_then(|q| Decimal::from_str(q).ok())
917                        .unwrap_or(Decimal::ZERO);
918
919                    Some(UnindexedOrderResponseCancel {
920                        key,
921                        state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
922                    })
923                }
924                Err(e) => {
925                    // serde_json deserialization failure on a successful response — not an API error
926                    Some(UnindexedOrderResponseCancel {
927                        key,
928                        state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
929                            e.to_string(),
930                        ))),
931                    })
932                }
933            },
934            Err(e) => {
935                // binance-sdk routes both transport failures and venue responses with status
936                // >= 400 (ResponseError) through this outer Err path. Distinguish them so a
937                // venue response (-2010, -1121, etc.) doesn't tear down a healthy WS session.
938                // A venue response is mostly a rejection, but one that leaves the order's
939                // status unknown (a venue-failure code, a 5xx) surfaces as Connectivity.
940                // Check the venue response first (zero-alloc downcast) — order rejections are
941                // the common case; rate limits during placement are rare.
942                if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
943                    // Venue response — WS session is healthy, don't tear it down. A throttle
944                    // (429, -1003, a WAF 403) backs off the shared limiter so REST calls
945                    // back off too.
946                    if matches!(order_err, OrderError::Rejected(ApiError::RateLimit)) {
947                        self.rate_limiter.on_rate_limited(None);
948                    }
949                    // if this is BalanceInsufficient, its AssetNameExchange field
950                    // holds the instrument name ("BTCUSDT"), not an asset name — see
951                    // parse_binance_api_error for details. Do not match on that field
952                    // to identify the low-balance asset.
953                    Some(UnindexedOrderResponseCancel {
954                        key,
955                        state: Err(order_err),
956                    })
957                } else {
958                    // Transport-level error — clear cached session so next call reconnects.
959                    // Order status is unknown (may or may not have reached the matching engine).
960                    self.clear_ws_api().await;
961                    Some(UnindexedOrderResponseCancel {
962                        key,
963                        state: Err(UnindexedOrderError::Connectivity(
964                            ConnectivityError::Socket(format!("{e:#}")),
965                        )),
966                    })
967                }
968            }
969        }
970    }
971
972    async fn open_order(
973        &self,
974        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
975    ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
976        let instrument = request.key.instrument.clone();
977        let side = request.state.side;
978        let price = request.state.price;
979        let quantity = request.state.quantity;
980        let kind = request.state.kind;
981        let time_in_force = request.state.time_in_force;
982        let cid = request.key.cid.clone();
983
984        let order_key = OrderKey::new(
985            ExchangeId::BinanceSpot,
986            instrument.clone(),
987            request.key.strategy.clone(),
988            cid.clone(),
989        );
990
991        let ws = match self.get_ws_api().await {
992            Ok(ws) => ws,
993            Err(unavailable) => {
994                return Some(Order {
995                    key: order_key,
996                    side,
997                    price,
998                    quantity,
999                    kind,
1000                    time_in_force,
1001                    state: OrderState::inactive(unavailable.into_order_error()),
1002                });
1003            }
1004        };
1005
1006        let binance_side = match side {
1007            Side::Buy => OrderPlaceSideEnum::Buy,
1008            Side::Sell => OrderPlaceSideEnum::Sell,
1009        };
1010
1011        let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
1012            Some(converted) => converted,
1013            None => {
1014                return Some(Order {
1015                    key: order_key,
1016                    side,
1017                    price,
1018                    quantity,
1019                    kind,
1020                    time_in_force,
1021                    state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1022                        "Binance Spot does not yet support OrderKind::{kind:?}"
1023                    ))),
1024                });
1025            }
1026        };
1027
1028        // market BUY sends base quantity, not quoteOrderQty. Callers must
1029        // specify how much of the base asset they want, not how much quote to spend.
1030        // The trait's OrderRequestOpen has a single `quantity` field so we can't
1031        // distinguish — this is a known semantic difference from Binance convention.
1032        // SDK constraint — OrderPlaceParams::builder takes String, not &str.
1033        // Allocates two Strings (symbol + client_order_id); unavoidable without SDK changes.
1034        let mut params_builder =
1035            OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
1036                .quantity(quantity)
1037                .new_client_order_id(cid.0.to_string());
1038
1039        // Set price, stop_price, and trailing_delta in a single exhaustive match
1040        // so that adding a new OrderKind variant fails to compile until handled here.
1041        match kind {
1042            OrderKind::Limit => {
1043                params_builder = params_builder.price(price);
1044            }
1045            OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
1046                params_builder = params_builder.stop_price(trigger_price);
1047            }
1048            OrderKind::StopLimit { trigger_price }
1049            | OrderKind::TakeProfitLimit { trigger_price } => {
1050                params_builder = params_builder.price(price).stop_price(trigger_price);
1051            }
1052            OrderKind::TrailingStop {
1053                offset,
1054                offset_type,
1055            } => {
1056                // Convert to basis points (i32). Binance trailingDelta is in basis points.
1057                let basis_points: i32 = match offset_type {
1058                    TrailingOffsetType::BasisPoints => {
1059                        let Ok(bp) = i32::try_from(offset) else {
1060                            return Some(Order {
1061                                key: order_key,
1062                                side,
1063                                price,
1064                                quantity,
1065                                kind,
1066                                time_in_force,
1067                                state: OrderState::inactive(OrderError::UnsupportedOrderType(
1068                                    format!(
1069                                        "TrailingStop basis-point offset {offset} overflows i32 \
1070                                         (Binance trailingDelta filter typically caps at 2000)"
1071                                    ),
1072                                )),
1073                            });
1074                        };
1075                        bp
1076                    }
1077                    TrailingOffsetType::Percentage => {
1078                        let Ok(bp) = i32::try_from(offset * Decimal::from(100)) else {
1079                            return Some(Order {
1080                                key: order_key,
1081                                side,
1082                                price,
1083                                quantity,
1084                                kind,
1085                                time_in_force,
1086                                state: OrderState::inactive(OrderError::UnsupportedOrderType(
1087                                    format!(
1088                                        "TrailingStop percentage offset {offset} overflows i32 \
1089                                         after scaling to basis points"
1090                                    ),
1091                                )),
1092                            });
1093                        };
1094                        bp
1095                    }
1096                    TrailingOffsetType::Absolute => {
1097                        // convert_order_kind_tif already rejects Absolute; surface a clean
1098                        // error here too rather than panic, so a future refactor that
1099                        // changes that contract still fails observably.
1100                        return Some(Order {
1101                            key: order_key,
1102                            side,
1103                            price,
1104                            quantity,
1105                            kind,
1106                            time_in_force,
1107                            state: OrderState::inactive(OrderError::UnsupportedOrderType(
1108                                "TrailingStop with Absolute offset is not supported by Binance; \
1109                                 convert to basis points: (absolute / price) * 10000"
1110                                    .into(),
1111                            )),
1112                        });
1113                    }
1114                };
1115                params_builder = params_builder.trailing_delta(basis_points);
1116            }
1117            // Market and TrailingStopLimit need no price/stop_price/trailing_delta here.
1118            // (TrailingStopLimit is already rejected by convert_order_kind_tif above.)
1119            // Wildcard catches them and any future variants added before this match is updated.
1120            _ => {}
1121        }
1122
1123        if let Some(tif) = binance_tif {
1124            params_builder = params_builder.time_in_force(tif);
1125        }
1126
1127        let params = match params_builder.build() {
1128            Ok(p) => p,
1129            Err(e) => {
1130                error!(%e, "BinanceSpot failed to build new order params");
1131                return Some(Order {
1132                    key: order_key,
1133                    side,
1134                    price,
1135                    quantity,
1136                    kind,
1137                    time_in_force,
1138                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1139                        e.to_string(),
1140                    ))),
1141                });
1142            }
1143        };
1144
1145        let sent_ms = unix_ms();
1146        let result = ws.order_place(params).await;
1147        // A WS-API response reports the shared spot pool's weight limit and usage.
1148        if let Ok(response) = &result {
1149            self.rate_limiter
1150                .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1151        }
1152        match result {
1153            Ok(response) => match response.data() {
1154                Ok(data) => {
1155                    let time_exchange = data
1156                        .transact_time
1157                        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1158                        .unwrap_or_else(Utc::now);
1159
1160                    let exchange_order_id = match data.order_id {
1161                        Some(id) => OrderId(format_smolstr!("{id}")),
1162                        None => {
1163                            error!("BinanceSpot open_order response missing orderId");
1164                            return Some(Order {
1165                                key: order_key,
1166                                side,
1167                                price,
1168                                quantity,
1169                                kind,
1170                                time_in_force,
1171                                state: OrderState::inactive(OrderError::Rejected(
1172                                    ApiError::OrderRejected(
1173                                        "open_order response missing orderId".into(),
1174                                    ),
1175                                )),
1176                            });
1177                        }
1178                    };
1179
1180                    let filled_qty = data
1181                        .executed_qty
1182                        .as_deref()
1183                        .and_then(|q| Decimal::from_str(q).ok())
1184                        .unwrap_or(Decimal::ZERO);
1185
1186                    let state = if filled_qty >= quantity {
1187                        // Order was fully filled immediately
1188                        OrderState::fully_filled(Filled::new(
1189                            exchange_order_id,
1190                            time_exchange,
1191                            filled_qty,
1192                            None, // Binance SDK response doesn't expose avg_price directly
1193                        ))
1194                    } else {
1195                        // Order is resting on the order book
1196                        OrderState::active(Open::new(
1197                            VenueOrderId::Assigned(exchange_order_id),
1198                            time_exchange,
1199                            filled_qty,
1200                        ))
1201                    };
1202
1203                    Some(Order {
1204                        key: order_key,
1205                        side,
1206                        price,
1207                        quantity,
1208                        kind,
1209                        time_in_force,
1210                        state,
1211                    })
1212                }
1213                Err(e) => {
1214                    // serde_json deserialization failure on a successful response — not an API error
1215                    Some(Order {
1216                        key: order_key,
1217                        side,
1218                        price,
1219                        quantity,
1220                        kind,
1221                        time_in_force,
1222                        state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1223                            e.to_string(),
1224                        ))),
1225                    })
1226                }
1227            },
1228            Err(e) => {
1229                // binance-sdk routes both transport failures and venue responses with status
1230                // >= 400 (ResponseError) through this outer Err path. Distinguish them so a
1231                // venue response (-2010, -1121, etc.) doesn't tear down a healthy WS session.
1232                // A venue response is mostly a rejection, but one that leaves the order's
1233                // status unknown (a venue-failure code, a 5xx) surfaces as Connectivity.
1234                // Check the venue response first (zero-alloc downcast) — order rejections are
1235                // the common case; rate limits during placement are rare.
1236                if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1237                    // Venue response — WS session is healthy, don't tear it down. A throttle
1238                    // (429, -1003, a WAF 403) backs off the shared limiter so REST calls
1239                    // back off too.
1240                    if matches!(order_err, OrderError::Rejected(ApiError::RateLimit)) {
1241                        self.rate_limiter.on_rate_limited(None);
1242                    }
1243                    // if this is BalanceInsufficient, its AssetNameExchange field
1244                    // holds the instrument name ("BTCUSDT"), not an asset name — see
1245                    // parse_binance_api_error for details. Do not match on that field
1246                    // to identify the low-balance asset.
1247                    Some(Order {
1248                        key: order_key,
1249                        side,
1250                        price,
1251                        quantity,
1252                        kind,
1253                        time_in_force,
1254                        state: OrderState::inactive(order_err),
1255                    })
1256                } else {
1257                    // Transport-level error — clear cached session so next call reconnects.
1258                    // Order status is unknown (may or may not have reached the matching engine).
1259                    self.clear_ws_api().await;
1260                    Some(Order {
1261                        key: order_key,
1262                        side,
1263                        price,
1264                        quantity,
1265                        kind,
1266                        time_in_force,
1267                        state: OrderState::inactive(OrderError::Connectivity(
1268                            ConnectivityError::Socket(format!("{e:#}")),
1269                        )),
1270                    })
1271                }
1272            }
1273        }
1274    }
1275
1276    async fn fetch_balances(
1277        &self,
1278        assets: &[AssetNameExchange],
1279    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1280        let response =
1281            rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1282                Box::pin(async move {
1283                    let params = GetAccountParams::builder().build()?;
1284                    rest.get_account(params).await
1285                })
1286            })
1287            .await
1288            .map_err(|e| classify_rest_query_error(&e, None))?;
1289
1290        let account = response.data().await.map_err(response_decode_error)?;
1291
1292        Ok(filter_and_convert_balances(
1293            account.balances.unwrap_or_default(),
1294            assets,
1295        ))
1296    }
1297
1298    /// An empty `instruments` slice is the [`ExecutionClient`] "return all" sentinel: a single
1299    /// no-symbol `GET /api/v3/openOrders` call returns open orders across every instrument (each
1300    /// order's instrument is recovered from its own `symbol` field). A non-empty slice fetches the
1301    /// listed instruments concurrently, per-symbol.
1302    async fn fetch_open_orders(
1303        &self,
1304        instruments: &[InstrumentNameExchange],
1305    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1306        // Empty slice = "return all" sentinel: a single no-symbol query is both correct (the
1307        // contract requires all instruments) and far cheaper than enumerating every symbol.
1308        if instruments.is_empty() {
1309            return fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await;
1310        }
1311        // limit concurrency to avoid bursting Binance's request weight limits
1312        // (each GET /api/v3/openOrders costs 3 weight; 8 concurrent = 24 weight).
1313        // try_fold into a flat Vec avoids the intermediate Vec<Vec<_>> that
1314        // try_collect().flatten() would allocate.
1315        use futures::{StreamExt as _, TryStreamExt as _};
1316        futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1317            fetch_open_orders_for_instrument(
1318                self.rest.clone(),
1319                self.rate_limiter.clone(),
1320                instrument,
1321            )
1322        }))
1323            .buffer_unordered(8)
1324            .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1325                acc.extend(listing.orders);
1326                Ok(acc)
1327            })
1328            .await
1329    }
1330
1331    /// **Documented deviation from the `ExecutionClient::fetch_trades` "return all" contract:**
1332    /// Binance's `myTrades` endpoint requires a symbol — there is no no-symbol "all trades" query
1333    /// (unlike open orders). An empty `instruments` slice therefore has nothing to query and
1334    /// returns an empty `Vec`; callers wanting all trades must enumerate instruments explicitly.
1335    // `.iter().cloned()` is required: Rust async closures cannot satisfy the HRTB
1336    // `for<'a> FnMut(&'a InstrumentNameExchange) -> impl Future + 'static` needed by
1337    // the iterator machinery, even when the clone is moved inside the closure body.
1338    #[allow(clippy::redundant_iter_cloned)]
1339    async fn fetch_trades(
1340        &self,
1341        time_since: DateTime<Utc>,
1342        instruments: &[InstrumentNameExchange],
1343    ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1344        use futures::StreamExt;
1345
1346        if instruments.is_empty() {
1347            debug!(
1348                "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1349            );
1350            return Ok(Vec::new());
1351        }
1352        let start_time_ms = time_since.timestamp_millis();
1353        // Vec::new() — capacity(instruments.len()) would be misleading since this accumulates
1354        // up to BINANCE_MAX_TRADES * instruments.len() trades total.
1355        let mut all_trades = Vec::new();
1356
1357        // Binance requires per-symbol queries for trade history.
1358        // Limit concurrency to avoid bursting Binance's request weight limits
1359        // (each GET /api/v3/myTrades costs 20 weight; 8 concurrent = 160 weight).
1360        let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1361            let rest = self.rest.clone();
1362            let rate_limiter = self.rate_limiter.clone();
1363            async move {
1364                let pages = paginate_my_trades(
1365                    &rest,
1366                    &rate_limiter,
1367                    &inst,
1368                    MyTradesFrom::Time(start_time_ms),
1369                    RequestKind::Query,
1370                )
1371                .await?;
1372                Ok::<_, UnindexedClientError>((inst, pages))
1373            }
1374        }))
1375        .buffer_unordered(8);
1376        while let Some(result) = stream.next().await {
1377            let (instrument, trades_data) = result?;
1378            for t in trades_data {
1379                if let Some(trade) = convert_my_trade(&t, &instrument) {
1380                    all_trades.push(trade);
1381                }
1382            }
1383        }
1384
1385        Ok(all_trades)
1386    }
1387}
1388
1389// ---------------------------------------------------------------------------
1390// Connection manager (reconnection, heartbeat, fill recovery)
1391// ---------------------------------------------------------------------------
1392
1393/// Connect to Binance WS and subscribe to the user data stream.
1394/// On failure, disconnects the WS to avoid leaking the TCP connection.
1395async fn connect_and_subscribe(ws_handle: &WebsocketApiHandle) -> anyhow::Result<WebsocketApi> {
1396    let ws = ws_handle.connect().await?;
1397    #[allow(clippy::expect_used)] // Builder has no required fields; infallible
1398    let params = UserDataStreamSubscribeSignatureParams::builder()
1399        .build()
1400        .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1401    match ws.user_data_stream_subscribe_signature(params).await {
1402        Ok(_) => Ok(ws),
1403        Err(e) => {
1404            warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1405            let ws_cleanup = ws;
1406            // fire-and-forget disconnect — the JoinHandle is intentionally
1407            // dropped. Unlike `clear_ws_api` (which awaits inline to prevent unbounded
1408            // task accumulation under rapid retries), here we're on the connect failure
1409            // path: at most 3 cleanup tasks may overlap during early attempts (1s, 2s,
1410            // 4s backoff < 5s cleanup timeout); starting at attempt 3 the backoff
1411            // (8s) exceeds the cleanup timeout so no further accumulation occurs.
1412            // Each cleanup task is bounded to 5s — acceptable accumulation.
1413            // If the Tokio runtime shuts down before the task completes, the task is
1414            // cancelled and the TCP socket is reclaimed by the OS.
1415            tokio::spawn(async move {
1416                match tokio::time::timeout(Duration::from_secs(5), ws_cleanup.disconnect()).await {
1417                    Ok(Err(dc_err)) => warn!(%dc_err, "BinanceSpot WS cleanup disconnect failed"),
1418                    Err(_) => warn!("BinanceSpot WS cleanup disconnect timed out (5s)"),
1419                    Ok(Ok(())) => {}
1420                }
1421            });
1422            Err(e)
1423        }
1424    }
1425}
1426
1427/// Long-running task that manages the WebSocket lifecycle for account_stream.
1428///
1429/// Drives the reconnection loop: connect → subscribe → stream events → on disconnect
1430/// → backoff → fill recovery → reconnect. The `tx` channel persists across reconnections
1431/// so the consumer sees a seamless event stream.
1432///
1433/// Terminates when:
1434/// - The consumer drops the stream (receiver side of `tx` is closed)
1435/// - Max reconnect attempts are exhausted
1436///
1437/// # Panics
1438///
1439/// This function is spawned via `tokio::spawn`. If it panics, Tokio surfaces the panic
1440/// via the `JoinHandle`. Because the handle is transferred to `AbortOnDropStream` and
1441/// dropped, the panic is discarded at drop — the consumer will observe the
1442/// `UnboundedReceiverStream` ending (yielding `None`), indistinguishable from normal
1443/// max-reconnect exhaustion. The WS subscription cleanup (`subscription.unsubscribe()`)
1444/// at the end of the loop body will be skipped on panic. If you change this to `.await`
1445/// the handle, check `JoinError::is_panic()`.
1446// inherent complexity from reconnection loop (connect → subscribe → callback →
1447// fill recovery → heartbeat monitor → cleanup → backoff). Not worth splitting further.
1448#[allow(clippy::cognitive_complexity)]
1449async fn connection_manager(
1450    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1451    dedup: SharedDedupCache,
1452    ws_handle: WebsocketApiHandle,
1453    rest: Arc<RestApi>,
1454    rate_limiter: Arc<RateLimitTracker>,
1455    instruments: Vec<InstrumentNameExchange>,
1456    initial_ws: Option<WebsocketApi>,
1457) {
1458    let mut backoff = ExponentialBackoff::new();
1459    let mut disconnect_time: Option<DateTime<Utc>> = None;
1460    // Gaps not yet recovered: read at reconnect, and retried on a timer while connected, once due.
1461    let mut unrecovered = UnrecoveredFills::default();
1462    let mut current_ws = initial_ws;
1463
1464    loop {
1465        // --- Connect (skip on first iteration if initial_ws was provided) ---
1466        let ws = match current_ws.take() {
1467            Some(ws) => ws,
1468            None => match connect_and_subscribe(&ws_handle).await {
1469                // backoff is NOT reset here — resetting on TCP-connect success
1470                // would lock retry intervals at INITIAL_BACKOFF_MS forever when the
1471                // server closes within the first heartbeat window (auth rejection,
1472                // server-side close). Reset is deferred to the monitor loop after the
1473                // first heartbeat interval survives (proven-stable connection).
1474                Ok(ws) => ws,
1475                Err(e) => {
1476                    error!(%e, "BinanceSpot WS connect/subscribe failed");
1477                    if !backoff.wait().await {
1478                        error!("BinanceSpot max reconnect attempts exhausted");
1479                        // Signal terminal stream death in-band before tx drops.
1480                        emit_stream_terminated(
1481                            &tx,
1482                            ExchangeId::BinanceSpot,
1483                            StreamTerminationReason::ReconnectBudgetExhausted {
1484                                attempts: backoff.attempts(),
1485                                last_error: e.to_string(),
1486                            },
1487                        );
1488                        break;
1489                    }
1490                    continue;
1491                }
1492            },
1493        };
1494        info!("BinanceSpot account_stream connected and subscribed");
1495
1496        // --- Set up WS event callback BEFORE fill recovery ---
1497        // binance-sdk silently drops WS messages with no registered subscriber.
1498        // Register the callback first so events arriving during fill recovery (REST)
1499        // are captured. The dedup cache prevents duplicates between live events and
1500        // recovered fills.
1501        let (signal_tx, signal_rx) = oneshot::channel::<()>();
1502        let mut signal_tx_opt = Some(signal_tx);
1503        // start as true — grants the connection one full heartbeat window before
1504        // requiring activity. A slow first ping from Binance would otherwise trigger a
1505        // false-positive timeout and unnecessary reconnect on the very first check.
1506        let heartbeat_flag = Arc::new(AtomicBool::new(true));
1507        let hb_callback = heartbeat_flag.clone();
1508        let dedup_callback = dedup.clone();
1509        // tx: used directly by recover_fills and the heartbeat/consumer-drop monitor.
1510        // event_tx: cloned into the WS callback closure (taken on first send failure or
1511        // stream termination so the callback becomes a no-op after disconnect).
1512        let mut event_tx = Some(tx.clone());
1513        // 32: covers typical multi-asset accounts without excessive over-allocation;
1514        // outboundAccountPosition emits one entry per asset, so 8 would reallocate
1515        // for accounts with more than 8 assets.
1516        let mut event_buf = Vec::with_capacity(32);
1517
1518        // Safety — `signal_tx_opt.take()` and `event_tx.take()` are non-atomic,
1519        // which is safe because binance-sdk spawns one tokio::spawn'd task per
1520        // subscription; that task processes events sequentially from an internal channel,
1521        // so FnMut callbacks are never invoked concurrently per subscription. If the SDK
1522        // changes to concurrent callbacks, these `Option::take()` calls would need a Mutex.
1523        // verified against binance-sdk =50.0.0. Re-verify on SDK upgrade.
1524        let subscription = ws.subscribe_on_ws_events(move |event| {
1525            let Some(ref sender) = event_tx else { return };
1526            match event {
1527                WebsocketEvent::Message(json_str) => {
1528                    // Release: pairs with Acquire swap in monitor task so the stored
1529                    // `true` is visible before the monitor swaps in `false`.
1530                    hb_callback.store(true, Ordering::Release);
1531                    // Borrowed discriminator + matched-variant parse (no full Value DOM).
1532                    // RPC responses (the subscribe ack) are ignored inside the converter, and
1533                    // unrecognised frames are logged there (throttled warn).
1534                    let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1535                    for ev in event_buf.drain(..) {
1536                        // Dedup check
1537                        if let Some(key) = dedup_key_from_event(&ev)
1538                            && is_duplicate(&dedup_callback, key)
1539                        {
1540                            trace!("BinanceSpot dedup: skipping duplicate event");
1541                            continue;
1542                        }
1543                        if sender.send(ev).is_err() {
1544                            warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1545                            event_tx.take();
1546                            if let Some(s) = signal_tx_opt.take() {
1547                                let _ = s.send(());
1548                            }
1549                            return;
1550                        }
1551                    }
1552                    // eventStreamTerminated arrives as a JSON message,
1553                    // not a WS close frame — signal reconnect explicitly.
1554                    if stream_terminated {
1555                        event_tx.take();
1556                        if let Some(s) = signal_tx_opt.take() {
1557                            let _ = s.send(());
1558                        }
1559                    }
1560                }
1561                WebsocketEvent::Ping | WebsocketEvent::Pong => {
1562                    // SDK handles ping/pong at protocol level; we just track activity.
1563                    // Release: pairs with Acquire swap in monitor task (same as Message handler).
1564                    hb_callback.store(true, Ordering::Release);
1565                }
1566                WebsocketEvent::Error(e) => {
1567                    // warn! not error! — a transient WS error that triggers
1568                    // auto-reconnect is recoverable. error! is reserved for failures
1569                    // that exhaust reconnect attempts (logged in connection_manager).
1570                    warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1571                    event_tx.take();
1572                    if let Some(s) = signal_tx_opt.take() {
1573                        let _ = s.send(());
1574                    }
1575                }
1576                WebsocketEvent::Close(code, reason) => {
1577                    warn!(code, %reason, "BinanceSpot WebSocket closed");
1578                    event_tx.take();
1579                    if let Some(s) = signal_tx_opt.take() {
1580                        let _ = s.send(());
1581                    }
1582                }
1583                _ => {}
1584            }
1585        });
1586
1587        // --- Fill recovery after reconnect (with timeout to avoid blocking forever) ---
1588        // Runs after subscribe_on_ws_events so live events during recovery are captured.
1589        //
1590        // WARNING — only fills (GET /api/v3/myTrades) are recovered here.
1591        // Order lifecycle events (NEW, CANCELED, EXPIRED) that occurred during the
1592        // disconnect window are NOT recovered. Open-order state may be stale until
1593        // the next account_snapshot or fetch_open_orders call. A caller MUST call
1594        // fetch_open_orders after each reconnect to reconcile open-order state.
1595        // The gap is opened before it is read, so a recovery that fails or times out keeps it.
1596        if let Some(dt) = disconnect_time.take() {
1597            unrecovered.open(&instruments, dt, Utc::now());
1598        }
1599        recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup).await;
1600
1601        // --- Monitor: wait for disconnect, heartbeat timeout, or consumer drop ---
1602        enum DisconnectReason {
1603            Signal,
1604            HeartbeatTimeout,
1605            ConsumerDropped,
1606        }
1607        let reason = {
1608            let mut signal_rx = signal_rx;
1609            // Gaps a recovery did not read are retried as they fall due, alongside the monitor, so
1610            // a disconnect is still seen at once. It never completes; dropping it when the monitor
1611            // ends loses nothing, since each gap is settled as soon as its read ends.
1612            let retry_gaps = async {
1613                loop {
1614                    match unrecovered.next_due() {
1615                        Some(due) => tokio::time::sleep_until(due).await,
1616                        None => std::future::pending::<()>().await,
1617                    }
1618                    recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup).await;
1619                }
1620            };
1621            tokio::pin!(retry_gaps);
1622            loop {
1623                tokio::select! {
1624                    // Biased: a consumer drop is terminal and wins; the gap retry is polled last.
1625                    biased;
1626                    _ = tx.closed() => {
1627                        debug!("BinanceSpot account_stream consumer dropped, terminating");
1628                        break DisconnectReason::ConsumerDropped;
1629                    }
1630                    _ = &mut signal_rx => {
1631                        warn!("BinanceSpot WS disconnected, will attempt reconnect");
1632                        break DisconnectReason::Signal;
1633                    }
1634                    _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1635                        // AcqRel on the swap: the Acquire half synchronizes with the
1636                        // callback's Release store of `true`, so if we read `true` we
1637                        // know the callback's side effects are visible. The Release half
1638                        // is a no-op here since no other thread reads the `false` we
1639                        // write back — but AcqRel is the semantically correct ordering
1640                        // for a read-modify-write and protects against future readers.
1641                        if heartbeat_flag.swap(false, Ordering::AcqRel) {
1642                            // Activity detected: connection is proven stable for one full
1643                            // heartbeat window — safe to reset reconnect backoff so a
1644                            // stable connection doesn't carry over prior failure counts.
1645                            backoff.reset();
1646                            continue;
1647                        }
1648                        warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1649                        break DisconnectReason::HeartbeatTimeout;
1650                    }
1651                    // Last: the arms above end the monitor and win when ready together.
1652                    () = &mut retry_gaps => {}
1653                }
1654            }
1655        };
1656        let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1657
1658        // record disconnect time BEFORE cleanup so fill recovery covers
1659        // the full gap. For heartbeat timeouts the connection may have died up to
1660        // HEARTBEAT_TIMEOUT_SECS ago, so subtract that as a safety margin.
1661        // For signal-based disconnects (WS close/error/stream terminated), subtract a
1662        // small margin to cover Tokio scheduling jitter between the WS close event and
1663        // the monitor task recording Utc::now(). Dedup cache handles any resulting duplicates.
1664        if should_reconnect {
1665            disconnect_time = Some(match reason {
1666                DisconnectReason::HeartbeatTimeout => {
1667                    Utc::now()
1668                        - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1669                        - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1670                }
1671                _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1672            });
1673        }
1674
1675        // --- Cleanup current connection ---
1676        // must explicitly unsubscribe — Subscription::drop only detaches the
1677        // internal JoinHandle, it doesn't abort it.
1678        // assumption (verified against binance-sdk =50.0.0): unsubscribe() stops
1679        // the callback task before returning, so no further invocations of the FnMut
1680        // callback occur after this point. The old `heartbeat_flag` Arc is therefore
1681        // safe to drop here. Re-verify on SDK upgrade if the callback invocation model changes.
1682        subscription.unsubscribe();
1683        // binance-sdk WebsocketApi has no Drop impl that closes the TCP
1684        // connection — must call disconnect() explicitly
1685        if let Err(e) = ws.disconnect().await {
1686            warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1687        }
1688
1689        if !should_reconnect || tx.is_closed() {
1690            // Consumer dropped the receiver — no StreamTerminated emit (the channel is already
1691            // closed, so it would be a no-op; see emit_stream_terminated docs).
1692            debug!("BinanceSpot connection manager exiting");
1693            break;
1694        }
1695        if !backoff.wait().await {
1696            error!("BinanceSpot max reconnect attempts exhausted, stream terminating");
1697            // Surrender after repeated reconnects: the most recent failure is the disconnect
1698            // that triggered this round (ConsumerDropped already broke out above).
1699            let last_error = match reason {
1700                DisconnectReason::HeartbeatTimeout => {
1701                    format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
1702                }
1703                _ => "WebSocket disconnected (server close/error)".to_string(),
1704            };
1705            emit_stream_terminated(
1706                &tx,
1707                ExchangeId::BinanceSpot,
1708                StreamTerminationReason::ReconnectBudgetExhausted {
1709                    attempts: backoff.attempts(),
1710                    last_error,
1711                },
1712            );
1713            break;
1714        }
1715    }
1716}
1717
1718/// Recover the fills of every gap in `unrecovered` that is due: the fills missed while the stream
1719/// was disconnected.
1720///
1721/// Reads each gap's span by REST and sends its trades through the dedup cache, so a trade already
1722/// delivered is not sent again. A gap whose fills are all forwarded leaves `unrecovered`; one whose
1723/// read fails or does not finish is retried later, until it is given up (see
1724/// [`UnrecoveredFills`]). Each gap is settled as soon as its read ends, so dropping this future
1725/// part-way, as a disconnect during a retry does, loses nothing.
1726///
1727/// `myTrades` reports executions only, with no cumulative, so each recovered trade's
1728/// `order_filled_quantity` is rebuilt from its order's executions by
1729/// [`recovered_order_totals`]. A trade whose order could not be looked up keeps `None`.
1730///
1731/// The whole recovery is bounded by [`FILL_RECOVERY_TIMEOUT_SECS`]. Fills forwarded before the
1732/// deadline stay delivered. Its reads are [`RequestKind::Essential`]: they wait out a rate-limit
1733/// cooldown but not the pause near the weight limit, which could outlast the budget.
1734async fn recover_fills(
1735    rest: &Arc<RestApi>,
1736    rate_limiter: &Arc<RateLimitTracker>,
1737    unrecovered: &mut UnrecoveredFills,
1738    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1739    dedup: &SharedDedupCache,
1740) {
1741    use futures::StreamExt;
1742
1743    let due = unrecovered.due(tokio::time::Instant::now());
1744    let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
1745        return;
1746    };
1747    info!(
1748        gaps = due.len(),
1749        oldest = %gap_time(oldest),
1750        "BinanceSpot recovering fills missed while disconnected"
1751    );
1752
1753    // Which gaps of `due` have been settled, recovered or failed, and how many have started: the
1754    // stream starts them in order, eight at a time, so those from `started` on were never read.
1755    let mut settled = vec![false; due.len()];
1756    let started = AtomicUsize::new(0);
1757    let recovery = async {
1758        let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
1759        let mut recovered = 0u32;
1760        let mut duplicates = 0u32;
1761
1762        // limit concurrency to avoid bursting Binance's request weight limits
1763        // (each GET /api/v3/myTrades costs 20 weight; 8 concurrent = 160 weight).
1764        // pagination is critical for fill recovery — a missed page means a gap read short.
1765        // paginate_my_trades handles the full cursor-based pagination loop shared with
1766        // fetch_trades.
1767        let mut stream =
1768            futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
1769                started.store(index + 1, Ordering::Relaxed);
1770                let rest = rest.clone();
1771                let rl = rate_limiter.clone();
1772                async move {
1773                    let span = MyTradesFrom::Span {
1774                        start: gap.start_ms,
1775                        end: gap.end_ms,
1776                    };
1777                    let raw =
1778                        match paginate_my_trades(&rest, &rl, &inst, span, RequestKind::Essential)
1779                            .await
1780                        {
1781                            Ok(pages) => pages,
1782                            Err(e) => return (index, Err(e)),
1783                        };
1784                    // `myTrades` carries no cumulative, so each recovered fill's is rebuilt from
1785                    // its order's executions; without it the fill advances the position but not
1786                    // the order.
1787                    let totals = recovered_order_totals(
1788                        ExchangeId::BinanceSpot,
1789                        &inst,
1790                        &raw,
1791                        order_executions_deadline,
1792                        |order_id| {
1793                            paginate_my_trades(
1794                                &rest,
1795                                &rl,
1796                                &inst,
1797                                MyTradesFrom::Order(order_id),
1798                                RequestKind::Essential,
1799                            )
1800                        },
1801                    )
1802                    .await;
1803                    let trades: Vec<_> = raw
1804                        .iter()
1805                        .filter_map(|t| {
1806                            let mut trade = convert_my_trade(t, &inst)?;
1807                            trade.order_filled_quantity =
1808                                t.id.and_then(|id| totals.get(&id).copied());
1809                            Some(trade)
1810                        })
1811                        .collect();
1812                    (index, Ok(trades))
1813                }
1814            }))
1815            .buffer_unordered(8);
1816        while let Some((index, result)) = stream.next().await {
1817            let (inst, gap) = &due[index];
1818            settled[index] = true;
1819            let trades = match result {
1820                Ok(trades) => trades,
1821                Err(e) => {
1822                    gap_failed(
1823                        unrecovered,
1824                        ExchangeId::BinanceSpot,
1825                        inst,
1826                        gap,
1827                        &e.to_string(),
1828                    );
1829                    continue;
1830                }
1831            };
1832            for trade in trades {
1833                // Construct the event first so dedup_key_from_event can be reused,
1834                // keeping key construction in one place.
1835                let event = UnindexedAccountEvent::new(
1836                    ExchangeId::BinanceSpot,
1837                    AccountEventKind::Trade(trade),
1838                );
1839                // Only Trade events are deduped during recovery — we don't recover NEW/CANCELLED
1840                // lifecycle events here (those require fetch_open_orders reconciliation).
1841                if let Some(key) = dedup_key_from_event(&event)
1842                    && is_duplicate(dedup, key)
1843                {
1844                    duplicates += 1;
1845                    continue;
1846                }
1847                if tx.send(event).is_err() {
1848                    // early return on consumer drop — no point recovering remaining
1849                    // gaps if the receiver is gone.
1850                    debug!("BinanceSpot fill recovery: consumer dropped during recovery");
1851                    return;
1852                }
1853                recovered += 1;
1854            }
1855            unrecovered.recovered(inst, gap);
1856        }
1857        info!(recovered, duplicates, "BinanceSpot fill recovery complete");
1858    };
1859    // A timeout drops `recovery` at an await, between gaps: one gap's fills are sent without
1860    // awaiting, so each is either fully forwarded and settled, or not forwarded at all. A gap
1861    // whose read started and did not finish has failed; one never started stays due as it was.
1862    if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
1863        .await
1864        .is_err()
1865    {
1866        let started = started.load(Ordering::Relaxed);
1867        for ((inst, gap), _) in due[..started]
1868            .iter()
1869            .zip(&settled)
1870            .filter(|(_, settled)| !**settled)
1871        {
1872            gap_failed(
1873                unrecovered,
1874                ExchangeId::BinanceSpot,
1875                inst,
1876                gap,
1877                "fill recovery timed out",
1878            );
1879        }
1880    }
1881}
1882
1883// ---------------------------------------------------------------------------
1884// Type conversion helpers
1885// ---------------------------------------------------------------------------
1886
1887/// Filter Binance account balances to the requested assets and convert to rustrade types.
1888///
1889/// If `assets` is empty, **all** balances are returned (no filter applied). This matches
1890/// the `ExecutionClient::account_snapshot` contract where an empty slice means "all assets."
1891///
1892/// Zero-balance entries (`free == 0` and `locked == 0`) are **intentionally included**.
1893/// Binance's `GET /api/v3/account` returns every asset the account has ever touched,
1894/// including those with zero balance. The engine or caller is responsible for filtering
1895/// if zero-balance assets are not desired.
1896fn convert_balance_entry(
1897    b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
1898    now: chrono::DateTime<Utc>,
1899) -> Option<AssetBalance<AssetNameExchange>> {
1900    let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
1901    let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1902        Some(v) => v,
1903        None => {
1904            warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
1905            return None;
1906        }
1907    };
1908    let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1909        Some(v) => v,
1910        None => {
1911            warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
1912            return None;
1913        }
1914    };
1915    Some(AssetBalance::new(
1916        asset_name,
1917        Balance::new(free + locked, free),
1918        now,
1919    ))
1920}
1921
1922fn filter_and_convert_balances(
1923    balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
1924    assets: &[AssetNameExchange],
1925) -> Vec<AssetBalance<AssetNameExchange>> {
1926    let now = Utc::now();
1927    // Empty assets slice means "return all" — skip building the set entirely.
1928    if assets.is_empty() {
1929        return balances
1930            .into_iter()
1931            .filter_map(|b| convert_balance_entry(b, now))
1932            .collect();
1933    }
1934    // For small slices (≤16 assets), linear scan avoids allocation and hashing overhead.
1935    // For larger slices, HashSet O(1) lookup amortizes the construction cost.
1936    if assets.len() <= 16 {
1937        return balances
1938            .into_iter()
1939            .filter_map(|b| {
1940                let asset_name_str = b.asset.as_deref()?;
1941                if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
1942                    return None;
1943                }
1944                convert_balance_entry(b, now)
1945            })
1946            .collect();
1947    }
1948    use std::collections::HashSet;
1949    let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
1950    balances
1951        .into_iter()
1952        .filter_map(|b| {
1953            let asset_name_str = b.asset.as_deref()?;
1954            if !asset_set.contains(asset_name_str) {
1955                return None;
1956            }
1957            convert_balance_entry(b, now)
1958        })
1959        .collect()
1960}
1961
1962/// Convert a Binance myTrades REST response into a rustrade Trade.
1963fn convert_my_trade(
1964    t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
1965    instrument: &InstrumentNameExchange,
1966) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
1967    let trade_id_raw = match t.id {
1968        Some(id) => id,
1969        None => {
1970            warn!(%instrument, "BinanceSpot trade missing id");
1971            return None;
1972        }
1973    };
1974    let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
1975    let order_id = match t.order_id {
1976        Some(id) => OrderId(format_smolstr!("{}", id)),
1977        None => {
1978            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
1979            return None;
1980        }
1981    };
1982    let side = match t.is_buyer {
1983        Some(is_buyer) => {
1984            if is_buyer {
1985                Side::Buy
1986            } else {
1987                Side::Sell
1988            }
1989        }
1990        None => {
1991            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
1992            return None;
1993        }
1994    };
1995    let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1996        Some(v) => v,
1997        None => {
1998            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
1999            return None;
2000        }
2001    };
2002    let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2003        Some(v) => v,
2004        None => {
2005            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2006            return None;
2007        }
2008    };
2009    let commission = t
2010        .commission
2011        .as_deref()
2012        .and_then(|s| Decimal::from_str(s).ok())
2013        .unwrap_or(Decimal::ZERO);
2014    let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
2015        Some(ts) => ts,
2016        None => {
2017            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2018            Utc::now()
2019        }
2020    };
2021
2022    // Use actual commission asset from Binance (e.g., BNB, USDT, BTC).
2023    // fees_quote is set to None here; the indexer will compute it if fee is in
2024    // quote or base asset. For third-party assets (BNB), downstream must convert.
2025    // "UNKNOWN" fallback (rare: API omits commission_asset) will fail indexing.
2026    let fee_asset = t
2027        .commission_asset
2028        .as_deref()
2029        .map(AssetNameExchange::from)
2030        .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2031
2032    Some(Trade::new(
2033        trade_id,
2034        order_id,
2035        instrument.clone(),
2036        StrategyId::unknown(), // Binance doesn't carry strategy IDs
2037        time_exchange,
2038        side,
2039        price,
2040        quantity,
2041        // `myTrades` reports executions only -- no cumulative, no order status.
2042        None,
2043        AssetFees::new(fee_asset, commission, None),
2044    ))
2045}
2046
2047/// Convert a raw BinanceSpot user-data WS frame to rustrade AccountEvents.
2048///
2049/// Pushes into the provided buffer to avoid per-message heap allocation.
2050/// A single Binance event (e.g., outboundAccountPosition) may map to multiple
2051/// rustrade events (one per asset balance).
2052///
2053/// Returns `true` if the stream should be considered terminated (requires reconnect).
2054///
2055/// # Frame shape
2056///
2057/// The subscription is WS-API `userDataStream.subscribe.signature`, so each pushed event arrives
2058/// wrapped as `{ "subscriptionId", "event": { "e", .. } }`, and binance-sdk passes the frame on
2059/// unchanged; [`parse_user_data_frame`] unwraps it, as for margin. RPC responses (the subscribe
2060/// acknowledgement) are ignored. A frame of any other shape, including an event without a
2061/// readable `e` tag, is ignored and logged by [`log_unrecognised_frame`] (throttled `warn`), so a
2062/// change in delivery cannot drop events silently. Unknown event types inside the envelope are
2063/// ignored at `trace`.
2064///
2065/// # Hot path
2066///
2067/// Reads the `e` discriminator from a borrowed view of the frame, then deserializes **only**
2068/// the matched variant straight from the inner event slice — avoiding the full
2069/// `serde_json::Value` DOM that `UserDataStreamEventsResponse`'s `#[serde(try_from = "Value")]`
2070/// would build for every inbound frame.
2071fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2072    let (event_type, event) = match parse_user_data_frame(frame) {
2073        UserDataFrame::Response => return false,
2074        UserDataFrame::Event {
2075            event_type, event, ..
2076        } => (event_type, event),
2077        UserDataFrame::Unrecognised => {
2078            static SEEN: AtomicU64 = AtomicU64::new(0);
2079            log_unrecognised_frame("BinanceSpot", &SEEN, frame);
2080            return false;
2081        }
2082    };
2083    // Tag values match `UserDataStreamEventsResponse`'s `try_from = "Value"` arms (binance-sdk).
2084    match event_type {
2085        "executionReport" => {
2086            // Single typed pass straight from the inner event — no intermediate DOM, and only the
2087            // matched branch deserializes its payload. The SDK struct ignores the unknown `e` tag.
2088            match serde_json::from_str::<binance_sdk::spot::websocket_api::ExecutionReport>(event) {
2089                Ok(report) => {
2090                    convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
2091                }
2092                Err(e) => {
2093                    warn!(error = %e, "BinanceSpot: undeserializable executionReport, dropping")
2094                }
2095            }
2096            false
2097        }
2098        "outboundAccountPosition" => {
2099            match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2100                event,
2101            ) {
2102                Ok(position) => convert_account_position(position, buf),
2103                Err(e) => {
2104                    warn!(error = %e, "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2105                }
2106            }
2107            false
2108        }
2109        "balanceUpdate" => {
2110            // balanceUpdate events are for deposits/withdrawals;
2111            // outboundAccountPosition covers balance changes from trades.
2112            // deposit/withdrawal balance changes are not forwarded to the consumer.
2113            // A caller should call fetch_balances or account_snapshot periodically to
2114            // reconcile balances after external transfers.
2115            // No log here: this is the per-frame receive hot path.
2116            false
2117        }
2118        "eventStreamTerminated" => {
2119            // Binance sends eventStreamTerminated as a JSON message, not a WS
2120            // close frame. Without signalling reconnect here, the stream silently dies
2121            // while heartbeat ping/pong keeps the connection alive.
2122            warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2123            true
2124        }
2125        // listStatus, externalLockUpdate, and any future/unknown event types: harmless
2126        // fall-through (observable at trace, never tears down the stream).
2127        _ => {
2128            trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2129            false
2130        }
2131    }
2132}
2133
2134/// Convert a Binance outboundAccountPosition to balance stream-update events.
2135///
2136/// Emits one `BalanceStreamUpdate` event per asset (the WS message is a `free`/`locked` partial,
2137/// not a full snapshot). Pushes into the provided buffer to avoid per-message allocation.
2138fn convert_account_position(
2139    position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2140    buf: &mut Vec<UnindexedAccountEvent>,
2141) {
2142    // Use field `u` (last account update time) rather than `E` (event time)
2143    // for more accurate balance timestamps
2144    let time_exchange = position
2145        .u
2146        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2147        .unwrap_or_else(Utc::now);
2148
2149    for b in position.b_uppercase.unwrap_or_default() {
2150        let asset = match b.a {
2151            Some(a) => AssetNameExchange::new(a),
2152            None => {
2153                warn!("BinanceSpot account position entry missing asset name");
2154                continue;
2155            }
2156        };
2157        let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2158            Some(v) => v,
2159            None => {
2160                warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2161                continue;
2162            }
2163        };
2164        let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2165            Some(v) => v,
2166            None => {
2167                warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2168                continue;
2169            }
2170        };
2171        // WS `outboundAccountPosition` is a free/locked partial (no debt), so emit a
2172        // `BalanceStreamUpdate` rather than a full `BalanceSnapshot`. Spot carries no margin debt,
2173        // so nothing is preserved here — but routing it through the update path keeps one
2174        // consistent model (REST → snapshot, WS → update) and protects margin debt downstream.
2175        let update =
2176            AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2177        buf.push(UnindexedAccountEvent::new(
2178            ExchangeId::BinanceSpot,
2179            AccountEventKind::BalanceStreamUpdate(
2180                rustrade_integration::collection::snapshot::Snapshot::new(update),
2181            ),
2182        ));
2183    }
2184}
2185
2186/// Convert rustrade OrderKind + TimeInForce to Binance SDK order type and TIF.
2187///
2188/// Returns `None` for unsupported combinations, which `open_order` converts to
2189/// `UnsupportedOrderType` rejection.
2190///
2191/// # Conditional Orders
2192///
2193/// - `Stop` → `STOP_LOSS` (market order triggered at stop price)
2194/// - `StopLimit` → `STOP_LOSS_LIMIT` (limit order triggered at stop price)
2195/// - `TakeProfit` → `TAKE_PROFIT` (market order triggered at take-profit price)
2196/// - `TakeProfitLimit` → `TAKE_PROFIT_LIMIT` (limit order triggered at take-profit price)
2197///
2198/// # Trailing Stop Orders
2199///
2200/// Binance implements trailing stops via `STOP_LOSS` with `trailingDelta` parameter.
2201///
2202/// **Supported offset types:**
2203/// - `BasisPoints`: used directly (1 basis point = 0.01%)
2204/// - `Percentage`: converted to basis points (multiplied by 100)
2205///
2206/// **Unsupported:**
2207/// - `Absolute`: returns `None` → `UnsupportedOrderType`. The caller (`open_order`)
2208///   must surface this so end users can convert absolute offsets to basis points
2209///   themselves: `basis_points = (absolute / price) * 10000`
2210/// - `TrailingStopLimit`: Binance does not support limit orders with trailing stops
2211///
2212fn convert_order_kind_tif(
2213    kind: OrderKind,
2214    tif: TimeInForce,
2215) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2216    // The decision logic (which kind/TIF combinations Binance supports, GTD coercion,
2217    // trailing-offset handling) lives in the shared classifier so spot and margin share it;
2218    // this adapter only maps the venue-neutral result onto spot's WS-API enum types.
2219    let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2220    let type_enum = match binance_type {
2221        BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2222        BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2223        BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2224        BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2225        BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2226        BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2227        BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2228    };
2229    let tif_enum = binance_tif.map(|t| match t {
2230        BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2231        BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2232        BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2233    });
2234    Some((type_enum, tif_enum))
2235}
2236
2237#[cfg(test)]
2238#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
2239mod tests {
2240    use super::*;
2241    use crate::client::binance::shared::*;
2242    use crate::client::dedup::{DedupEventKind, DedupKey};
2243    use crate::order::{TrailingOffsetType, id::ClientOrderId};
2244    use binance_sdk::common::errors::WebsocketError;
2245    use smol_str::SmolStr;
2246
2247    #[test]
2248    fn test_binance_spot_config_new_uses_testnet_by_default() {
2249        let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2250        assert!(cfg.testnet);
2251        assert_eq!(cfg.api_key(), "my_key");
2252    }
2253
2254    #[test]
2255    fn test_binance_spot_config_production_is_explicit() {
2256        let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2257        assert!(!cfg.testnet);
2258    }
2259
2260    #[test]
2261    fn test_binance_spot_config_debug_redacts_credentials() {
2262        let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2263        let debug = format!("{cfg:?}");
2264
2265        assert!(!debug.contains("my_key"), "api_key should be redacted");
2266        assert!(
2267            !debug.contains("my_secret"),
2268            "secret_key should be redacted"
2269        );
2270        assert!(debug.contains("testnet: true"));
2271    }
2272
2273    #[test]
2274    fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2275        let cfg: BinanceSpotConfig = serde_json::from_str(
2276            r#"{
2277        "api_key": "my_key",
2278        "secret_key": "my_secret"
2279    }"#,
2280        )
2281        .unwrap();
2282
2283        assert!(cfg.testnet);
2284        assert_eq!(cfg.api_key(), "my_key");
2285    }
2286
2287    #[test]
2288    fn test_binance_spot_config_deserialize_testnet_true() {
2289        let cfg: BinanceSpotConfig = serde_json::from_str(
2290            r#"{
2291        "api_key": "my_key",
2292        "secret_key": "my_secret",
2293        "testnet": true
2294    }"#,
2295        )
2296        .unwrap();
2297
2298        assert!(cfg.testnet);
2299    }
2300
2301    #[test]
2302    fn test_binance_spot_config_deserialize_testnet_false() {
2303        let cfg: BinanceSpotConfig = serde_json::from_str(
2304            r#"{
2305        "api_key": "my_key",
2306        "secret_key": "my_secret",
2307        "testnet": false
2308    }"#,
2309        )
2310        .unwrap();
2311
2312        assert!(!cfg.testnet);
2313    }
2314
2315    #[test]
2316    #[serial_test::serial]
2317    fn test_binance_spot_config_from_env_defaults_to_testnet() {
2318        temp_env::with_vars(
2319            [
2320                ("BINANCE_API_KEY", Some("my_key")),
2321                ("BINANCE_SECRET_KEY", Some("my_secret")),
2322                ("BINANCE_TESTNET", None),
2323            ],
2324            || {
2325                let cfg = BinanceSpotConfig::from_env().unwrap();
2326                assert!(cfg.testnet);
2327                assert_eq!(cfg.api_key(), "my_key");
2328            },
2329        );
2330    }
2331
2332    #[test]
2333    #[serial_test::serial]
2334    fn test_binance_spot_config_from_env_accepts_explicit_production() {
2335        temp_env::with_vars(
2336            [
2337                ("BINANCE_API_KEY", Some("my_key")),
2338                ("BINANCE_SECRET_KEY", Some("my_secret")),
2339                ("BINANCE_TESTNET", Some("false")),
2340            ],
2341            || {
2342                let cfg = BinanceSpotConfig::from_env().unwrap();
2343                assert!(!cfg.testnet);
2344            },
2345        );
2346    }
2347
2348    #[test]
2349    #[serial_test::serial]
2350    fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2351        temp_env::with_vars(
2352            [
2353                ("BINANCE_API_KEY", Some("my_key")),
2354                ("BINANCE_SECRET_KEY", Some("my_secret")),
2355                ("BINANCE_TESTNET", Some("maybe")),
2356            ],
2357            || {
2358                let err = BinanceSpotConfig::from_env().unwrap_err();
2359                assert!(
2360                    matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2361                );
2362            },
2363        );
2364    }
2365
2366    #[test]
2367    #[serial_test::serial]
2368    fn test_binance_spot_config_from_env_requires_credentials() {
2369        temp_env::with_vars(
2370            [
2371                ("BINANCE_API_KEY", None),
2372                ("BINANCE_SECRET_KEY", Some("my_secret")),
2373                ("BINANCE_TESTNET", None),
2374            ],
2375            || {
2376                let err = BinanceSpotConfig::from_env().unwrap_err();
2377                assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2378            },
2379        );
2380    }
2381
2382    #[test]
2383    fn test_parse_side() {
2384        assert_eq!(parse_side("BUY"), Some(Side::Buy));
2385        assert_eq!(parse_side("SELL"), Some(Side::Sell));
2386        assert_eq!(parse_side("buy"), None);
2387        assert_eq!(parse_side("UNKNOWN"), None);
2388    }
2389
2390    #[test]
2391    fn test_parse_order_kind() {
2392        assert_eq!(parse_order_kind("MARKET"), Some(OrderKind::Market));
2393        assert_eq!(parse_order_kind("LIMIT"), Some(OrderKind::Limit));
2394        assert_eq!(parse_order_kind("LIMIT_MAKER"), Some(OrderKind::Limit));
2395        assert_eq!(parse_order_kind("STOP_LOSS"), None);
2396        assert_eq!(parse_order_kind("TAKE_PROFIT"), None);
2397        assert_eq!(parse_order_kind("STOP_LOSS_LIMIT"), Some(OrderKind::Limit));
2398        assert_eq!(
2399            parse_order_kind("TAKE_PROFIT_LIMIT"),
2400            Some(OrderKind::Limit)
2401        );
2402        assert_eq!(parse_order_kind("UNKNOWN_TYPE"), None);
2403    }
2404
2405    #[test]
2406    fn test_parse_time_in_force() {
2407        assert_eq!(
2408            parse_time_in_force("GTC"),
2409            TimeInForce::GoodUntilCancelled { post_only: false }
2410        );
2411        assert_eq!(
2412            parse_time_in_force("GTX"),
2413            TimeInForce::GoodUntilCancelled { post_only: true }
2414        );
2415        assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2416        assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2417        assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2418        // Unknown defaults to GTC
2419        assert_eq!(
2420            parse_time_in_force("UNKNOWN"),
2421            TimeInForce::GoodUntilCancelled { post_only: false }
2422        );
2423    }
2424
2425    #[test]
2426    fn test_convert_order_kind_tif() {
2427        use rust_decimal::Decimal;
2428
2429        // binance-sdk enums don't derive PartialEq, so use matches!
2430        assert!(matches!(
2431            convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2432            Some((OrderPlaceTypeEnum::Market, None))
2433        ));
2434        assert!(matches!(
2435            convert_order_kind_tif(
2436                OrderKind::Limit,
2437                TimeInForce::GoodUntilCancelled { post_only: false }
2438            ),
2439            Some((
2440                OrderPlaceTypeEnum::Limit,
2441                Some(OrderPlaceTimeInForceEnum::Gtc)
2442            ))
2443        ));
2444        assert!(matches!(
2445            convert_order_kind_tif(
2446                OrderKind::Limit,
2447                TimeInForce::GoodUntilCancelled { post_only: true }
2448            ),
2449            Some((OrderPlaceTypeEnum::LimitMaker, None))
2450        ));
2451        assert!(matches!(
2452            convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2453            Some((
2454                OrderPlaceTypeEnum::Limit,
2455                Some(OrderPlaceTimeInForceEnum::Fok)
2456            ))
2457        ));
2458        assert!(matches!(
2459            convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2460            Some((
2461                OrderPlaceTypeEnum::Limit,
2462                Some(OrderPlaceTimeInForceEnum::Ioc)
2463            ))
2464        ));
2465        // GoodUntilEndOfDay is unsupported on Binance (no native EOD order) — surfaced as
2466        // unsupported rather than silently coerced to GTC, which would drop the EOD semantics.
2467        assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2468
2469        // Conditional orders
2470        assert!(matches!(
2471            convert_order_kind_tif(
2472                OrderKind::Stop {
2473                    trigger_price: Decimal::from(100)
2474                },
2475                TimeInForce::GoodUntilCancelled { post_only: false }
2476            ),
2477            Some((OrderPlaceTypeEnum::StopLoss, None))
2478        ));
2479        assert!(matches!(
2480            convert_order_kind_tif(
2481                OrderKind::StopLimit {
2482                    trigger_price: Decimal::from(100)
2483                },
2484                TimeInForce::GoodUntilCancelled { post_only: false }
2485            ),
2486            Some((
2487                OrderPlaceTypeEnum::StopLossLimit,
2488                Some(OrderPlaceTimeInForceEnum::Gtc)
2489            ))
2490        ));
2491        assert!(matches!(
2492            convert_order_kind_tif(
2493                OrderKind::TakeProfit {
2494                    trigger_price: Decimal::from(150)
2495                },
2496                TimeInForce::ImmediateOrCancel
2497            ),
2498            Some((OrderPlaceTypeEnum::TakeProfit, None))
2499        ));
2500        assert!(matches!(
2501            convert_order_kind_tif(
2502                OrderKind::TakeProfitLimit {
2503                    trigger_price: Decimal::from(150)
2504                },
2505                TimeInForce::FillOrKill
2506            ),
2507            Some((
2508                OrderPlaceTypeEnum::TakeProfitLimit,
2509                Some(OrderPlaceTimeInForceEnum::Fok)
2510            ))
2511        ));
2512
2513        // TrailingStop with BasisPoints/Percentage → StopLoss (trailingDelta set separately)
2514        assert!(matches!(
2515            convert_order_kind_tif(
2516                OrderKind::TrailingStop {
2517                    offset: Decimal::from(100),
2518                    offset_type: TrailingOffsetType::BasisPoints,
2519                },
2520                TimeInForce::GoodUntilCancelled { post_only: false }
2521            ),
2522            Some((OrderPlaceTypeEnum::StopLoss, None))
2523        ));
2524        assert!(matches!(
2525            convert_order_kind_tif(
2526                OrderKind::TrailingStop {
2527                    offset: Decimal::from(5),
2528                    offset_type: TrailingOffsetType::Percentage,
2529                },
2530                TimeInForce::GoodUntilCancelled { post_only: false }
2531            ),
2532            Some((OrderPlaceTypeEnum::StopLoss, None))
2533        ));
2534
2535        // TrailingStop with Absolute → unsupported (returns None)
2536        assert!(
2537            convert_order_kind_tif(
2538                OrderKind::TrailingStop {
2539                    offset: Decimal::from(10),
2540                    offset_type: TrailingOffsetType::Absolute,
2541                },
2542                TimeInForce::GoodUntilCancelled { post_only: false }
2543            )
2544            .is_none()
2545        );
2546
2547        // TrailingStopLimit → unsupported (Binance doesn't support)
2548        assert!(
2549            convert_order_kind_tif(
2550                OrderKind::TrailingStopLimit {
2551                    offset: Decimal::from(100),
2552                    offset_type: TrailingOffsetType::BasisPoints,
2553                    limit_offset: Decimal::from(10),
2554                },
2555                TimeInForce::GoodUntilCancelled { post_only: false }
2556            )
2557            .is_none()
2558        );
2559    }
2560
2561    #[test]
2562    fn test_parse_binance_api_error() {
2563        let instrument = InstrumentNameExchange::new("BTCUSDT");
2564
2565        assert!(matches!(
2566            parse_binance_api_error("Insufficient balance".into(), &instrument),
2567            ApiError::BalanceInsufficient(_, _)
2568        ));
2569        assert!(matches!(
2570            parse_binance_api_error("Not enough funds".into(), &instrument),
2571            ApiError::BalanceInsufficient(_, _)
2572        ));
2573        assert_eq!(
2574            parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2575            ApiError::RateLimit
2576        );
2577        assert_eq!(
2578            parse_binance_api_error("Error code -1015".into(), &instrument),
2579            ApiError::RateLimit
2580        );
2581        // -1003: too many requests — must also map to RateLimit (not OrderRejected)
2582        assert_eq!(
2583            parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2584            ApiError::RateLimit
2585        );
2586        // -2011 maps to OrderAlreadyCancelled
2587        assert_eq!(
2588            parse_binance_api_error("Error code -2011".into(), &instrument),
2589            ApiError::OrderAlreadyCancelled
2590        );
2591        // -2013 maps to OrderAlreadyCancelled (benign race condition on cancel)
2592        assert!(matches!(
2593            parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2594            ApiError::OrderAlreadyCancelled
2595        ));
2596        // -2013 without "unknown order" text still matches via code
2597        assert!(matches!(
2598            parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2599            ApiError::OrderAlreadyCancelled
2600        ));
2601        // "Unknown order" text without a code falls through to text heuristic
2602        assert!(matches!(
2603            parse_binance_api_error("Unknown order encountered".into(), &instrument),
2604            ApiError::OrderRejected(_)
2605        ));
2606        assert!(matches!(
2607            parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2608            ApiError::InstrumentInvalid(_, _)
2609        ));
2610        // -2010 matched by numeric code (not text heuristic)
2611        assert!(matches!(
2612            parse_binance_api_error(
2613                "Server-side response error (code -2010): Account has insufficient balance".into(),
2614                &instrument
2615            ),
2616            ApiError::BalanceInsufficient(_, _)
2617        ));
2618        assert!(matches!(
2619            parse_binance_api_error("Some other error".into(), &instrument),
2620            ApiError::OrderRejected(_)
2621        ));
2622    }
2623
2624    #[test]
2625    fn test_contains_error_code_suffix_guard() {
2626        // Suffix digit guard: "-2013" must not match "-20130" or "-20131"
2627        assert!(
2628            !contains_error_code("-20130", "-2013"),
2629            "-20130 should not match -2013"
2630        );
2631        assert!(
2632            !contains_error_code("-20131", "-2013"),
2633            "-20131 should not match -2013"
2634        );
2635        // Exact match and match with trailing text should succeed
2636        assert!(contains_error_code("-2013", "-2013"), "exact match");
2637        assert!(
2638            contains_error_code("Error -2013: text", "-2013"),
2639            "match with trailing text"
2640        );
2641    }
2642
2643    #[test]
2644    fn test_contains_error_code_prefix_guard() {
2645        // Prefix digit guard: "-2013" must not match a string where the code
2646        // is immediately preceded by a digit (e.g. "1-2013" in some error context).
2647        assert!(
2648            !contains_error_code("1-2013", "-2013"),
2649            "1-2013 should not match -2013"
2650        );
2651        assert!(
2652            !contains_error_code("error 1-2013 text", "-2013"),
2653            "embedded 1-2013 should not match"
2654        );
2655        // Non-digit prefix should still match
2656        assert!(
2657            contains_error_code("code=-2013,", "-2013"),
2658            "=-2013 prefix should match"
2659        );
2660        assert!(
2661            contains_error_code(" -2013 ", "-2013"),
2662            "space prefix should match"
2663        );
2664    }
2665
2666    #[test]
2667    fn test_contains_error_code_second_occurrence_valid() {
2668        // When the first occurrence fails the suffix digit guard (e.g. "-2013" inside
2669        // "-20130"), the function must continue scanning and find the later valid occurrence.
2670        assert!(
2671            contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
2672            "second valid occurrence must match when first fails digit guard"
2673        );
2674        // Symmetric: first valid, second is a longer code — first must still match
2675        assert!(
2676            contains_error_code("-2013 or -20130", "-2013"),
2677            "first valid occurrence must match when second is a longer code"
2678        );
2679    }
2680
2681    #[test]
2682    fn test_dedup_cache() {
2683        let cache = new_dedup_cache();
2684        let key = DedupKey {
2685            instrument: SmolStr::from("BTCUSDT"),
2686            id: SmolStr::from("12345"),
2687            kind: DedupEventKind::Trade,
2688        };
2689
2690        // First time: not a duplicate
2691        assert!(!is_duplicate(&cache, key));
2692        // Second time: is a duplicate
2693        let key = DedupKey {
2694            instrument: SmolStr::from("BTCUSDT"),
2695            id: SmolStr::from("12345"),
2696            kind: DedupEventKind::Trade,
2697        };
2698        assert!(is_duplicate(&cache, key));
2699
2700        // Different key (same id, different kind): not a duplicate
2701        let key2 = DedupKey {
2702            instrument: SmolStr::from("BTCUSDT"),
2703            id: SmolStr::from("12345"),
2704            kind: DedupEventKind::OrderState {
2705                filled_quantity: Decimal::ZERO,
2706            },
2707        };
2708        assert!(!is_duplicate(&cache, key2));
2709    }
2710
2711    /// Two order-state snapshots for one order differ only by cumulative filled quantity, and
2712    /// must not be deduplicated against each other -- otherwise the acknowledgement (`z = 0`)
2713    /// swallows every fill snapshot that follows it.
2714    #[test]
2715    fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
2716        let cache = new_dedup_cache();
2717        let state = |filled: &str| DedupKey {
2718            instrument: SmolStr::from("BTCUSDT"),
2719            id: SmolStr::from("12345"),
2720            kind: DedupEventKind::OrderState {
2721                filled_quantity: Decimal::from_str(filled).unwrap(),
2722            },
2723        };
2724
2725        // Acknowledgement, then two fills on the same order: three distinct states.
2726        assert!(!is_duplicate(&cache, state("0")), "ack is new");
2727        assert!(
2728            !is_duplicate(&cache, state("1")),
2729            "first fill is not the ack"
2730        );
2731        assert!(
2732            !is_duplicate(&cache, state("2")),
2733            "second fill is not the first"
2734        );
2735
2736        // A re-delivered snapshot still collides, which is what keeps a replayed
2737        // acknowledgement from resurrecting an order that has since retired.
2738        assert!(
2739            is_duplicate(&cache, state("0")),
2740            "replayed ack is a duplicate"
2741        );
2742        assert!(
2743            is_duplicate(&cache, state("2")),
2744            "replayed fill is a duplicate"
2745        );
2746
2747        // Decimal hashes normalised, so the venue's chosen representation does not split a key.
2748        assert!(
2749            is_duplicate(&cache, state("2.00")),
2750            "2.00 must be the same key as 2"
2751        );
2752    }
2753
2754    /// Drive frames through the same two stages the live WebSocket callback does: convert, then
2755    /// apply the dedup gate. Returns what a consumer would actually receive.
2756    ///
2757    /// Every other test in this file calls `convert_execution_report` directly. That is the wrong
2758    /// altitude to prove a fill reaches anyone: the converter can be perfectly correct while the
2759    /// gate downstream of it discards what the converter produced.
2760    fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
2761        use crate::client::dedup::dedup_key_from_event;
2762
2763        let cache = new_dedup_cache();
2764        let mut delivered = Vec::new();
2765        let mut buf = Vec::new();
2766        for frame in frames {
2767            let _ = convert_user_data_events(frame, &mut buf);
2768            for ev in buf.drain(..) {
2769                if let Some(key) = dedup_key_from_event(&ev)
2770                    && is_duplicate(&cache, key)
2771                {
2772                    continue;
2773                }
2774                delivered.push(ev);
2775            }
2776        }
2777        delivered
2778    }
2779
2780    // Wrapped as the WS-API subscription delivers them: `{ subscriptionId, event }`.
2781    const WS_NEW: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport","s":"BTCUSDT",
2782        "i":12345,"c":"client-1","x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2",
2783        "p":"100","z":"0","T":1700000000000}}"#;
2784
2785    const WS_PARTIAL_FILL: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport",
2786        "s":"BTCUSDT","i":12345,"c":"client-1","x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY",
2787        "o":"LIMIT","f":"GTC","q":"2","p":"100","z":"1","l":"1","L":"100","t":555,"n":"0.1",
2788        "N":"USDT","T":1700000001000}}"#;
2789
2790    /// A fill's order snapshot must survive the dedup gate that sits between the converter and the
2791    /// consumer.
2792    ///
2793    /// The acknowledgement and the fill describe one order id, so a key built from the id alone
2794    /// makes the second look like a replay of the first. `Open::filled_quantity` then never leaves
2795    /// the `0` the acknowledgement carried, which is the defect the converter change was meant to
2796    /// fix -- fixed in the converter, undone one stage later.
2797    #[test]
2798    fn ws_fill_snapshot_survives_the_dedup_gate() {
2799        use crate::order::state::ActiveOrderState;
2800
2801        let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
2802
2803        assert_eq!(
2804            delivered.len(),
2805            3,
2806            "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
2807        );
2808
2809        let snapshots: Vec<Decimal> = delivered
2810            .iter()
2811            .filter_map(|ev| match &ev.kind {
2812                AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
2813                    OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
2814                    _ => None,
2815                },
2816                _ => None,
2817            })
2818            .collect();
2819
2820        assert_eq!(
2821            snapshots,
2822            vec![Decimal::ZERO, Decimal::ONE],
2823            "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
2824        );
2825    }
2826
2827    /// The gate must still collapse a genuinely re-delivered frame, because a replayed
2828    /// acknowledgement for a retired order is re-inserted as a live resting order by the engine.
2829    #[test]
2830    fn ws_replayed_frame_is_still_deduplicated() {
2831        let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
2832
2833        assert_eq!(
2834            delivered.len(),
2835            3,
2836            "re-delivering both frames must add nothing, got: {delivered:?}"
2837        );
2838    }
2839
2840    #[test]
2841    fn test_is_rate_limit_error() {
2842        // Matches actual binance-sdk TooManyRequestsError Display output
2843        assert!(is_rate_limit_error(&anyhow::anyhow!(
2844            "Too many requests. You are being rate-limited. Please slow down."
2845        )));
2846        // Matches actual binance-sdk RateLimitBanError Display output
2847        assert!(is_rate_limit_error(&anyhow::anyhow!(
2848            "The IP address has been banned for exceeding rate limits. Contact support."
2849        )));
2850        // Binance error codes in the msg body
2851        assert!(is_rate_limit_error(&anyhow::anyhow!(
2852            "Error -1015: too many new orders"
2853        )));
2854        assert!(is_rate_limit_error(&anyhow::anyhow!(
2855            "Error -1003: too many requests"
2856        )));
2857        // Non-rate-limit errors
2858        assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
2859        assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
2860        assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
2861        // Digit-boundary false-positive guard: longer codes must NOT match
2862        assert!(
2863            !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
2864            "-10150 should not match -1015"
2865        );
2866        assert!(
2867            !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
2868            "-10030 should not match -1003"
2869        );
2870    }
2871
2872    #[test]
2873    fn test_classify_ws_order_error_recognises_venue_responses_only() {
2874        let instrument = InstrumentNameExchange::new("BTCUSDT");
2875
2876        // A ResponseError from binance-sdk (the venue answered with status >= 400) is classified
2877        let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
2878            code: -2010,
2879            message: "Account has insufficient balance for requested action.".into(),
2880        });
2881        assert!(
2882            matches!(
2883                classify_ws_order_error(&rejection, &instrument),
2884                Some(OrderError::Rejected(ApiError::BalanceInsufficient(..)))
2885            ),
2886            "ResponseError should be classified as a venue response"
2887        );
2888
2889        // A transport error (e.g. connection reset) is NOT a venue response
2890        let transport = anyhow::anyhow!("connection reset by peer");
2891        assert!(
2892            classify_ws_order_error(&transport, &instrument).is_none(),
2893            "plain transport error should not be classified as a venue response"
2894        );
2895
2896        // A rate-limit error string (not a WebsocketError) is NOT a venue response
2897        let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
2898        assert!(
2899            classify_ws_order_error(&rate_limit, &instrument).is_none(),
2900            "rate-limit string error should not be classified as a venue response"
2901        );
2902    }
2903
2904    #[test]
2905    fn test_dedup_key_from_event_trade_includes_instrument() {
2906        use crate::order::id::{OrderId, StrategyId};
2907        use crate::trade::{AssetFees, Trade, TradeId};
2908        use chrono::Utc;
2909        use rust_decimal::Decimal;
2910        use rustrade_instrument::Side;
2911
2912        let instrument = InstrumentNameExchange::new("BTCUSDT");
2913        let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
2914            TradeId::new("9001"),
2915            OrderId::new("4242"),
2916            instrument.clone(),
2917            StrategyId::unknown(),
2918            Utc::now(),
2919            Side::Buy,
2920            Decimal::ZERO,
2921            Decimal::ZERO,
2922            None,
2923            AssetFees::new(
2924                AssetNameExchange::from("USDT"),
2925                Decimal::ZERO,
2926                Some(Decimal::ZERO),
2927            ),
2928        );
2929        let event =
2930            UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
2931
2932        let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
2933        assert_eq!(key.kind, DedupEventKind::Trade);
2934        assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
2935        assert_eq!(
2936            key.instrument.as_str(),
2937            "BTCUSDT",
2938            "key.instrument should be the symbol"
2939        );
2940
2941        // Same trade ID on a different instrument must produce a different key (cross-symbol collision prevention)
2942        let instrument2 = InstrumentNameExchange::new("ETHUSDT");
2943        let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
2944            TradeId::new("9001"),
2945            OrderId::new("7777"),
2946            instrument2,
2947            StrategyId::unknown(),
2948            Utc::now(),
2949            Side::Buy,
2950            Decimal::ZERO,
2951            Decimal::ZERO,
2952            None,
2953            AssetFees::new(
2954                AssetNameExchange::from("USDT"),
2955                Decimal::ZERO,
2956                Some(Decimal::ZERO),
2957            ),
2958        );
2959        let event2 =
2960            UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
2961        let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
2962        assert_ne!(
2963            key, key2,
2964            "same trade ID on different symbols must produce distinct keys"
2965        );
2966    }
2967
2968    // ---------------------------------------------------------------------------
2969    // convert_account_position tests
2970    // ---------------------------------------------------------------------------
2971
2972    fn make_balance_inner(
2973        asset: &str,
2974        free: &str,
2975        locked: &str,
2976    ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2977        binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2978            a: Some(asset.to_string()),
2979            f: Some(free.to_string()),
2980            l: Some(locked.to_string()),
2981        }
2982    }
2983
2984    #[test]
2985    fn test_convert_account_position_happy_path() {
2986        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2987            u: Some(1_700_000_000_000),
2988            b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
2989            ..Default::default()
2990        };
2991        let mut buf = Vec::new();
2992        convert_account_position(position, &mut buf);
2993
2994        assert_eq!(buf.len(), 1);
2995        match &buf[0].kind {
2996            AccountEventKind::BalanceStreamUpdate(snap) => {
2997                let update = &snap.0;
2998                assert_eq!(update.asset.as_ref(), "BTC");
2999                // free = 1.5, locked = 0.5; total = free + locked = 2.0
3000                let expected_free = Decimal::from_str("1.5").unwrap();
3001                let expected_locked = Decimal::from_str("0.5").unwrap();
3002                assert_eq!(update.update.free, expected_free);
3003                assert_eq!(update.update.locked, expected_locked);
3004                assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3005            }
3006            other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3007        }
3008    }
3009
3010    #[test]
3011    fn test_convert_account_position_u_field_none_uses_now() {
3012        // When `u` is None the function falls back to Utc::now() — just verify it doesn't panic
3013        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3014            u: None,
3015            b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3016            ..Default::default()
3017        };
3018        let mut buf = Vec::new();
3019        convert_account_position(position, &mut buf);
3020        assert_eq!(buf.len(), 1);
3021    }
3022
3023    #[test]
3024    fn test_convert_account_position_missing_asset_name_skipped() {
3025        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3026            u: Some(1_700_000_000_000),
3027            b_uppercase: Some(vec![
3028                binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3029                    a: None, // missing asset name
3030                    f: Some("1.0".to_string()),
3031                    l: Some("0.0".to_string()),
3032                },
3033                make_balance_inner("USDT", "100.0", "0.0"), // valid
3034            ]),
3035            ..Default::default()
3036        };
3037        let mut buf = Vec::new();
3038        convert_account_position(position, &mut buf);
3039        // The entry with missing asset name is skipped; USDT entry is kept
3040        assert_eq!(buf.len(), 1);
3041        match &buf[0].kind {
3042            AccountEventKind::BalanceStreamUpdate(snap) => {
3043                assert_eq!(snap.0.asset.as_ref(), "USDT");
3044            }
3045            other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3046        }
3047    }
3048
3049    #[test]
3050    fn test_convert_account_position_unparseable_free_skipped() {
3051        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3052            u: Some(1_700_000_000_000),
3053            b_uppercase: Some(vec![
3054                binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3055                    a: Some("BTC".to_string()),
3056                    f: Some("not-a-number".to_string()),
3057                    l: Some("0.0".to_string()),
3058                },
3059                make_balance_inner("ETH", "1.0", "0.0"), // valid
3060            ]),
3061            ..Default::default()
3062        };
3063        let mut buf = Vec::new();
3064        convert_account_position(position, &mut buf);
3065        // BTC skipped due to unparseable free; ETH kept
3066        assert_eq!(buf.len(), 1);
3067        match &buf[0].kind {
3068            AccountEventKind::BalanceStreamUpdate(snap) => {
3069                assert_eq!(snap.0.asset.as_ref(), "ETH");
3070            }
3071            other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3072        }
3073    }
3074
3075    #[test]
3076    fn test_convert_account_position_empty_balances() {
3077        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3078            u: Some(1_700_000_000_000),
3079            b_uppercase: Some(vec![]),
3080            ..Default::default()
3081        };
3082        let mut buf = Vec::new();
3083        convert_account_position(position, &mut buf);
3084        assert!(
3085            buf.is_empty(),
3086            "empty balance list should produce no events"
3087        );
3088    }
3089
3090    #[test]
3091    fn test_convert_account_position_b_field_none() {
3092        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3093            u: Some(1_700_000_000_000),
3094            b_uppercase: None, // no B field at all
3095            ..Default::default()
3096        };
3097        let mut buf = Vec::new();
3098        convert_account_position(position, &mut buf);
3099        assert!(buf.is_empty(), "None B field should produce no events");
3100    }
3101
3102    // Behavioral test — verify wait() exhaustion and reset
3103    #[tokio::test]
3104    async fn test_exponential_backoff_exhaustion() {
3105        tokio::time::pause(); // auto-advances to next timer when all tasks are waiting
3106        let mut backoff = ExponentialBackoff::new();
3107
3108        // All MAX_RECONNECT_ATTEMPTS calls should return true
3109        for i in 0..MAX_RECONNECT_ATTEMPTS {
3110            assert!(
3111                backoff.wait().await,
3112                "expected true on attempt {i} (before exhaustion)"
3113            );
3114        }
3115
3116        // The next call must return false (exhausted)
3117        assert!(!backoff.wait().await, "expected false after max attempts");
3118    }
3119
3120    #[tokio::test]
3121    async fn test_exponential_backoff_reset() {
3122        tokio::time::pause();
3123        let mut backoff = ExponentialBackoff::new();
3124
3125        for _ in 0..MAX_RECONNECT_ATTEMPTS {
3126            backoff.wait().await;
3127        }
3128        assert!(!backoff.wait().await, "should be exhausted");
3129
3130        backoff.reset();
3131        assert!(backoff.wait().await, "should succeed again after reset");
3132    }
3133
3134    // 7b: convert_execution_report round-trip tests
3135    // ExecutionReport derives Default with all-Option fields, making it easy to
3136    // construct targeted test cases.
3137    fn make_base_report() -> binance_sdk::spot::websocket_api::ExecutionReport {
3138        binance_sdk::spot::websocket_api::ExecutionReport {
3139            s: Some("BTCUSDT".to_string()),
3140            i: Some(12345),
3141            c: Some("client-1".to_string()),
3142            t_uppercase: Some(1_700_000_000_000),
3143            ..Default::default()
3144        }
3145    }
3146
3147    /// Run the shared converter over one report and collect what it appends.
3148    fn convert(
3149        report: binance_sdk::spot::websocket_api::ExecutionReport,
3150    ) -> Vec<UnindexedAccountEvent> {
3151        let mut buf = Vec::new();
3152        convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
3153        buf
3154    }
3155
3156    /// The one event produced by a report that maps to a single `AccountEvent`.
3157    ///
3158    /// Asserts nothing followed it, so a test written for a single-event report fails loudly if
3159    /// that report ever starts producing two.
3160    fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
3161        assert!(
3162            events.len() <= 1,
3163            "expected at most a single event, got: {events:?}"
3164        );
3165        events.pop()
3166    }
3167
3168    #[test]
3169    fn test_convert_execution_report_new() {
3170        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3171            x: Some("NEW".to_string()),
3172            s_uppercase: Some("BUY".to_string()),
3173            o: Some("LIMIT".to_string()),
3174            p: Some("50000.00".to_string()),
3175            q: Some("0.01".to_string()),
3176            f: Some("GTC".to_string()),
3177            z: Some("0".to_string()),
3178            ..make_base_report()
3179        };
3180
3181        let event = sole_event(convert(report)).expect("NEW event should produce Some");
3182        assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3183        match &event.kind {
3184            AccountEventKind::OrderSnapshot(snap) => {
3185                let order = &snap.0;
3186                assert_eq!(order.side, Side::Buy);
3187                assert_eq!(order.kind, OrderKind::Limit);
3188                assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3189                assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3190                assert_eq!(order.key.cid.0.as_str(), "client-1");
3191                assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3192            }
3193            other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3194        }
3195    }
3196
3197    #[test]
3198    fn test_convert_execution_report_trade() {
3199        // No `X` (order status): the report says nothing about whether the order is still
3200        // working, so only the execution is emitted.
3201        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3202            x: Some("TRADE".to_string()),
3203            s_uppercase: Some("BUY".to_string()),
3204            t: Some(9999),
3205            l_uppercase: Some("50000.00".to_string()),
3206            l: Some("0.01".to_string()),
3207            n: Some("0.000001".to_string()),
3208            ..make_base_report()
3209        };
3210
3211        let event = sole_event(convert(report)).expect("TRADE event should produce Some");
3212        assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3213        match &event.kind {
3214            AccountEventKind::Trade(trade) => {
3215                assert_eq!(trade.side, Side::Buy);
3216                assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3217                assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3218                assert_eq!(trade.id.0.as_str(), "9999");
3219                assert_eq!(trade.order_id.0.as_str(), "12345");
3220                assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3221            }
3222            other => panic!("TRADE should yield Trade, got {other:?}"),
3223        }
3224    }
3225
3226    /// The `(Trade, OrderSnapshot)` pair a fill report must produce, in that order.
3227    fn trade_events(
3228        events: Vec<UnindexedAccountEvent>,
3229    ) -> (
3230        Trade<AssetNameExchange, InstrumentNameExchange>,
3231        crate::order::Order<
3232            ExchangeId,
3233            InstrumentNameExchange,
3234            OrderState<AssetNameExchange, InstrumentNameExchange>,
3235        >,
3236    ) {
3237        let [first, second]: [UnindexedAccountEvent; 2] = events
3238            .try_into()
3239            .expect("a fill report must produce an execution and an order snapshot");
3240        let AccountEventKind::Trade(trade) = first.kind else {
3241            panic!("first event must be the Trade, got {:?}", first.kind);
3242        };
3243        let AccountEventKind::OrderSnapshot(snap) = second.kind else {
3244            panic!(
3245                "second event must be the OrderSnapshot, got {:?}",
3246                second.kind
3247            );
3248        };
3249        (trade, snap.0)
3250    }
3251
3252    /// A `PARTIALLY_FILLED` report carries the order's cumulative filled quantity in `z`. The
3253    /// execution alone never reaches `Open::filled_quantity`, so without the snapshot a half-done
3254    /// order reads as having nothing filled until REST reconciliation refreshes it.
3255    #[test]
3256    fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
3257        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3258            x: Some("TRADE".to_string()),
3259            x_uppercase: Some("PARTIALLY_FILLED".to_string()),
3260            s_uppercase: Some("BUY".to_string()),
3261            o: Some("LIMIT".to_string()),
3262            p: Some("50000.00".to_string()),
3263            q: Some("0.01".to_string()),
3264            f: Some("GTC".to_string()),
3265            t: Some(9999),
3266            l_uppercase: Some("50000.00".to_string()),
3267            l: Some("0.004".to_string()),
3268            z: Some("0.004".to_string()),
3269            ..make_base_report()
3270        };
3271
3272        let (trade, order) = trade_events(convert(report));
3273        assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
3274
3275        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3276        else {
3277            panic!("expected an Open snapshot, got {:?}", order.state);
3278        };
3279        assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
3280        assert_eq!(
3281            open.quantity_remaining(order.quantity),
3282            Decimal::from_str("0.006").unwrap()
3283        );
3284    }
3285
3286    /// A `FILLED` report reports nothing left to fill, which is how the engine learns the order
3287    /// is done rather than leaving it as a resting order that no longer exists at the venue.
3288    #[test]
3289    fn a_filled_report_reports_nothing_left_to_fill() {
3290        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3291            x: Some("TRADE".to_string()),
3292            x_uppercase: Some("FILLED".to_string()),
3293            s_uppercase: Some("BUY".to_string()),
3294            o: Some("LIMIT".to_string()),
3295            p: Some("50000.00".to_string()),
3296            q: Some("0.01".to_string()),
3297            f: Some("GTC".to_string()),
3298            t: Some(10_000),
3299            l_uppercase: Some("50000.00".to_string()),
3300            l: Some("0.006".to_string()),
3301            z: Some("0.01".to_string()),
3302            ..make_base_report()
3303        };
3304
3305        let (_trade, order) = trade_events(convert(report));
3306        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3307        else {
3308            panic!("expected an Open snapshot, got {:?}", order.state);
3309        };
3310        assert!(
3311            open.quantity_remaining(order.quantity).is_zero(),
3312            "a completed order must report nothing left to fill so the engine retires it"
3313        );
3314    }
3315
3316    /// A fill whose order the exchange no longer reports as working must not be written back into
3317    /// engine state as an `Open` order. The execution still counts -- it moved the position --
3318    /// but resurrecting the order would leave a resting order that does not exist at the venue.
3319    #[test]
3320    fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
3321        for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
3322            let report = binance_sdk::spot::websocket_api::ExecutionReport {
3323                x: Some("TRADE".to_string()),
3324                x_uppercase: Some(status.to_string()),
3325                s_uppercase: Some("BUY".to_string()),
3326                o: Some("LIMIT".to_string()),
3327                p: Some("50000.00".to_string()),
3328                q: Some("0.01".to_string()),
3329                f: Some("GTC".to_string()),
3330                t: Some(9999),
3331                l_uppercase: Some("50000.00".to_string()),
3332                l: Some("0.004".to_string()),
3333                z: Some("0.004".to_string()),
3334                ..make_base_report()
3335            };
3336            let events = convert(report);
3337            assert_eq!(
3338                events.len(),
3339                1,
3340                "status {status} must produce the execution alone, got: {events:?}"
3341            );
3342            assert!(
3343                matches!(events[0].kind, AccountEventKind::Trade(_)),
3344                "the execution must still be reported for status {status}"
3345            );
3346        }
3347    }
3348
3349    #[test]
3350    fn test_convert_execution_report_trade_missing_last_price() {
3351        // l_uppercase (L = last filled price) missing: must drop the fill
3352        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3353            x: Some("TRADE".to_string()),
3354            s_uppercase: Some("BUY".to_string()),
3355            t: Some(9999),
3356            l_uppercase: None, // missing L field
3357            l: Some("0.01".to_string()),
3358            ..make_base_report()
3359        };
3360        assert!(
3361            sole_event(convert(report)).is_none(),
3362            "missing last price (L) should return None"
3363        );
3364    }
3365
3366    #[test]
3367    fn test_convert_execution_report_trade_missing_last_qty() {
3368        // l (last filled qty) missing: must drop the fill
3369        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3370            x: Some("TRADE".to_string()),
3371            s_uppercase: Some("BUY".to_string()),
3372            t: Some(9999),
3373            l_uppercase: Some("50000.00".to_string()),
3374            l: None, // missing l field
3375            ..make_base_report()
3376        };
3377        assert!(
3378            sole_event(convert(report)).is_none(),
3379            "missing last qty (l) should return None"
3380        );
3381    }
3382
3383    #[test]
3384    fn test_convert_execution_report_canceled() {
3385        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3386            x: Some("CANCELED".to_string()),
3387            ..make_base_report()
3388        };
3389
3390        let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3391        assert!(
3392            matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3393            "CANCELED should yield OrderCancelled with Ok state"
3394        );
3395    }
3396
3397    #[test]
3398    fn test_convert_execution_report_expired() {
3399        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3400            x: Some("EXPIRED".to_string()),
3401            ..make_base_report()
3402        };
3403        let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
3404        assert!(
3405            matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3406            "EXPIRED should yield OrderCancelled with Ok state"
3407        );
3408    }
3409
3410    #[test]
3411    fn test_convert_execution_report_expired_in_match() {
3412        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3413            x: Some("EXPIRED_IN_MATCH".to_string()),
3414            ..make_base_report()
3415        };
3416        let event = sole_event(convert(report)).expect("EXPIRED_IN_MATCH should produce Some");
3417        assert!(
3418            matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3419            "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3420        );
3421    }
3422
3423    #[test]
3424    fn test_convert_execution_report_rejected() {
3425        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3426            x: Some("REJECTED".to_string()),
3427            r: Some("INSUFFICIENT_FUNDS".to_string()),
3428            ..make_base_report()
3429        };
3430
3431        let event = sole_event(convert(report)).expect("REJECTED should produce Some");
3432        assert!(
3433            matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_err()),
3434            "REJECTED should yield OrderCancelled with Err state"
3435        );
3436    }
3437
3438    #[test]
3439    fn test_convert_execution_report_missing_exec_type() {
3440        // Missing x field: must drop the event
3441        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3442            s: Some("BTCUSDT".to_string()),
3443            ..Default::default()
3444        };
3445        assert!(
3446            sole_event(convert(report)).is_none(),
3447            "missing execution type should return None"
3448        );
3449    }
3450
3451    #[test]
3452    fn test_convert_execution_report_missing_symbol() {
3453        // Missing s field with valid x: must drop the event
3454        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3455            x: Some("NEW".to_string()),
3456            ..Default::default()
3457        };
3458        assert!(
3459            sole_event(convert(report)).is_none(),
3460            "missing symbol should return None"
3461        );
3462    }
3463
3464    #[test]
3465    fn test_convert_execution_report_missing_order_id() {
3466        // Missing i field (orderId): shared early-exit path for all exec types
3467        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3468            x: Some("NEW".to_string()),
3469            s: Some("BTCUSDT".to_string()),
3470            i: None,
3471            ..Default::default()
3472        };
3473        assert!(
3474            sole_event(convert(report)).is_none(),
3475            "missing orderId should return None"
3476        );
3477    }
3478
3479    #[test]
3480    fn test_convert_execution_report_trade_missing_trade_id() {
3481        // Missing t field (tradeId) on a TRADE event: must drop the fill
3482        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3483            x: Some("TRADE".to_string()),
3484            t: None,
3485            ..make_base_report()
3486        };
3487        assert!(
3488            sole_event(convert(report)).is_none(),
3489            "TRADE event missing tradeId should return None"
3490        );
3491    }
3492
3493    #[test]
3494    fn test_convert_execution_report_replace_yields_cancelled() {
3495        let report = binance_sdk::spot::websocket_api::ExecutionReport {
3496            x: Some("REPLACE".to_string()),
3497            ..make_base_report()
3498        };
3499        // REPLACE describes the cancelled original order (field `i` = original order ID).
3500        // The replacement order arrives as a subsequent NEW execution report.
3501        let event = sole_event(convert(report)).expect("REPLACE should produce Some");
3502        assert!(
3503            matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3504            "REPLACE should yield OrderCancelled with Ok state"
3505        );
3506    }
3507
3508    // ---------------------------------------------------------------------------
3509    // filter_and_convert_balances tests
3510    // ---------------------------------------------------------------------------
3511
3512    fn make_balance(
3513        asset: &str,
3514        free: &str,
3515        locked: &str,
3516    ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3517        binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3518            asset: Some(asset.to_string()),
3519            free: Some(free.to_string()),
3520            locked: Some(locked.to_string()),
3521        }
3522    }
3523
3524    #[test]
3525    fn test_filter_balances_empty_assets_returns_all() {
3526        let balances = vec![
3527            make_balance("BTC", "1.0", "0.0"),
3528            make_balance("USDT", "500.0", "50.0"),
3529        ];
3530        let result = filter_and_convert_balances(balances, &[]);
3531        assert_eq!(result.len(), 2, "empty filter should return all balances");
3532    }
3533
3534    #[test]
3535    fn test_filter_balances_matching_asset_returned() {
3536        let balances = vec![
3537            make_balance("BTC", "1.5", "0.5"),
3538            make_balance("ETH", "10.0", "0.0"),
3539        ];
3540        let assets = vec![AssetNameExchange::new("BTC")];
3541        let result = filter_and_convert_balances(balances, &assets);
3542        assert_eq!(result.len(), 1);
3543        assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
3544        // total = free + locked = 1.5 + 0.5
3545        assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
3546        assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
3547    }
3548
3549    #[test]
3550    fn test_filter_balances_non_matching_asset_filtered_out() {
3551        let balances = vec![
3552            make_balance("BTC", "1.0", "0.0"),
3553            make_balance("ETH", "2.0", "0.0"),
3554        ];
3555        let assets = vec![AssetNameExchange::new("USDT")];
3556        let result = filter_and_convert_balances(balances, &assets);
3557        assert!(
3558            result.is_empty(),
3559            "non-matching asset should be filtered out"
3560        );
3561    }
3562
3563    #[test]
3564    fn test_filter_balances_missing_asset_field_skipped() {
3565        let balances = vec![
3566            binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3567                asset: None,
3568                free: Some("1.0".to_string()),
3569                locked: Some("0.0".to_string()),
3570            },
3571            make_balance("USDT", "100.0", "0.0"),
3572        ];
3573        let result = filter_and_convert_balances(balances, &[]);
3574        assert_eq!(
3575            result.len(),
3576            1,
3577            "entry with missing asset should be skipped"
3578        );
3579        assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
3580    }
3581
3582    #[test]
3583    fn test_filter_balances_unparseable_free_skipped() {
3584        let balances = vec![
3585            binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3586                asset: Some("BTC".to_string()),
3587                free: Some("not-a-number".to_string()),
3588                locked: Some("0.0".to_string()),
3589            },
3590        ];
3591        let result = filter_and_convert_balances(balances, &[]);
3592        assert!(
3593            result.is_empty(),
3594            "unparseable free field should be skipped"
3595        );
3596    }
3597
3598    #[test]
3599    fn test_filter_balances_zero_balance_included() {
3600        // Zero-balance entries are intentionally passed through — the caller decides
3601        // whether to filter them. Binance returns all ever-touched assets including zeroes.
3602        let balances = vec![
3603            make_balance("BTC", "0.00000000", "0.00000000"),
3604            make_balance("USDT", "100.0", "0.0"),
3605        ];
3606        let result = filter_and_convert_balances(balances, &[]);
3607        assert_eq!(result.len(), 2, "zero-balance entries must be included");
3608        let btc = result
3609            .iter()
3610            .find(|b| b.asset == AssetNameExchange::new("BTC"))
3611            .unwrap();
3612        assert_eq!(btc.balance.total, Decimal::ZERO);
3613        assert_eq!(btc.balance.free, Decimal::ZERO);
3614    }
3615
3616    #[test]
3617    fn test_filter_balances_duplicate_assets_in_response() {
3618        // if the API response contains two entries for the same asset,
3619        // filter_and_convert_balances emits two AssetBalance entries. Callers
3620        // are responsible for deduplication if this matters for their use case.
3621        let balances = vec![
3622            make_balance("BTC", "1.0", "0.0"),
3623            make_balance("BTC", "2.0", "0.0"),
3624        ];
3625        let result = filter_and_convert_balances(balances, &[]);
3626        assert_eq!(
3627            result.len(),
3628            2,
3629            "duplicate asset entries produce two AssetBalance entries"
3630        );
3631    }
3632
3633    // ---------------------------------------------------------------------------
3634    // convert_my_trade tests
3635    // ---------------------------------------------------------------------------
3636
3637    fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
3638        binance_sdk::spot::rest_api::MyTradesResponseInner {
3639            id: Some(9001),
3640            order_id: Some(4242),
3641            price: Some("50000.00".to_string()),
3642            qty: Some("0.01".to_string()),
3643            commission: Some("0.05".to_string()),
3644            time: Some(1_700_000_000_000),
3645            is_buyer: Some(true),
3646            ..Default::default()
3647        }
3648    }
3649
3650    #[test]
3651    fn test_convert_my_trade_happy_path() {
3652        let instrument = InstrumentNameExchange::new("BTCUSDT");
3653        let trade =
3654            convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
3655        assert_eq!(trade.instrument, instrument);
3656        assert_eq!(trade.side, Side::Buy);
3657        assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3658        assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3659    }
3660
3661    #[test]
3662    fn test_convert_my_trade_sell_side() {
3663        let instrument = InstrumentNameExchange::new("BTCUSDT");
3664        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3665            is_buyer: Some(false),
3666            ..make_base_trade()
3667        };
3668        let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
3669        assert_eq!(trade.side, Side::Sell);
3670    }
3671
3672    #[test]
3673    fn test_convert_my_trade_missing_id_returns_none() {
3674        let instrument = InstrumentNameExchange::new("BTCUSDT");
3675        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3676            id: None,
3677            ..make_base_trade()
3678        };
3679        assert!(
3680            convert_my_trade(&t, &instrument).is_none(),
3681            "missing id should return None"
3682        );
3683    }
3684
3685    #[test]
3686    fn test_convert_my_trade_missing_order_id_returns_none() {
3687        let instrument = InstrumentNameExchange::new("BTCUSDT");
3688        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3689            order_id: None,
3690            ..make_base_trade()
3691        };
3692        assert!(
3693            convert_my_trade(&t, &instrument).is_none(),
3694            "missing orderId should return None"
3695        );
3696    }
3697
3698    #[test]
3699    fn test_convert_my_trade_missing_is_buyer_returns_none() {
3700        let instrument = InstrumentNameExchange::new("BTCUSDT");
3701        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3702            is_buyer: None,
3703            ..make_base_trade()
3704        };
3705        assert!(
3706            convert_my_trade(&t, &instrument).is_none(),
3707            "missing isBuyer should return None"
3708        );
3709    }
3710
3711    #[test]
3712    fn test_convert_my_trade_commission_none_defaults_to_zero() {
3713        let instrument = InstrumentNameExchange::new("BTCUSDT");
3714        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3715            commission: None,
3716            ..make_base_trade()
3717        };
3718        let trade =
3719            convert_my_trade(&t, &instrument).expect("None commission should still convert");
3720        assert_eq!(trade.fees.fees, Decimal::ZERO);
3721    }
3722
3723    // ---------------------------------------------------------------------------
3724    // convert_open_order tests
3725    // ---------------------------------------------------------------------------
3726
3727    fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3728        binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3729            order_id: Some(12345),
3730            client_order_id: Some("cid-abc".to_string()),
3731            side: Some("BUY".to_string()),
3732            r#type: Some("LIMIT".to_string()),
3733            price: Some("50000.00".to_string()),
3734            orig_qty: Some("0.01".to_string()),
3735            executed_qty: Some("0.0".to_string()),
3736            time_in_force: Some("GTC".to_string()),
3737            time: Some(1_700_000_000_000),
3738            status: Some("NEW".to_string()),
3739            ..Default::default()
3740        }
3741    }
3742
3743    #[test]
3744    fn test_convert_open_order_all_orders_response_converts_identically() {
3745        // `convert_open_order` is generic over `BinanceOrderFields`; the live open-orders path feeds
3746        // it `GetOpenOrdersResponseInner` (what every other test here uses) while `allOrders`
3747        // would feed it `AllOrdersResponseInner`. Pin that the second impl reads the same fields,
3748        // so the two endpoint structs cannot drift apart unnoticed. This guards field drift only:
3749        // an `allOrders` row for a finished order must not convert, which
3750        // `test_convert_open_order_refuses_an_all_orders_row_that_is_not_live` pins.
3751        let instrument = InstrumentNameExchange::new("BTCUSDT");
3752        let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3753            order_id: Some(12345),
3754            client_order_id: Some("cid-abc".to_string()),
3755            side: Some("BUY".to_string()),
3756            r#type: Some("LIMIT".to_string()),
3757            price: Some("50000.00".to_string()),
3758            orig_qty: Some("0.01".to_string()),
3759            executed_qty: Some("0.0".to_string()),
3760            time_in_force: Some("GTC".to_string()),
3761            time: Some(1_700_000_000_000),
3762            status: Some("NEW".to_string()),
3763            ..Default::default()
3764        };
3765        let from_all_orders = convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument)
3766            .expect("valid order should convert");
3767        let from_open_orders = convert_open_order(
3768            &make_base_open_order(),
3769            ExchangeId::BinanceSpot,
3770            &instrument,
3771        )
3772        .expect("valid order should convert");
3773        assert_eq!(from_all_orders, from_open_orders);
3774    }
3775
3776    /// An `allOrders` row for an order that is no longer live must not become an `Open` order.
3777    ///
3778    /// The case that matters is a cancelled order that had partly filled: as `Open` it would rest
3779    /// in engine state with quantity remaining, and nothing at the exchange would ever fill or
3780    /// cancel it.
3781    #[test]
3782    fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
3783        let instrument = InstrumentNameExchange::new("BTCUSDT");
3784        for status in [
3785            "CANCELED",
3786            "FILLED",
3787            "EXPIRED",
3788            "EXPIRED_IN_MATCH",
3789            "REJECTED",
3790            "PENDING_CANCEL",
3791            "SOME_FUTURE_STATUS",
3792        ] {
3793            let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3794                order_id: Some(12345),
3795                client_order_id: Some("cid-abc".to_string()),
3796                side: Some("BUY".to_string()),
3797                r#type: Some("LIMIT".to_string()),
3798                price: Some("50000.00".to_string()),
3799                orig_qty: Some("0.01".to_string()),
3800                executed_qty: Some("0.004".to_string()),
3801                time_in_force: Some("GTC".to_string()),
3802                time: Some(1_700_000_000_000),
3803                status: Some(status.to_string()),
3804                ..Default::default()
3805            };
3806            assert_eq!(
3807                convert_open_order(&row, ExchangeId::BinanceSpot, &instrument),
3808                None,
3809                "{status}"
3810            );
3811        }
3812    }
3813
3814    #[test]
3815    fn test_convert_open_order_admits_every_live_status() {
3816        let instrument = InstrumentNameExchange::new("BTCUSDT");
3817        for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
3818            let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3819                status: Some(status.to_string()),
3820                ..make_base_open_order()
3821            };
3822            assert!(
3823                convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_some(),
3824                "{status}"
3825            );
3826        }
3827    }
3828
3829    /// A listing is complete only while it shows every row under the id its order was placed with.
3830    ///
3831    /// The engine retires an order a complete snapshot leaves out, so a row that is dropped, or kept
3832    /// under its `orderId` alone, must make the listing incomplete: either can hide a live order.
3833    #[test]
3834    fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
3835        let instrument = InstrumentNameExchange::new("BTCUSDT");
3836        let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3837            order_id: Some(67890),
3838            client_order_id: Some("cid-def".to_string()),
3839            ..make_base_open_order()
3840        };
3841
3842        let listing = convert_open_order_listing(
3843            &[make_base_open_order(), second()],
3844            ExchangeId::BinanceSpot,
3845            &instrument,
3846        );
3847        assert_eq!(listing.orders.len(), 2);
3848        assert!(listing.complete);
3849
3850        let empty = convert_open_order_listing::<
3851            binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
3852        >(&[], ExchangeId::BinanceSpot, &instrument);
3853        assert!(empty.orders.is_empty());
3854        assert!(empty.complete, "no open orders is a complete answer");
3855
3856        let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3857            side: None,
3858            ..second()
3859        };
3860        let listing = convert_open_order_listing(
3861            &[make_base_open_order(), dropped],
3862            ExchangeId::BinanceSpot,
3863            &instrument,
3864        );
3865        assert_eq!(listing.orders.len(), 1);
3866        assert!(!listing.complete, "a dropped row may be a live order");
3867
3868        let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3869            client_order_id: None,
3870            ..second()
3871        };
3872        let listing = convert_open_order_listing(
3873            &[make_base_open_order(), without_cid],
3874            ExchangeId::BinanceSpot,
3875            &instrument,
3876        );
3877        assert_eq!(listing.orders.len(), 2, "the row is still converted");
3878        assert!(
3879            !listing.complete,
3880            "an order kept under its orderId cannot be found under the cid it was placed with"
3881        );
3882    }
3883
3884    #[test]
3885    fn test_convert_open_order_missing_status_returns_none() {
3886        let instrument = InstrumentNameExchange::new("BTCUSDT");
3887        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3888            status: None,
3889            ..make_base_open_order()
3890        };
3891        assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none());
3892    }
3893
3894    /// A REST order snapshot is stamped with when the order last changed, not when it was created.
3895    ///
3896    /// The engine discards an `Open` snapshot older than the state it already tracks, so a
3897    /// snapshot carrying `time` is thrown away as soon as a WebSocket fill has advanced the
3898    /// tracked order past creation -- the exact order a reconciliation fetch is meant to repair.
3899    #[test]
3900    fn test_convert_open_order_prefers_update_time_over_creation_time() {
3901        let instrument = InstrumentNameExchange::new("BTCUSDT");
3902
3903        let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3904            update_time: Some(1_700_000_005_000),
3905            ..make_base_open_order()
3906        };
3907        let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument)
3908            .expect("valid order should convert");
3909        assert_eq!(
3910            order.state.time_exchange,
3911            Utc.timestamp_millis_opt(1_700_000_005_000)
3912                .single()
3913                .unwrap(),
3914            "update_time must win over time"
3915        );
3916
3917        // Falls back to `time` when the venue omits `update_time`, rather than to `now` -- which
3918        // would be worse than creation time, since it is not a venue-reported instant at all.
3919        let order = convert_open_order(
3920            &make_base_open_order(),
3921            ExchangeId::BinanceSpot,
3922            &instrument,
3923        )
3924        .expect("valid order should convert");
3925        assert_eq!(
3926            order.state.time_exchange,
3927            Utc.timestamp_millis_opt(1_700_000_000_000)
3928                .single()
3929                .unwrap(),
3930            "absent update_time falls back to time"
3931        );
3932    }
3933
3934    #[test]
3935    fn test_convert_open_order_happy_path() {
3936        let instrument = InstrumentNameExchange::new("BTCUSDT");
3937        let order = convert_open_order(
3938            &make_base_open_order(),
3939            ExchangeId::BinanceSpot,
3940            &instrument,
3941        )
3942        .expect("valid order should convert");
3943        assert_eq!(order.key.instrument, instrument);
3944        assert_eq!(order.side, Side::Buy);
3945        assert_eq!(order.kind, OrderKind::Limit);
3946        assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3947        assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3948        assert_eq!(order.state.filled_quantity, Decimal::ZERO);
3949    }
3950
3951    #[test]
3952    fn test_convert_open_order_owned_symbol_recovers_instrument() {
3953        // The no-symbol "return all" path derives the instrument from each order's own `symbol`.
3954        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3955            symbol: Some("ETHUSDT".to_string()),
3956            ..make_base_open_order()
3957        };
3958        let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot)
3959            .expect("valid order should convert");
3960        assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
3961        assert_eq!(order.side, Side::Buy);
3962    }
3963
3964    #[test]
3965    fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
3966        // make_base_open_order() leaves `symbol` unset — drop rather than guess the instrument.
3967        let o = make_base_open_order();
3968        assert!(o.symbol.is_none());
3969        assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot).is_none());
3970    }
3971
3972    #[test]
3973    fn test_convert_open_order_missing_order_id_returns_none() {
3974        let instrument = InstrumentNameExchange::new("BTCUSDT");
3975        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3976            order_id: None,
3977            ..make_base_open_order()
3978        };
3979        assert!(
3980            convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
3981            "missing orderId should return None"
3982        );
3983    }
3984
3985    #[test]
3986    fn test_convert_open_order_missing_side_returns_none() {
3987        let instrument = InstrumentNameExchange::new("BTCUSDT");
3988        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3989            side: None,
3990            ..make_base_open_order()
3991        };
3992        assert!(
3993            convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
3994            "missing side should return None"
3995        );
3996    }
3997
3998    #[test]
3999    fn test_convert_open_order_missing_type_returns_none() {
4000        let instrument = InstrumentNameExchange::new("BTCUSDT");
4001        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4002            r#type: None,
4003            ..make_base_open_order()
4004        };
4005        assert!(
4006            convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4007            "missing type should return None"
4008        );
4009    }
4010
4011    #[test]
4012    fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
4013        let instrument = InstrumentNameExchange::new("BTCUSDT");
4014        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4015            executed_qty: None,
4016            ..make_base_open_order()
4017        };
4018        let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4019            .expect("None executedQty should still convert");
4020        assert_eq!(
4021            order.state.filled_quantity,
4022            Decimal::ZERO,
4023            "None executedQty should default to zero"
4024        );
4025    }
4026
4027    #[test]
4028    fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
4029        let instrument = InstrumentNameExchange::new("BTCUSDT");
4030        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4031            executed_qty: Some("bad-value".to_string()),
4032            ..make_base_open_order()
4033        };
4034        let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4035            .expect("unparseable executedQty should still convert");
4036        assert_eq!(
4037            order.state.filled_quantity,
4038            Decimal::ZERO,
4039            "unparseable executedQty should default to zero"
4040        );
4041    }
4042
4043    // ---------------------------------------------------------------------------
4044    // dedup_key_from_event — non-Open active state paths
4045    // ---------------------------------------------------------------------------
4046
4047    #[test]
4048    fn test_dedup_key_from_event_non_open_states_return_none() {
4049        use crate::order::state::{
4050            ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
4051        };
4052        use rustrade_integration::collection::snapshot::Snapshot;
4053
4054        let key = OrderKey::new(
4055            ExchangeId::BinanceSpot,
4056            InstrumentNameExchange::new("BTCUSDT"),
4057            StrategyId::unknown(),
4058            ClientOrderId::new("cid1"),
4059        );
4060
4061        // OpenInFlight → None (order is not yet acknowledged by exchange)
4062        let event = UnindexedAccountEvent::new(
4063            ExchangeId::BinanceSpot,
4064            AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4065                key.clone(),
4066                Side::Buy,
4067                None, // Market orders have no limit price
4068                Decimal::ZERO,
4069                OrderKind::Market,
4070                TimeInForce::ImmediateOrCancel,
4071                OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4072                    ActiveOrderState::OpenInFlight(OpenInFlight),
4073                ),
4074            ))),
4075        );
4076        assert!(
4077            dedup_key_from_event(&event).is_none(),
4078            "OpenInFlight should return None — dedup not meaningful before exchange ack"
4079        );
4080
4081        // CancelInFlight → None
4082        let event = UnindexedAccountEvent::new(
4083            ExchangeId::BinanceSpot,
4084            AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4085                key.clone(),
4086                Side::Buy,
4087                None, // Market orders have no limit price
4088                Decimal::ZERO,
4089                OrderKind::Market,
4090                TimeInForce::ImmediateOrCancel,
4091                OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4092                    ActiveOrderState::CancelInFlight(CancelInFlight { order: None }),
4093                ),
4094            ))),
4095        );
4096        assert!(
4097            dedup_key_from_event(&event).is_none(),
4098            "CancelInFlight should return None"
4099        );
4100
4101        // Inactive(FullyFilled) → None
4102        let event = UnindexedAccountEvent::new(
4103            ExchangeId::BinanceSpot,
4104            AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4105                key,
4106                Side::Buy,
4107                None, // Market orders have no limit price
4108                Decimal::ONE,
4109                OrderKind::Market,
4110                TimeInForce::ImmediateOrCancel,
4111                OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
4112                    InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
4113                        OrderId::new("123"),
4114                        Utc::now(),
4115                        Decimal::ONE, // FullyFilled must have non-zero filled_quantity
4116                        None,
4117                    )),
4118                ),
4119            ))),
4120        );
4121        assert!(
4122            dedup_key_from_event(&event).is_none(),
4123            "Inactive state should return None"
4124        );
4125    }
4126
4127    #[test]
4128    fn test_dedup_key_from_event_cancelled_error_returns_none() {
4129        // A REJECTED execution report produces OrderCancelled with Err state.
4130        // Verify that dedup_key_from_event returns None for such events (no dedup needed).
4131        let report = binance_sdk::spot::websocket_api::ExecutionReport {
4132            x: Some("REJECTED".to_string()),
4133            r: Some("INSUFFICIENT_FUNDS".to_string()),
4134            ..make_base_report()
4135        };
4136        let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
4137        assert!(
4138            matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
4139            "prerequisite: event is OrderCancelled with Err"
4140        );
4141        assert!(
4142            dedup_key_from_event(&event).is_none(),
4143            "OrderCancelled with Err state should return None"
4144        );
4145    }
4146
4147    // ---------------------------------------------------------------------------
4148    // convert_user_data_events tests
4149    // ---------------------------------------------------------------------------
4150
4151    /// Build a raw user-data wire frame from an SDK event struct.
4152    ///
4153    /// The SDK event structs carry only the `E` (event time) field, not the lowercase `e`
4154    /// discriminator, so the tag must be injected. The event is then wrapped as the WS-API
4155    /// subscription delivers it, `{ "subscriptionId": 0, "event": { "e": "<type>", .. } }`.
4156    fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
4157        let mut value = serde_json::to_value(event).expect("event serializes to Value");
4158        value
4159            .as_object_mut()
4160            .expect("event serializes to a JSON object")
4161            .insert(
4162                "e".to_string(),
4163                serde_json::Value::String(event_type.to_string()),
4164            );
4165        serde_json::json!({ "subscriptionId": 0, "event": value }).to_string()
4166    }
4167
4168    #[test]
4169    fn test_convert_user_data_events_execution_report_pushes_to_buf() {
4170        let report = binance_sdk::spot::websocket_api::ExecutionReport {
4171            x: Some("NEW".to_string()),
4172            s_uppercase: Some("BUY".to_string()),
4173            o: Some("LIMIT".to_string()),
4174            p: Some("50000.00".to_string()),
4175            q: Some("0.01".to_string()),
4176            f: Some("GTC".to_string()),
4177            z: Some("0".to_string()),
4178            ..make_base_report()
4179        };
4180        let frame = user_data_frame("executionReport", &report);
4181        let mut buf = Vec::new();
4182        let terminated = convert_user_data_events(&frame, &mut buf);
4183        assert!(
4184            !terminated,
4185            "ExecutionReport should not signal stream termination"
4186        );
4187        assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
4188        assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
4189    }
4190
4191    #[test]
4192    fn test_convert_user_data_events_account_position_pushes_to_buf() {
4193        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4194            u: Some(1_700_000_000_000),
4195            b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
4196            ..Default::default()
4197        };
4198        let frame = user_data_frame("outboundAccountPosition", &position);
4199        let mut buf = Vec::new();
4200        let terminated = convert_user_data_events(&frame, &mut buf);
4201        assert!(
4202            !terminated,
4203            "OutboundAccountPosition should not signal stream termination"
4204        );
4205        assert_eq!(
4206            buf.len(),
4207            1,
4208            "OutboundAccountPosition should push one balance event"
4209        );
4210    }
4211
4212    #[test]
4213    fn test_convert_user_data_events_balance_update_ignored() {
4214        let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4215            ..Default::default()
4216        };
4217        let frame = user_data_frame("balanceUpdate", &update);
4218        let mut buf = Vec::new();
4219        let terminated = convert_user_data_events(&frame, &mut buf);
4220        assert!(
4221            !terminated,
4222            "BalanceUpdate should not signal stream termination"
4223        );
4224        assert!(buf.is_empty(), "BalanceUpdate should push no events");
4225    }
4226
4227    #[test]
4228    fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4229        // No payload struct — the terminal event is just the discriminator and its time.
4230        let frame =
4231            r#"{"subscriptionId":0,"event":{"e":"eventStreamTerminated","E":1700000000000}}"#;
4232        let mut buf = Vec::new();
4233        let terminated = convert_user_data_events(frame, &mut buf);
4234        assert!(
4235            terminated,
4236            "eventStreamTerminated must signal stream termination"
4237        );
4238        assert!(
4239            buf.is_empty(),
4240            "eventStreamTerminated should push no events"
4241        );
4242    }
4243
4244    #[test]
4245    fn test_convert_user_data_events_unknown_event_ignored() {
4246        // listStatus / externalLockUpdate / future event types: harmless fall-through —
4247        // ignored, no events pushed, stream not terminated.
4248        let frame =
4249            r#"{"subscriptionId":0,"event":{"e":"listStatus","E":1700000000000,"s":"BTCUSDT"}}"#;
4250        let mut buf = Vec::new();
4251        let terminated = convert_user_data_events(frame, &mut buf);
4252        assert!(!terminated, "unknown event must not signal termination");
4253        assert!(buf.is_empty(), "unknown event should push no events");
4254    }
4255
4256    /// Regression: the WS-API subscription wraps every event, and binance-sdk passes the frame on
4257    /// unchanged. The converter used to read `e` from the top level, found none in the envelope,
4258    /// and dropped every event without a log. An enveloped fill must reach the buffer, and a frame
4259    /// that is not an envelope must not be read as one.
4260    #[test]
4261    fn test_convert_user_data_events_reads_the_ws_api_envelope() {
4262        let fill = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000001000,
4263            "s":"BTCUSDT","c":"client-1","S":"SELL","o":"MARKET","f":"GTC","q":"0.5","p":"0",
4264            "x":"TRADE","X":"FILLED","i":777,"l":"0.5","z":"0.5","L":"40000","n":"0.02",
4265            "N":"USDT","T":1700000001000,"t":888}}"#;
4266        let mut buf = Vec::new();
4267        assert!(!convert_user_data_events(fill, &mut buf));
4268        assert!(
4269            buf.iter()
4270                .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
4271            "the enveloped fill must produce its trade, got: {buf:?}"
4272        );
4273
4274        // The pre-fix fixture shape: a bare event with no envelope is not an event frame.
4275        let bare = r#"{"e":"executionReport","s":"BTCUSDT","i":777,"x":"NEW","X":"NEW"}"#;
4276        buf.clear();
4277        assert!(!convert_user_data_events(bare, &mut buf));
4278        assert!(buf.is_empty());
4279    }
4280
4281    #[test]
4282    fn test_convert_user_data_events_non_user_data_frame_ignored() {
4283        // RPC responses such as the subscribe ack carry a top-level `id` — ignored, not mis-parsed.
4284        let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4285        let mut buf = Vec::new();
4286        let terminated = convert_user_data_events(frame, &mut buf);
4287        assert!(
4288            !terminated,
4289            "non-user-data frame must not signal termination"
4290        );
4291        assert!(buf.is_empty(), "non-user-data frame should push no events");
4292    }
4293
4294    // ---------------------------------------------------------------------------
4295    // RateLimitTracker tests
4296    // ---------------------------------------------------------------------------
4297
4298    #[tokio::test]
4299    async fn test_rate_limit_tracker_not_blocked_initially() {
4300        tokio::time::pause();
4301        let tracker = RateLimitTracker::new(WeightPool::Spot);
4302        // wait_if_blocked should return immediately (no cooldown set)
4303        tokio::time::timeout(
4304            std::time::Duration::from_millis(1),
4305            tracker.wait_if_blocked(RequestKind::Order),
4306        )
4307        .await
4308        .expect("wait_if_blocked should return immediately when not blocked");
4309    }
4310
4311    #[tokio::test]
4312    async fn test_rate_limit_tracker_blocks_until_deadline() {
4313        tokio::time::pause();
4314        let tracker = RateLimitTracker::new(WeightPool::Spot);
4315        let delay = Duration::from_secs(5);
4316        tracker.on_rate_limited(Some(delay));
4317
4318        // Should not complete immediately
4319        assert!(
4320            tokio::time::timeout(
4321                Duration::from_millis(1),
4322                tracker.wait_if_blocked(RequestKind::Order)
4323            )
4324            .await
4325            .is_err(),
4326            "wait_if_blocked should block while cooldown is active"
4327        );
4328
4329        // Advance past cooldown
4330        tokio::time::advance(delay + Duration::from_millis(1)).await;
4331        tokio::time::timeout(
4332            Duration::from_millis(1),
4333            tracker.wait_if_blocked(RequestKind::Order),
4334        )
4335        .await
4336        .expect("wait_if_blocked should return after cooldown expires");
4337    }
4338
4339    #[tokio::test]
4340    async fn test_rate_limit_tracker_cooldown_extends_to_max() {
4341        tokio::time::pause();
4342        let tracker = RateLimitTracker::new(WeightPool::Spot);
4343
4344        // Set initial 5s cooldown
4345        tracker.on_rate_limited(Some(Duration::from_secs(5)));
4346        // Extend with a longer 10s cooldown — deadline should be pushed out
4347        tracker.on_rate_limited(Some(Duration::from_secs(10)));
4348
4349        // Advance past the initial 5s — should still be blocked
4350        tokio::time::advance(Duration::from_secs(6)).await;
4351        assert!(
4352            tokio::time::timeout(
4353                Duration::from_millis(1),
4354                tracker.wait_if_blocked(RequestKind::Order)
4355            )
4356            .await
4357            .is_err(),
4358            "cooldown should have been extended to 10s"
4359        );
4360
4361        // Advance past the extended 10s deadline
4362        tokio::time::advance(Duration::from_secs(5)).await;
4363        tokio::time::timeout(
4364            Duration::from_millis(1),
4365            tracker.wait_if_blocked(RequestKind::Order),
4366        )
4367        .await
4368        .expect("wait_if_blocked should return after extended cooldown expires");
4369    }
4370
4371    #[tokio::test]
4372    async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
4373        tokio::time::pause();
4374        let tracker = RateLimitTracker::new(WeightPool::Spot);
4375
4376        // Set 10s cooldown then try to shorten with 2s — deadline should stay at 10s
4377        tracker.on_rate_limited(Some(Duration::from_secs(10)));
4378        tracker.on_rate_limited(Some(Duration::from_secs(2)));
4379
4380        // Advance past 2s — should still be blocked
4381        tokio::time::advance(Duration::from_secs(3)).await;
4382        assert!(
4383            tokio::time::timeout(
4384                Duration::from_millis(1),
4385                tracker.wait_if_blocked(RequestKind::Order)
4386            )
4387            .await
4388            .is_err(),
4389            "shorter on_rate_limited must not shorten existing cooldown"
4390        );
4391    }
4392
4393    // ---------------------------------------------------------------------------
4394    // M1: BalanceInsufficient type confusion — explicit field value test
4395    // ---------------------------------------------------------------------------
4396
4397    #[test]
4398    fn test_parse_binance_api_error_balance_insufficient_holds_instrument_name() {
4399        let instrument = InstrumentNameExchange::new("BTCUSDT");
4400        match parse_binance_api_error("Insufficient balance".into(), &instrument) {
4401            ApiError::BalanceInsufficient(asset_field, _) => {
4402                // documented known-wrong value — the AssetNameExchange field holds
4403                // the *instrument* name ("BTCUSDT"), not an asset name ("BTC" or "USDT").
4404                // Splitting the instrument into base/quote requires symbol-info metadata.
4405                // Do NOT pattern-match on this field to identify the low-balance asset.
4406                assert_eq!(
4407                    asset_field.name().as_str(),
4408                    "BTCUSDT",
4409                    "BalanceInsufficient.0 holds the instrument name, not an asset name"
4410                );
4411            }
4412            other => panic!("expected BalanceInsufficient, got {other:?}"),
4413        }
4414    }
4415
4416    // ---------------------------------------------------------------------------
4417    // L1: classify_ws_order_error with a context-wrapped error chain
4418    // ---------------------------------------------------------------------------
4419
4420    #[test]
4421    fn test_classify_ws_order_error_with_wrapped_error_chain() {
4422        // `classify_ws_order_error` uses `anyhow::Error::downcast_ref`, which searches
4423        // the *entire* error chain (not just the root). This test verifies that a
4424        // ResponseError wrapped in anyhow context layers is still detected correctly,
4425        // so SDK-internal context wrapping does not break the rejection check.
4426        let instrument = InstrumentNameExchange::new("BTCUSDT");
4427        let raw = anyhow::anyhow!(WebsocketError::ResponseError {
4428            code: -2010,
4429            message: "insufficient balance".into(),
4430        });
4431        // Unwrapped root: must be detected
4432        assert!(
4433            classify_ws_order_error(&raw, &instrument).is_some(),
4434            "unwrapped ResponseError at root must be detected"
4435        );
4436        // Context-wrapped: anyhow::downcast_ref searches the full chain, so this is also detected
4437        let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
4438        assert!(
4439            classify_ws_order_error(&wrapped, &instrument).is_some(),
4440            "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
4441        );
4442    }
4443
4444    /// A spot REST response reporting `x-mbx-used-weight-1m` at 90% of the default limit pauses
4445    /// later queries but not orders; under it, nothing pauses.
4446    #[tokio::test]
4447    async fn rest_used_weight_near_the_limit_pauses_queries() {
4448        for (used, pauses) in [(5_399, false), (5_400, true)] {
4449            let server = wiremock::MockServer::start().await;
4450            wiremock::Mock::given(wiremock::matchers::method("GET"))
4451                .and(wiremock::matchers::path("/api/v3/openOrders"))
4452                .respond_with(
4453                    wiremock::ResponseTemplate::new(200)
4454                        .insert_header("x-mbx-used-weight-1m", used.to_string())
4455                        .set_body_json(serde_json::json!([])),
4456                )
4457                .mount(&server)
4458                .await;
4459            let rest = Arc::new(SpotRestApi::from_config(
4460                ConfigurationRestApi::builder()
4461                    .api_key("key")
4462                    .api_secret("secret")
4463                    .base_path(server.uri())
4464                    .build()
4465                    .unwrap(),
4466            ));
4467            let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4468
4469            fetch_all_open_orders(rest, Arc::clone(&tracker))
4470                .await
4471                .unwrap();
4472
4473            let query_waits = tokio::time::timeout(
4474                Duration::from_millis(50),
4475                tracker.wait_if_blocked(RequestKind::Query),
4476            )
4477            .await
4478            .is_err();
4479            assert_eq!(query_waits, pauses, "used weight {used}");
4480            tokio::time::timeout(
4481                Duration::from_millis(50),
4482                tracker.wait_if_blocked(RequestKind::Order),
4483            )
4484            .await
4485            .expect("orders never wait on the pause");
4486        }
4487    }
4488
4489    /// A spot client whose WS-API order session connects to `ws_url`.
4490    fn client_with_ws_api(ws_url: String) -> BinanceSpot {
4491        let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4492            "key".into(),
4493            "secret".into(),
4494        ));
4495        client.ws_handle = SpotWsApi::from_config(
4496            ConfigurationWebsocketApi::builder()
4497                .api_key("key")
4498                .api_secret("secret")
4499                .ws_url(ws_url)
4500                .build()
4501                .unwrap(),
4502        );
4503        client
4504    }
4505
4506    /// A cancel by client order ID for `instrument`.
4507    fn cancel_request(
4508        instrument: &InstrumentNameExchange,
4509    ) -> OrderRequestCancel<ExchangeId, &InstrumentNameExchange> {
4510        crate::order::OrderEvent {
4511            key: OrderKey {
4512                exchange: ExchangeId::BinanceSpot,
4513                instrument,
4514                strategy: StrategyId::new("strategy"),
4515                cid: ClientOrderId::new("cid"),
4516            },
4517            state: crate::order::request::RequestCancel { id: None },
4518        }
4519    }
4520
4521    /// A local WebSocket server: every connection is handed to `serve`. Returns its `ws://` URL.
4522    async fn ws_server<F, Fut>(serve: F) -> String
4523    where
4524        F: Fn(tokio::net::TcpStream) -> Fut + Send + 'static,
4525        Fut: std::future::Future<Output = ()> + Send + 'static,
4526    {
4527        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4528        let url = format!("ws://{}", listener.local_addr().unwrap());
4529        tokio::spawn(async move {
4530            while let Ok((stream, _)) = listener.accept().await {
4531                tokio::spawn(serve(stream));
4532            }
4533        });
4534        url
4535    }
4536
4537    /// During a rate-limit cooldown a cancel with no WS-API session is not sent: it fails as a
4538    /// rate-limit rejection, without a handshake.
4539    #[tokio::test]
4540    async fn cancel_without_a_session_during_a_cooldown_is_rejected_unsent() {
4541        let client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4542            "key".into(),
4543            "secret".into(),
4544        ));
4545        client
4546            .rate_limiter
4547            .on_rate_limited(Some(Duration::from_secs(60)));
4548        let instrument = InstrumentNameExchange::new("BTCUSDT");
4549        let response = client
4550            .cancel_order(cancel_request(&instrument))
4551            .await
4552            .expect("a cancel always answers");
4553
4554        assert!(
4555            matches!(
4556                response.state,
4557                Err(UnindexedOrderError::Rejected(ApiError::RateLimit))
4558            ),
4559            "{:?}",
4560            response.state
4561        );
4562        assert!(
4563            client.ws_api.read().await.is_none(),
4564            "no session was opened"
4565        );
4566    }
4567
4568    /// A WS-API handshake Binance refuses with 429 starts a cooldown, and the cancel that needed
4569    /// the session fails as a rate-limit rejection.
4570    #[tokio::test]
4571    async fn a_handshake_refused_with_429_starts_a_cooldown() {
4572        use tokio_tungstenite::tungstenite::http;
4573
4574        let url = ws_server(|stream| async move {
4575            // tungstenite's handshake callback fixes the error type to a whole HTTP response.
4576            #[allow(clippy::result_large_err)]
4577            let refuse = |_: &http::Request<()>, _| {
4578                Err(http::Response::builder()
4579                    .status(http::StatusCode::TOO_MANY_REQUESTS)
4580                    .body(None)
4581                    .unwrap())
4582            };
4583            let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
4584        })
4585        .await;
4586        let client = client_with_ws_api(url);
4587        let instrument = InstrumentNameExchange::new("BTCUSDT");
4588
4589        let response = client
4590            .cancel_order(cancel_request(&instrument))
4591            .await
4592            .expect("a cancel always answers");
4593
4594        assert!(
4595            matches!(
4596                response.state,
4597                Err(UnindexedOrderError::Rejected(ApiError::RateLimit))
4598            ),
4599            "{:?}",
4600            response.state
4601        );
4602        assert!(client.rate_limiter.is_blocked());
4603    }
4604
4605    /// A WS-API cancel response carries the spot pool's weight limit and usage: the limit
4606    /// replaces the default, and usage at 90% of it pauses queries but not orders.
4607    #[tokio::test]
4608    async fn a_ws_api_response_near_the_weight_limit_pauses_queries() {
4609        use futures::{SinkExt as _, StreamExt as _};
4610        use tokio_tungstenite::tungstenite::Message;
4611
4612        let url = ws_server(|stream| async move {
4613            let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
4614                return;
4615            };
4616            while let Some(Ok(message)) = ws.next().await {
4617                let Message::Text(text) = message else {
4618                    continue;
4619                };
4620                let request: serde_json::Value = serde_json::from_str(&text).unwrap();
4621                let response = serde_json::json!({
4622                    "id": request["id"],
4623                    "status": 200,
4624                    "result": {
4625                        "symbol": "BTCUSDT",
4626                        "origClientOrderId": "cid",
4627                        "orderId": 7,
4628                        "transactTime": 1_700_000_000_000_i64,
4629                        "executedQty": "0",
4630                    },
4631                    "rateLimits": [{
4632                        "rateLimitType": "REQUEST_WEIGHT",
4633                        "interval": "MINUTE",
4634                        "intervalNum": 1,
4635                        "limit": 1_000,
4636                        "count": 950,
4637                    }],
4638                });
4639                if ws.send(Message::text(response.to_string())).await.is_err() {
4640                    return;
4641                }
4642            }
4643        })
4644        .await;
4645        let client = client_with_ws_api(url);
4646        let instrument = InstrumentNameExchange::new("BTCUSDT");
4647
4648        let response = client
4649            .cancel_order(cancel_request(&instrument))
4650            .await
4651            .expect("a cancel always answers");
4652
4653        assert!(response.state.is_ok(), "{:?}", response.state);
4654        assert_eq!(client.rate_limiter.weight_limit(), 1_000);
4655        assert!(
4656            tokio::time::timeout(
4657                Duration::from_millis(50),
4658                client.rate_limiter.wait_if_blocked(RequestKind::Query),
4659            )
4660            .await
4661            .is_err(),
4662            "queries pause"
4663        );
4664        tokio::time::timeout(
4665            Duration::from_millis(50),
4666            client.rate_limiter.wait_if_blocked(RequestKind::Order),
4667        )
4668        .await
4669        .expect("orders never wait on the pause");
4670    }
4671
4672    /// Fill recovery does not wait for the pause near the weight limit, which could outlast its
4673    /// budget and lose the fills: with queries paused, it still reads the trades at once.
4674    #[tokio::test]
4675    async fn fill_recovery_does_not_wait_for_the_weight_pause() {
4676        let server = wiremock::MockServer::start().await;
4677        wiremock::Mock::given(wiremock::matchers::method("GET"))
4678            .and(wiremock::matchers::path("/api/v3/myTrades"))
4679            // One trade, so recovery also looks its order up: both reads run under the pause.
4680            .respond_with(
4681                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
4682                    "symbol": "BTCUSDT",
4683                    "id": 1,
4684                    "orderId": 7,
4685                    "price": "100",
4686                    "qty": "1",
4687                    "commission": "0",
4688                    "commissionAsset": "USDT",
4689                    "time": Utc::now().timestamp_millis(),
4690                    "isBuyer": true,
4691                    "isMaker": false,
4692                }])),
4693            )
4694            .expect(2)
4695            .mount(&server)
4696            .await;
4697        let rest = Arc::new(SpotRestApi::from_config(
4698            ConfigurationRestApi::builder()
4699                .api_key("key")
4700                .api_secret("secret")
4701                .base_path(server.uri())
4702                .build()
4703                .unwrap(),
4704        ));
4705        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4706        tracker.throttle(Duration::from_secs(60));
4707        let (tx, _rx) = mpsc::unbounded_channel();
4708        let mut unrecovered = UnrecoveredFills::default();
4709        unrecovered.open(
4710            &[InstrumentNameExchange::new("BTCUSDT")],
4711            Utc::now() - chrono::Duration::minutes(1),
4712            Utc::now(),
4713        );
4714
4715        // Far under the 60 s pause set above, and ample for two local round trips.
4716        tokio::time::timeout(
4717            Duration::from_millis(800),
4718            recover_fills(&rest, &tracker, &mut unrecovered, &tx, &new_dedup_cache()),
4719        )
4720        .await
4721        .expect("recovery does not wait for the pause");
4722    }
4723
4724    /// A recovery that fails keeps its gap, closed at its own start; the retry reads exactly that
4725    /// span, forwards only the fill inside it (a later one arrived live), and clears it.
4726    #[tokio::test]
4727    async fn a_failed_recovery_keeps_its_closed_gap_for_the_retry() {
4728        let disconnect = Utc::now() - chrono::Duration::minutes(10);
4729        let reconnect = Utc::now() - chrono::Duration::minutes(5);
4730        let trade = |id: i64, time: DateTime<Utc>| {
4731            serde_json::json!({
4732                "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
4733                "commission": "0", "commissionAsset": "USDT", "time": time.timestamp_millis(),
4734                "isBuyer": true, "isMaker": false, "isBestMatch": true,
4735            })
4736        };
4737        let server = wiremock::MockServer::start().await;
4738        wiremock::Mock::given(wiremock::matchers::method("GET"))
4739            .and(wiremock::matchers::path("/api/v3/myTrades"))
4740            .respond_with(
4741                wiremock::ResponseTemplate::new(400)
4742                    .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
4743            )
4744            .up_to_n_times(1)
4745            .with_priority(1)
4746            .mount(&server)
4747            .await;
4748        wiremock::Mock::given(wiremock::matchers::method("GET"))
4749            .and(wiremock::matchers::path("/api/v3/myTrades"))
4750            .respond_with(
4751                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
4752                    trade(1, disconnect + chrono::Duration::minutes(1)),
4753                    trade(2, Utc::now() - chrono::Duration::minutes(1)),
4754                ])),
4755            )
4756            .mount(&server)
4757            .await;
4758        let rest = Arc::new(SpotRestApi::from_config(
4759            ConfigurationRestApi::builder()
4760                .api_key("key")
4761                .api_secret("secret")
4762                .base_path(server.uri())
4763                .build()
4764                .unwrap(),
4765        ));
4766        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4767        let (tx, mut rx) = mpsc::unbounded_channel();
4768        let dedup = new_dedup_cache();
4769        let mut unrecovered = UnrecoveredFills::default();
4770        unrecovered.open(
4771            &[InstrumentNameExchange::new("BTCUSDT")],
4772            disconnect,
4773            reconnect,
4774        );
4775
4776        recover_fills(&rest, &tracker, &mut unrecovered, &tx, &dedup).await;
4777        assert!(!unrecovered.is_empty(), "the failed gap is kept");
4778        assert!(rx.try_recv().is_err(), "nothing forwarded");
4779        assert!(
4780            unrecovered.due(tokio::time::Instant::now()).is_empty(),
4781            "and waits for its retry"
4782        );
4783
4784        unrecovered.make_due();
4785        recover_fills(&rest, &tracker, &mut unrecovered, &tx, &dedup).await;
4786        assert!(unrecovered.is_empty(), "the retry recovered it");
4787        let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
4788        assert_eq!(
4789            forwarded.len(),
4790            1,
4791            "only the fill inside the gap: {forwarded:?}"
4792        );
4793
4794        let start_times: Vec<String> = server
4795            .received_requests()
4796            .await
4797            .unwrap()
4798            .iter()
4799            .filter_map(|request| {
4800                request
4801                    .url
4802                    .query_pairs()
4803                    .find(|(key, _)| key == "startTime")
4804                    .map(|(_, value)| value.into_owned())
4805            })
4806            .collect();
4807        let start = disconnect.timestamp_millis().to_string();
4808        assert_eq!(start_times, [start.clone(), start]);
4809    }
4810
4811    /// A span walk reads on by id through a full page inside the span, and stops at the page that
4812    /// reaches past its end, without the executions after it.
4813    #[tokio::test]
4814    async fn a_span_walk_pages_by_id_and_stops_past_the_end() {
4815        let start = Utc::now().timestamp_millis() - 600_000;
4816        let end = start + 10_000;
4817        let trade = |id: i64, time: i64| {
4818            serde_json::json!({
4819                "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
4820                "commission": "0", "commissionAsset": "USDT", "time": time,
4821                "isBuyer": true, "isMaker": false, "isBestMatch": true,
4822            })
4823        };
4824        let server = wiremock::MockServer::start().await;
4825        wiremock::Mock::given(wiremock::matchers::method("GET"))
4826            .and(wiremock::matchers::path("/api/v3/myTrades"))
4827            .and(wiremock::matchers::query_param("fromId", "1001"))
4828            // A full page that reaches past the end: only the end, not a short page, stops the walk.
4829            .respond_with(
4830                wiremock::ResponseTemplate::new(200).set_body_json(
4831                    (1_001..=2_000)
4832                        .map(|id| trade(id, if id == 1_001 { end } else { end + 1 }))
4833                        .collect::<Vec<_>>(),
4834                ),
4835            )
4836            .with_priority(1)
4837            .mount(&server)
4838            .await;
4839        let full_page: Vec<_> = (1..=1_000).map(|id| trade(id, start + id)).collect();
4840        wiremock::Mock::given(wiremock::matchers::method("GET"))
4841            .and(wiremock::matchers::path("/api/v3/myTrades"))
4842            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(full_page))
4843            .mount(&server)
4844            .await;
4845        let rest = Arc::new(SpotRestApi::from_config(
4846            ConfigurationRestApi::builder()
4847                .api_key("key")
4848                .api_secret("secret")
4849                .base_path(server.uri())
4850                .build()
4851                .unwrap(),
4852        ));
4853
4854        let read = paginate_my_trades(
4855            &rest,
4856            &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
4857            &InstrumentNameExchange::new("BTCUSDT"),
4858            MyTradesFrom::Span { start, end },
4859            RequestKind::Query,
4860        )
4861        .await
4862        .unwrap();
4863
4864        let ids: Vec<_> = read.iter().filter_map(|t| t.id).collect();
4865        assert_eq!(ids, (1..=1_001).collect::<Vec<_>>());
4866        assert_eq!(server.received_requests().await.unwrap().len(), 2);
4867    }
4868
4869    /// When recovery times out, a gap whose read started has failed and waits for its retry, but
4870    /// one never started (eight are read at a time) is not charged and stays due.
4871    #[tokio::test]
4872    async fn a_timed_out_recovery_charges_only_the_gaps_it_started() {
4873        let server = wiremock::MockServer::start().await;
4874        wiremock::Mock::given(wiremock::matchers::method("GET"))
4875            .and(wiremock::matchers::path("/api/v3/myTrades"))
4876            .respond_with(
4877                wiremock::ResponseTemplate::new(200)
4878                    .set_body_json(serde_json::json!([]))
4879                    .set_delay(Duration::from_secs(3_600)),
4880            )
4881            .mount(&server)
4882            .await;
4883        let rest = Arc::new(SpotRestApi::from_config(
4884            ConfigurationRestApi::builder()
4885                .api_key("key")
4886                .api_secret("secret")
4887                .base_path(server.uri())
4888                .timeout(3_600_000_u64)
4889                .retries(0_u32)
4890                .build()
4891                .unwrap(),
4892        ));
4893        let instruments: Vec<_> = (0..9)
4894            .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
4895            .collect();
4896        let mut unrecovered = UnrecoveredFills::default();
4897        unrecovered.open(
4898            &instruments,
4899            Utc::now() - chrono::Duration::minutes(10),
4900            Utc::now(),
4901        );
4902        let (tx, _rx) = mpsc::unbounded_channel();
4903        // Paused: once every read is waiting, the clock jumps to the recovery timeout.
4904        tokio::time::pause();
4905
4906        recover_fills(
4907            &rest,
4908            &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
4909            &mut unrecovered,
4910            &tx,
4911            &new_dedup_cache(),
4912        )
4913        .await;
4914
4915        let now = tokio::time::Instant::now();
4916        assert_eq!(
4917            unrecovered.due(now).len(),
4918            1,
4919            "the gap never started stays due"
4920        );
4921        assert_eq!(
4922            unrecovered
4923                .due(now + Duration::from_secs(GAP_RETRY_BASE_SECS))
4924                .len(),
4925            9,
4926            "the eight started wait for their first retry"
4927        );
4928    }
4929}