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