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