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