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