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/418 and Binance -1003/-1015, reads the wait
16//   Binance sends with them (REST Retry-After, WS-API retryAfter) into
17//   ApiError::RateLimit's retry_after, and holds every REST call, order and cancel back
18//   for that wait (the account stream's own connects do not check it). Short waits are
19//   slept through and REST retried with exponential backoff; a request facing a longer
20//   one, such as a 418 ban, fails at once with the time left. Separately, pauses REST
21//   queries until the next minute once responses report >= 90% of the request-weight
22//   limit used
23// - Reconnection: account_stream auto-reconnects on WS disconnect/error with
24//   exponential backoff (1s → 30s, max 10 attempts)
25// - Heartbeat monitoring: tracks WS activity via AtomicBool flag; forces reconnect
26//   if no activity (messages, ping, pong) for 30 seconds
27// - Fill recovery: on reconnect, fetches missed trades via REST since disconnect
28//   timestamp, sends through dedup cache to avoid duplicates
29//
30// Known limitations:
31// - balanceUpdate events (deposits/withdrawals) are silently ignored. The crypto
32//   repo wrapper should call fetch_balances or account_snapshot periodically to
33//   reconcile balances after external transfers.
34// - A TRADE report whose order status (X) is neither PARTIALLY_FILLED nor FILLED emits
35//   the execution but no order snapshot, so a fill arriving after its order's terminal
36//   report cannot resurrect a retired order as a resting one. That order's filled
37//   quantity is settled instead by the terminal report's own z, or by fetch_open_orders.
38
39use super::commission::{CommissionRates, fetch_commission_rates};
40use super::restatement::{
41    BalanceRestatement, SharedStreamClock, StreamClock, restate, restatement_time,
42};
43use super::rules::RulesLoader;
44use super::server_time::{ServerTime, SigningClock, default_server_time_sync, read_server_time};
45use super::shared::{
46    AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
47    CONNECT_TIMEOUT_SECS, Disconnect, DisconnectOnDrop, ExecutionReport,
48    FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS, HandshakeRefusal, MyTradesFrom,
49    ORDER_EXECUTIONS_BUDGET, OpenOrderListing, PlacementResponse, RateLimitTracker, RequestKind,
50    SharedDedupCache, UnhandledEvents, UnrecoveredFills, UnsubscribeOnDrop, UserDataFrame,
51    WeightPool, account_position_time, answer_time, binance_filled_qty, classify_order_kind_tif,
52    classify_rest_query_error, classify_ws_order_error, convert_ended_order,
53    convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
54    dedup_key_from_event, disconnect_error, disconnect_lookback, drop_after, frame_excerpt,
55    gap_failed, gap_time, is_duplicate, is_unknown_order, log_unhandled_event,
56    log_unrecognised_frame, new_dedup_cache, parse_user_data_frame, placed_order_state,
57    recovered_order_cids, recovered_order_totals, response_decode_error, rest_call_with_retry,
58    rest_read_with_retry, retry_or_end, trailing_params, unix_ms, unreadable_answer,
59};
60use crate::client::venue_clock::HostMoment;
61use crate::rules::{OrderRules, PreSendRules};
62use crate::{
63    AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
64    UnindexedAccountEvent, UnindexedAccountSnapshot,
65    balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
66    client::{
67        ExecutionClient, OrderStatusClient,
68        order_recovery::{
69            KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
70            fetch_ended_by_key, recover_ended_orders,
71        },
72        reconnect_budget::{BackoffLimits, ReconnectBudget},
73    },
74    emit_stream_terminated,
75    error::{
76        ApiError, ConnectivityError, NotPlacedReason, OrderError, UnindexedClientError,
77        UnindexedOrderError,
78    },
79    margin::MarginEffect,
80    order::{
81        Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
82        id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
83        request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
84        state::{Cancelled, Open, OrderState, UnindexedOrderState},
85    },
86    parse_env_bool,
87    position::PositionReport,
88    trade::{AssetFees, TimeSource, Trade, TradeId, TradesRead},
89};
90use binance_sdk::{
91    common::{
92        config::{ConfigurationRestApi, ConfigurationWebsocketApi},
93        models::WebsocketEvent,
94    },
95    spot::{
96        SpotRestApi, SpotWsApi,
97        rest_api::{
98            GetAccountParams, GetOpenOrdersParams, GetOrderParams, MyTradesParams, RestApi,
99        },
100        websocket_api::{
101            OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
102            OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
103            WebsocketApiHandle,
104        },
105    },
106};
107use chrono::{DateTime, TimeDelta, TimeZone, Utc};
108use fnv::FnvHashSet;
109use futures::stream::BoxStream;
110use rust_decimal::Decimal;
111use rustrade_instrument::{
112    Side,
113    asset::name::AssetNameExchange,
114    exchange::ExchangeId,
115    instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
116};
117use serde::Deserialize;
118use smol_str::format_smolstr;
119use std::{
120    collections::HashMap,
121    str::FromStr,
122    sync::{
123        Arc,
124        atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
125    },
126    time::Duration,
127};
128use tokio::sync::{RwLock, mpsc, oneshot};
129use tracing::{debug, error, info, trace, warn};
130
131// ---------------------------------------------------------------------------
132// Configuration
133// ---------------------------------------------------------------------------
134
135/// Configuration for the BinanceSpot execution client.
136// Serialize intentionally omitted — would expose secret_key in plaintext
137#[derive(Clone, Deserialize)]
138pub struct BinanceSpotConfig {
139    // not pub — prevents accidental credential exposure via struct access.
140    // Use BinanceSpotConfig::new() to construct, or deserialize from config file.
141    api_key: String,
142    secret_key: String,
143
144    /// Use testnet endpoints instead of production.
145    #[serde(default = "default_testnet")]
146    pub testnet: bool,
147
148    /// Whether an order is checked against its symbol's tick, lot and notional rules before it
149    /// is sent. Defaults to [`PreSendRules::Enforce`]. See
150    /// [`BinanceSpot::open_order`](crate::client::ExecutionClient::open_order).
151    #[serde(default)]
152    pub pre_send_rules: PreSendRules,
153
154    /// Whether signed requests are stamped with Binance's clock, read from its server time,
155    /// rather than this host's. Defaults to `true`. See
156    /// [`BinanceSpot::host_clock_lead`](crate::client::binance::BinanceSpot::host_clock_lead).
157    #[serde(default = "crate::client::binance::server_time::default_server_time_sync")]
158    pub server_time_sync: bool,
159}
160
161/// Serde default for [`BinanceSpotConfig::testnet`]: an absent `testnet` field deserializes to the
162/// **safe** testnet environment (`true`).
163///
164/// `#[serde(default = "…")]` requires a named function (it cannot take a literal), so this exists
165/// purely to supply that default to the derive.
166fn default_testnet() -> bool {
167    true
168}
169
170// custom Debug to avoid leaking credentials in logs
171impl std::fmt::Debug for BinanceSpotConfig {
172    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173        f.debug_struct("BinanceSpotConfig")
174            .field("api_key", &"***")
175            .field("secret_key", &"***")
176            .field("testnet", &self.testnet)
177            .field("pre_send_rules", &self.pre_send_rules)
178            .field("server_time_sync", &self.server_time_sync)
179            .finish()
180    }
181}
182
183impl BinanceSpotConfig {
184    /// Create a new config using the **safe** testnet endpoints (alias for
185    /// [`testnet`](Self::testnet)).
186    pub fn new(api_key: String, secret_key: String) -> Self {
187        Self::testnet(api_key, secret_key)
188    }
189
190    /// Create a config targeting Binance Spot's **testnet** endpoints (simulated funds).
191    pub fn testnet(api_key: String, secret_key: String) -> Self {
192        Self {
193            api_key,
194            secret_key,
195            testnet: true,
196            pre_send_rules: PreSendRules::default(),
197            server_time_sync: default_server_time_sync(),
198        }
199    }
200
201    /// Set whether an order is checked against its symbol's rules before it is sent.
202    #[must_use]
203    pub fn with_pre_send_rules(mut self, pre_send_rules: PreSendRules) -> Self {
204        self.pre_send_rules = pre_send_rules;
205        self
206    }
207
208    /// Set whether signed requests are stamped with Binance's clock, read from its server time
209    /// ([`server_time_sync`](Self::server_time_sync)).
210    #[must_use]
211    pub fn with_server_time_sync(mut self, server_time_sync: bool) -> Self {
212        self.server_time_sync = server_time_sync;
213        self
214    }
215
216    /// Create a config targeting Binance Spot's **production** endpoints.
217    ///
218    /// ⚠️ Production trades execute against the live account with **real funds**. Prefer
219    /// [`testnet`](Self::testnet) unless you explicitly intend live execution.
220    pub fn production(api_key: String, secret_key: String) -> Self {
221        Self {
222            api_key,
223            secret_key,
224            testnet: false,
225            pre_send_rules: PreSendRules::default(),
226            server_time_sync: default_server_time_sync(),
227        }
228    }
229
230    /// Build a config from environment variables.
231    ///
232    /// Reads:
233    /// - `BINANCE_API_KEY` (required) — API key.
234    /// - `BINANCE_SECRET_KEY` (required) — API secret.
235    /// - `BINANCE_TESTNET` (optional) — `"true"`/`"false"` (case-insensitive). **Absent ⇒ the safe
236    ///   testnet environment.** Set `BINANCE_TESTNET=false` to target production (real funds).
237    ///
238    /// # Errors
239    ///
240    /// Returns [`BinanceSpotConfigError`] (never panics):
241    /// - a required credential var is unset
242    ///   ([`MissingApiKey`](BinanceSpotConfigError::MissingApiKey) /
243    ///   [`MissingSecretKey`](BinanceSpotConfigError::MissingSecretKey)) or holds non-UTF-8
244    ///   ([`InvalidApiKey`](BinanceSpotConfigError::InvalidApiKey) /
245    ///   [`InvalidSecretKey`](BinanceSpotConfigError::InvalidSecretKey));
246    /// - `BINANCE_TESTNET` is neither `true` nor `false`, or holds non-UTF-8
247    ///   ([`InvalidTestnet`](BinanceSpotConfigError::InvalidTestnet)).
248    pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
249        let api_key = match std::env::var("BINANCE_API_KEY") {
250            Ok(value) => value,
251            Err(std::env::VarError::NotPresent) => {
252                return Err(BinanceSpotConfigError::MissingApiKey);
253            }
254            Err(std::env::VarError::NotUnicode(_)) => {
255                return Err(BinanceSpotConfigError::InvalidApiKey);
256            }
257        };
258        let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
259            Ok(value) => value,
260            Err(std::env::VarError::NotPresent) => {
261                return Err(BinanceSpotConfigError::MissingSecretKey);
262            }
263            Err(std::env::VarError::NotUnicode(_)) => {
264                return Err(BinanceSpotConfigError::InvalidSecretKey);
265            }
266        };
267        let testnet = match std::env::var("BINANCE_TESTNET") {
268            Ok(value) => {
269                parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
270            }
271            Err(std::env::VarError::NotPresent) => true,
272            // The toggle value is not secret, so echo it (lossily) like the parse-failure arm above —
273            // an actionable "got X" beats a hardcoded sentinel.
274            Err(std::env::VarError::NotUnicode(value)) => {
275                return Err(BinanceSpotConfigError::InvalidTestnet(
276                    value.to_string_lossy().into_owned(),
277                ));
278            }
279        };
280
281        if testnet {
282            Ok(Self::testnet(api_key, secret_key))
283        } else {
284            Ok(Self::production(api_key, secret_key))
285        }
286    }
287
288    /// Read-only access to the API key (e.g. for logging or header construction).
289    pub fn api_key(&self) -> &str {
290        &self.api_key
291    }
292}
293
294#[derive(Debug, PartialEq, thiserror::Error)]
295pub enum BinanceSpotConfigError {
296    #[error("BINANCE_API_KEY environment variable not set")]
297    MissingApiKey,
298
299    // No payload: the raw value is secret-key material, so it must never be echoed into an error
300    // message or log. The variant name already identifies which credential var is non-UTF-8.
301    #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
302    InvalidApiKey,
303
304    #[error("BINANCE_SECRET_KEY environment variable not set")]
305    MissingSecretKey,
306
307    #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
308    InvalidSecretKey,
309
310    #[error("BINANCE_TESTNET must be true or false, got {0}")]
311    InvalidTestnet(String),
312}
313
314// ---------------------------------------------------------------------------
315// BinanceSpot client
316// ---------------------------------------------------------------------------
317
318/// Why [`BinanceSpot::get_ws_api`] has no WS-API session to hand out. Either way, nothing was
319/// sent.
320#[derive(Debug)]
321enum WsApiUnavailable {
322    /// A rate-limit cooldown is active, or Binance refused the handshake with 429 or 418. Carries
323    /// the wait the caller is told, when one is known.
324    RateLimited {
325        msg: String,
326        retry_after: Option<Duration>,
327    },
328    /// Any other connect failure.
329    Connect(String),
330}
331
332impl WsApiUnavailable {
333    /// The error an order or cancel that could not be sent fails with. A rate limit is a
334    /// rejection, which tells the caller the request was not sent and to back off.
335    fn into_order_error(self) -> UnindexedOrderError {
336        match self {
337            Self::RateLimited { msg, retry_after } => {
338                // debug! not warn!: during a ban every order and cancel lands here, and the
339                // refusal that started the cooldown already warned (`on_rate_limited`).
340                debug!(%msg, "BinanceSpot WS-API rate-limited, request not sent");
341                UnindexedOrderError::Rejected(ApiError::rate_limited(retry_after))
342            }
343            Self::Connect(msg) => UnindexedOrderError::Connectivity(ConnectivityError::Socket(msg)),
344        }
345    }
346
347    /// The error an open that could not be sent fails with: as
348    /// [`into_order_error`](Self::into_order_error), but a connect failure says the order was not
349    /// placed, since no session existed to send it on.
350    fn into_open_error(self) -> UnindexedOrderError {
351        match self {
352            Self::Connect(msg) => UnindexedOrderError::NotPlaced(NotPlacedReason::NotSent(
353                ConnectivityError::Socket(msg),
354            )),
355            rate_limited @ Self::RateLimited { .. } => rate_limited.into_order_error(),
356        }
357    }
358}
359
360/// BinanceSpot execution client using the official binance-sdk.
361///
362/// - REST API: account snapshot, balance/order/trade queries (startup/cold paths)
363/// - WebSocket API: order placement, order cancellation, user data stream (hot paths)
364///
365/// # Rate limits
366/// REST and the WebSocket API share one per-IP request-weight limit per minute. Its value comes
367/// from each WebSocket API response, and is Binance's documented 6000 until the first one. Once
368/// a response reports at least 90% of it used, REST queries wait for the next minute, so the
369/// rest is left for orders and cancels, which never wait for it. A query, including a
370/// [`fetch_open_orders`](Self::fetch_open_orders) after a reconnect, can therefore take up to
371/// about a minute longer. A reconnect's fill recovery does not wait for it either, since a pause
372/// could outlast the recovery's 30 s and delay its fills to a retry.
373///
374/// After Binance answers with a rate-limit error, every REST call, order and cancel waits out a
375/// cooldown as long as Binance asked (REST's `Retry-After`, the WebSocket API's
376/// `retryAfter`), or 10 s when it named no wait. A request facing more than 10 s of it is not
377/// sent: it fails at once with [`ApiError::RateLimit`] carrying the time left, rather than hanging
378/// through a ban that can last days. A REST 418 (an IP ban) is never retried in place. The
379/// WebSocket API names the end of a ban as a time on Binance's clock, which the client measures
380/// against the local clock, plus 1 s, so keep the host's clock synchronised.
381///
382/// The account stream follows the same rule. It does not open, or reconnect, during a cooldown:
383/// it waits out one of 10 s or less, without spending a reconnect attempt, and a longer one fails
384/// the open with [`ApiError::RateLimit`], unsent, or ends a running stream with
385/// [`StreamTerminationReason::RateLimited`](crate::error::StreamTerminationReason::RateLimited),
386/// carrying the time left. A handshake or user-data subscribe Binance refuses for a rate limit
387/// starts a cooldown, as an order's refusal does. A refused handshake names no wait, so a 429 starts
388/// the 10 s default, and handshakes refused that way spend the
389/// [reconnect budget](super#account-stream-reconnection) as any failure does; a 418, an IP ban,
390/// starts Binance's shortest ban, 2 minutes, and so ends the stream.
391///
392/// # Order and cancel times
393/// The time of a placed or cancelled order is the `transactTime` of Binance's answer. An answer
394/// without a readable one is still read, at the local time, and logged with a `warn!`.
395///
396/// # Fills of other clients' orders
397/// The account stream, fill recovery and `fetch_trades` report every fill on the account, whoever
398/// placed the order, and Binance does not say who did. A consumer that also trades the account
399/// outside the engine filters those fills itself, for example by
400/// [`Trade::client_order_id`](crate::trade::Trade::client_order_id). See the
401/// [module docs](super#every-fill-on-the-account-is-delivered).
402#[derive(Clone)]
403pub struct BinanceSpot {
404    config: Arc<BinanceSpotConfig>,
405    rest: Arc<RestApi>,
406    // Factory handle (cheap Clone) used to create WS connections.
407    ws_handle: WebsocketApiHandle,
408    // shared WS session for order operations (order.place, order.cancel).
409    // Distinct from the account_stream WS session created in connection_manager.
410    // Lazily connected on the first open_order / cancel_order call; cleared on
411    // connectivity errors so the next call reconnects. All clones share the same
412    // session via Arc<RwLock<...>>; the guard closes it when the last clone drops.
413    ws_api: Arc<RwLock<Option<DisconnectOnDrop<WebsocketApi>>>>,
414    // shared rate-limit tracker across all REST calls
415    rate_limiter: Arc<RateLimitTracker>,
416    // The orders seen live and not yet seen end, which a reconnect asks about. Shared by every
417    // clone and every account stream, since an order placed through one ends on any of them.
418    known_live: SharedKnownLiveOrders,
419    // The symbols' tick, lot and notional rules, loaded on each symbol's first order. Shared by
420    // every clone.
421    rules: Arc<RulesLoader>,
422}
423
424impl std::fmt::Debug for BinanceSpot {
425    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426        f.debug_struct("BinanceSpot")
427            .field("testnet", &self.config.testnet)
428            .finish_non_exhaustive()
429    }
430}
431
432impl BinanceSpot {
433    /// Load the order rules of `instruments` from Binance now, and hold them for
434    /// [`open_order`](ExecutionClient::open_order) to check against.
435    ///
436    /// Rules otherwise load on each symbol's first order, which adds a request to that order.
437    /// Calling this ahead takes it off the first order, and shows a bad symbol or a failed load
438    /// here, not as an unchecked order. Binance refuses the whole request if one symbol is not
439    /// listed. Holds nothing back when [`BinanceSpotConfig::pre_send_rules`] is
440    /// [`PreSendRules::Off`]: the rules are returned but not used.
441    pub async fn load_rules(
442        &self,
443        instruments: &[InstrumentNameExchange],
444    ) -> Result<HashMap<InstrumentNameExchange, OrderRules>, UnindexedClientError> {
445        self.rules.load(instruments).await
446    }
447
448    /// The account's commission rates on `instrument`: `GET /api/v3/account/commission`, at 20
449    /// of Binance's per-IP request weight. [`CommissionRates`] says how Binance combines them.
450    ///
451    /// Spot Testnet reports every rate as zero, and no discount asset, unless the account has
452    /// chosen to be charged commissions there.
453    ///
454    /// # Errors
455    ///
456    /// - [`ApiError::InstrumentInvalid`] for a symbol Binance does not list.
457    /// - [`UnindexedClientError::Internal`] for an answer missing a rate, with a rate that does
458    ///   not read as a decimal, or for another symbol.
459    pub async fn fetch_commission_rates(
460        &self,
461        instrument: &InstrumentNameExchange,
462    ) -> Result<CommissionRates, UnindexedClientError> {
463        fetch_commission_rates(&self.rest, &self.rate_limiter, instrument).await
464    }
465
466    /// The most this host's clock leads Binance's, from the client's reads of Binance's server
467    /// time; negative when it trails. `None` when
468    /// [`server_time_sync`](BinanceSpotConfig::server_time_sync) is off, or before a read has
469    /// succeeded.
470    ///
471    /// An upper bound: the true lead, plus the time the kept read's answer took to arrive and the
472    /// drift allowed for since. A read logs at `warn` when it finds the clocks 1 s or more apart.
473    pub fn host_clock_lead(&self) -> Option<TimeDelta> {
474        self.rate_limiter.host_clock_lead()
475    }
476
477    /// Hold each of `orders` as live, for a reconnect to ask about if the stream misses its end.
478    fn remember_live<'a>(
479        &self,
480        orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
481    ) {
482        let mut known = self.known_live.lock();
483        for order in orders {
484            known.live(&order.key, order.quantity, order.kind, &order.state);
485        }
486    }
487
488    /// # Panics
489    /// Panics if the binance-sdk configuration builder fails (invalid credentials format).
490    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
491    fn build_rest(config: &BinanceSpotConfig, signing: Option<&SigningClock>) -> Arc<RestApi> {
492        let mut builder = ConfigurationRestApi::builder()
493            .api_key(config.api_key.clone())
494            .api_secret(config.secret_key.clone());
495        if let Some(signing) = signing {
496            builder = builder.timestamp_provider(signing.provider());
497        }
498        let rest_config = builder
499            .build()
500            .expect("failed to build Binance REST configuration");
501
502        Arc::new(if config.testnet {
503            SpotRestApi::testnet(rest_config)
504        } else {
505            SpotRestApi::production(rest_config)
506        })
507    }
508
509    /// # Panics
510    /// Panics if the binance-sdk configuration builder fails (invalid credentials format).
511    #[allow(clippy::expect_used)] // Documented panic: invalid credentials detected at startup
512    fn build_ws_handle(
513        config: &BinanceSpotConfig,
514        signing: Option<&SigningClock>,
515    ) -> WebsocketApiHandle {
516        let mut builder = ConfigurationWebsocketApi::builder()
517            .api_key(config.api_key.clone())
518            .api_secret(config.secret_key.clone());
519        if let Some(signing) = signing {
520            builder = builder.timestamp_provider(signing.provider());
521        }
522        let ws_config = builder
523            .build()
524            .expect("failed to build Binance WebSocket configuration");
525
526        if config.testnet {
527            SpotWsApi::testnet(ws_config)
528        } else {
529            SpotWsApi::production(ws_config)
530        }
531    }
532
533    /// Returns the shared WebSocket session, connecting on the first call.
534    /// If the previous session was cleared (due to a connectivity error),
535    /// establishes a new connection.
536    ///
537    /// Every order and cancel comes through here, so it first waits out a rate-limit cooldown,
538    /// as REST requests do: Binance counts the WS API against the same limits, and a request
539    /// sent during a ban lengthens it. A cooldown with more than `MAX_COOLDOWN_SLEEP` left fails
540    /// at once with the time left. Nor does it connect during a cooldown that another request
541    /// started meanwhile: a handshake then counts against the limit that set it. A handshake
542    /// Binance refuses with 429 or 418 starts a cooldown, so REST calls back off too.
543    async fn get_ws_api(&self) -> Result<WebsocketApi, WsApiUnavailable> {
544        if let Err(cooldown) = self.rate_limiter.wait_if_blocked(RequestKind::Order).await {
545            return Err(WsApiUnavailable::RateLimited {
546                msg: cooldown.to_string(),
547                retry_after: Some(cooldown.remaining),
548            });
549        }
550        // Fast path: read lock to check if already connected
551        {
552            let guard = self.ws_api.read().await;
553            if let Some(ref ws) = *guard {
554                return Ok(WebsocketApi::clone(ws));
555            }
556        }
557        // Slow path: write lock to connect.
558        // The write lock is held across connect().await (TCP+TLS handshake). Concurrent
559        // open_order / cancel_order callers that also reach get_ws_api will block for the
560        // connection duration. The timeout below bounds the worst case to CONNECT_TIMEOUT_SECS
561        // instead of the OS TCP timeout (75–127 s); on timeout, the write lock is released
562        // immediately and callers receive an error.
563        let mut guard = self.ws_api.write().await;
564        // Double-check after acquiring write lock (another task may have connected)
565        if let Some(ref ws) = *guard {
566            return Ok(WebsocketApi::clone(ws));
567        }
568        if let Some(remaining) = self.rate_limiter.cooldown_remaining() {
569            return Err(WsApiUnavailable::RateLimited {
570                msg: "rate-limit cooldown active, not connecting the WS-API session".into(),
571                retry_after: Some(remaining),
572            });
573        }
574        let ws = match tokio::time::timeout(
575            Duration::from_secs(CONNECT_TIMEOUT_SECS),
576            self.ws_handle.connect(),
577        )
578        .await
579        {
580            Ok(Ok(ws)) => ws,
581            Ok(Err(e)) => {
582                let Some(refusal) = HandshakeRefusal::of(&e) else {
583                    return Err(WsApiUnavailable::Connect(format!("{e:#}")));
584                };
585                // The SDK reports a refused handshake as text, without its `Retry-After`.
586                warn!(error = %format!("{e:#}"), "Binance refused the WS-API handshake");
587                let retry_after = refusal.retry_after();
588                self.rate_limiter.on_rate_limited(retry_after);
589                return Err(WsApiUnavailable::RateLimited {
590                    msg: format!("{e:#}"),
591                    retry_after,
592                });
593            }
594            Err(_) => {
595                return Err(WsApiUnavailable::Connect(format!(
596                    "BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s"
597                )));
598            }
599        };
600        *guard = Some(DisconnectOnDrop::new(
601            ws.clone(),
602            "BinanceSpot order session",
603        ));
604        Ok(ws)
605    }
606
607    /// Clear the cached WS session after a connectivity error, so the next
608    /// `get_ws_api()` call establishes a fresh connection.
609    async fn clear_ws_api(&self) {
610        // release the write lock before awaiting disconnect. Taking `ws` under
611        // the lock then dropping the lock means `get_ws_api()` can establish a new
612        // session concurrently while the old TCP connection is being torn down (two-
613        // connection window). This is safe: each `WebsocketApi` holds fully independent
614        // connection state (separate auth sessions, separate TCP sockets). No duplicate
615        // events are received on both connections because the user data stream
616        // subscription lives only on the connection_manager WS session, not on the
617        // ws_api order session managed here.
618        // Awaiting inline (rather than spawning) bounds task accumulation under rapid retries
619        // during a network partition: a disconnect that outlasts the 5 s wait continues in a
620        // task the guard spawns, which binance-sdk bounds to 30 s.
621        let ws = {
622            let mut guard = self.ws_api.write().await;
623            guard.take()
624        };
625        if let Some(ws) = ws {
626            // On timeout the dropped guard spawns the disconnect again, in the background.
627            match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
628                Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
629                Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
630                Ok(Ok(())) => {}
631            }
632        }
633    }
634}
635
636/// Fetch open orders for a single instrument with rate-limit retry.
637///
638/// Returns `(instrument, orders)` so callers can associate results with their symbol.
639/// Used by both `account_snapshot` (wraps in `OrderState::active()`) and
640/// `fetch_open_orders` (returns `Open` directly), eliminating the duplicated
641/// ~40-line REST + concurrency pattern.
642async fn fetch_open_orders_for_instrument(
643    rest: Arc<RestApi>,
644    rate_limiter: Arc<RateLimitTracker>,
645    instrument: InstrumentNameExchange,
646    kind: RequestKind,
647) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
648    // Convert once before the retry closure to avoid a String allocation on every retry.
649    let symbol_str = instrument.name().to_string();
650    let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
651        let sym = symbol_str.clone();
652        Box::pin(async move {
653            let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
654            rest.get_open_orders(params).await
655        })
656    })
657    .await
658    .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
659
660    let orders_data = response.data().await.map_err(response_decode_error)?;
661
662    // Spot rows report every order's trailingDelta, so none is left unresolved.
663    let listing =
664        convert_open_order_listing(&orders_data, ExchangeId::BinanceSpot, &instrument, &|_| {
665            None
666        });
667
668    Ok((instrument, listing))
669}
670
671/// Fetch *all* open orders in a single no-symbol `GET /api/v3/openOrders` call. Backs the
672/// [`fetch_open_orders`](BinanceSpot::fetch_open_orders) "return all" sentinel: with no symbol the
673/// venue returns orders across every instrument, so each order's instrument is recovered from its
674/// own `symbol` field (orders missing it are dropped).
675async fn fetch_all_open_orders(
676    rest: Arc<RestApi>,
677    rate_limiter: Arc<RateLimitTracker>,
678) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
679    let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
680        Box::pin(async move {
681            let params = GetOpenOrdersParams::builder().build()?;
682            rest.get_open_orders(params).await
683        })
684    })
685    .await
686    .map_err(|e| classify_rest_query_error(&e, None))?;
687
688    let orders_data = response.data().await.map_err(response_decode_error)?;
689
690    let orders = orders_data
691        .into_iter()
692        // Spot rows report every order's trailingDelta, so none is left unresolved.
693        .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot, &|_| None))
694        .collect();
695
696    Ok(orders)
697}
698
699/// Look the order under `key` up with `GET /api/v3/order` by its client order id (weight 4).
700async fn fetch_order_lookup(
701    rest: Arc<RestApi>,
702    rate_limiter: Arc<RateLimitTracker>,
703    key: UnindexedOrderKey,
704    kind: RequestKind,
705) -> Result<OrderLookup, UnindexedClientError> {
706    // Convert once before the retry closure to avoid a String allocation on every retry.
707    let symbol = key.instrument.name().to_string();
708    let cid = key.cid.0.to_string();
709    let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
710        let (symbol, cid) = (symbol.clone(), cid.clone());
711        Box::pin(async move {
712            let params = GetOrderParams::builder(symbol)
713                .orig_client_order_id(cid)
714                .build()?;
715            rest.get_order(params).await
716        })
717    })
718    .await
719    {
720        Ok(response) => response,
721        Err(e) if is_unknown_order(&e) => {
722            debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceSpot does not know this order");
723            return Ok(OrderLookup::Unknown);
724        }
725        Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
726    };
727
728    let row = response.data().await.map_err(response_decode_error)?;
729
730    // Spot rows report trailingDelta, so every kind decodes without the one the client holds.
731    Ok(
732        convert_ended_order(&row, ExchangeId::BinanceSpot, &key, None)
733            .map_or(OrderLookup::NotEnded, |order| {
734                OrderLookup::Ended(Box::new(order))
735            }),
736    )
737}
738
739/// The client order id of the order `order_id` on `instrument`, read with `GET /api/v3/order`
740/// (weight 4), for fill recovery: `myTrades` carries none. `None` if Binance reports none, or
741/// does not know the order.
742async fn fetch_order_cid(
743    rest: &Arc<RestApi>,
744    rate_limiter: &RateLimitTracker,
745    instrument: &InstrumentNameExchange,
746    order_id: i64,
747) -> Result<Option<ClientOrderId>, UnindexedClientError> {
748    let symbol = instrument.name().to_string();
749    let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
750        let symbol = symbol.clone();
751        Box::pin(async move {
752            let params = GetOrderParams::builder(symbol).order_id(order_id).build()?;
753            rest.get_order(params).await
754        })
755    })
756    .await
757    {
758        Ok(response) => response,
759        Err(e) if is_unknown_order(&e) => {
760            debug!(%instrument, order_id, error = %e, "BinanceSpot does not know this order");
761            return Ok(None);
762        }
763        Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
764    };
765    let row = response.data().await.map_err(response_decode_error)?;
766    Ok(row
767        .client_order_id
768        .filter(|cid| !cid.is_empty())
769        .map(ClientOrderId::new))
770}
771
772/// The client order ids `GET /api/v3/openOrders` lists on `instruments` (weight 6 each, one at a
773/// time), for a reconnect's check of the orders held as live.
774async fn listed_open_cids(
775    rest: Arc<RestApi>,
776    rate_limiter: Arc<RateLimitTracker>,
777    instruments: Vec<InstrumentNameExchange>,
778) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
779    let mut listed = FnvHashSet::default();
780    for instrument in instruments {
781        let (_, listing) = fetch_open_orders_for_instrument(
782            rest.clone(),
783            rate_limiter.clone(),
784            instrument,
785            RequestKind::Essential,
786        )
787        .await?;
788        listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
789    }
790    Ok(listed)
791}
792
793/// [`recover_ended_orders`] on Binance Spot: listings by [`listed_open_cids`] and lookups by
794/// [`fetch_order_lookup`], both [`RequestKind::Essential`], as fill recovery's reads are.
795async fn recover_spot_ended_orders(
796    rest: &Arc<RestApi>,
797    rate_limiter: &Arc<RateLimitTracker>,
798    known: &SharedKnownLiveOrders,
799    unchecked: &mut UncheckedOrders,
800    unrecovered: &UnrecoveredFills,
801    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
802) {
803    recover_ended_orders(
804        ExchangeId::BinanceSpot,
805        known,
806        unchecked,
807        unrecovered,
808        tx,
809        OpenListing::PerInstrument,
810        |instruments| listed_open_cids(rest.clone(), rate_limiter.clone(), instruments),
811        |key| {
812            fetch_order_lookup(
813                rest.clone(),
814                rate_limiter.clone(),
815                key,
816                RequestKind::Essential,
817            )
818        },
819    )
820    .await;
821}
822
823/// Paginate `GET /api/v3/myTrades` for a single instrument, from `from`.
824///
825/// Uses cursor-based pagination: the first page queries by `start_time`, or by `order_id` for a
826/// single order's executions; subsequent pages use `from_id = last_id + 1` (Binance ignores
827/// `start_time` when `from_id` is set), keeping `order_id` alongside it, a combination Binance
828/// documents as supported. A [`MyTradesFrom::Span`] stops at the first page reaching past its end
829/// and drops the executions after it. Trade IDs are monotonically increasing per symbol, so this produces a
830/// gapless result.
831///
832/// Returns raw response items. Callers decide how to handle `Err` (propagate vs. log-skip).
833async fn paginate_my_trades(
834    rest: &Arc<RestApi>,
835    rate_limiter: &Arc<RateLimitTracker>,
836    instrument: &InstrumentNameExchange,
837    from: MyTradesFrom,
838    kind: RequestKind,
839) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
840    // Convert once before the retry closure to avoid a String allocation on every retry.
841    let symbol_str = instrument.name().to_string();
842    // cursor-based pagination — first page uses start_time; subsequent pages use
843    // from_id (Binance ignores start_time when from_id is set). Trade IDs are
844    // monotonically increasing per symbol, so from_id = last_id + 1 continues exactly
845    // where the previous page left off with no overlap or gap.
846    let mut all_pages = Vec::new();
847    let mut cursor: Option<i64> = None;
848    loop {
849        let fid = cursor; // Option<i64> is Copy
850        let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
851            let sym = symbol_str.clone();
852            Box::pin(async move {
853                // const_assert! above guarantees BINANCE_MAX_TRADES fits in i32
854                #[allow(clippy::cast_possible_truncation)]
855                let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
856                let params = match (from, fid) {
857                    (MyTradesFrom::Span { start, .. }, None) => builder.start_time(start),
858                    (MyTradesFrom::Span { .. }, Some(id)) => builder.from_id(id),
859                    (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
860                    (MyTradesFrom::Order(order_id), Some(id)) => {
861                        builder.order_id(order_id).from_id(id)
862                    }
863                }
864                .build()?;
865                rest.my_trades(params).await
866            })
867        })
868        .await
869        .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
870
871        let mut page = response.data().await.map_err(response_decode_error)?;
872
873        let page_len = page.len();
874        let last_id = page.last().and_then(|t| t.id);
875        // A span ends once a page reaches past it.
876        let past_end = match from {
877            MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
878            MyTradesFrom::Order(_) => false,
879        };
880        all_pages.extend(page);
881
882        if past_end || page_len < BINANCE_MAX_TRADES {
883            break;
884        }
885        match last_id {
886            Some(id) => {
887                debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
888                match id.checked_add(1) {
889                    Some(next) => cursor = Some(next),
890                    None => break, // saturated at i64::MAX; no further pages possible
891                }
892            }
893            None => {
894                warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
895                break;
896            }
897        }
898    }
899    Ok(all_pages)
900}
901
902// ---------------------------------------------------------------------------
903// ExecutionClient implementation
904// ---------------------------------------------------------------------------
905
906impl ExecutionClient for BinanceSpot {
907    const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
908
909    // Binance Spot trades spot pairs only; its derivatives live on separate USD-M/COIN-M venues
910    // with their own APIs, which this client does not speak.
911    const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
912        &[InstrumentKindDiscriminant::Spot];
913    type Config = BinanceSpotConfig;
914    type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
915
916    /// # Panics
917    ///
918    /// Panics if the binance-sdk REST or WebSocket configuration builder fails
919    /// (e.g. empty or malformed API key/secret).
920    fn new(config: Self::Config) -> Self {
921        let signing = config.server_time_sync.then(SigningClock::default);
922        let rest = Self::build_rest(&config, signing.as_ref());
923        let ws_handle = Self::build_ws_handle(&config, signing.as_ref());
924        // `GET /api/v3/time` is unsigned, so the client that signs with the clock reads it.
925        let server_time =
926            signing.map(|signing| ServerTime::new(signing, read_server_time(Arc::clone(&rest))));
927        let rate_limiter =
928            Arc::new(RateLimitTracker::new(WeightPool::Spot).with_server_time(server_time));
929        let rules = Arc::new(RulesLoader::new(
930            config.pre_send_rules,
931            Arc::clone(&rest),
932            Arc::clone(&rate_limiter),
933        ));
934        Self {
935            config: Arc::new(config),
936            rest,
937            ws_handle,
938            ws_api: Arc::new(RwLock::new(None)),
939            rate_limiter,
940            known_live: KnownLiveOrders::shared(ExchangeId::BinanceSpot),
941            rules,
942        }
943    }
944
945    async fn account_snapshot(
946        &self,
947        assets: &[AssetNameExchange],
948        instruments: &[InstrumentNameExchange],
949    ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
950        // Fetch account info via REST (with rate-limit retry)
951        let (response, sent) =
952            rest_read_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
953                Box::pin(async move {
954                    let params = GetAccountParams::builder().build()?;
955                    rest.get_account(params).await
956                })
957            })
958            .await
959            .map_err(|e| classify_rest_query_error(&e, None))?;
960
961        let account = response.data().await.map_err(response_decode_error)?;
962
963        // Convert balances, filtering to requested assets
964        let balances = filter_and_convert_balances(
965            account.balances.unwrap_or_default(),
966            assets,
967            self.rate_limiter.balance_read_time(sent),
968        );
969
970        // Fetch open orders for all instruments concurrently (with retry)
971        // limit concurrency to avoid bursting Binance's request weight limits
972        // (each GET /api/v3/openOrders for one symbol costs 6 weight; 8 concurrent = 48 weight).
973        // account_snapshot wraps Open orders in OrderState::active(); fetch_open_orders
974        // returns them without the wrapper — both use fetch_open_orders_for_instrument.
975        use futures::{StreamExt as _, TryStreamExt};
976        let instrument_snapshots: Vec<_> =
977            futures::stream::iter(instruments.iter().cloned().map(|instrument| {
978                fetch_open_orders_for_instrument(
979                    self.rest.clone(),
980                    self.rate_limiter.clone(),
981                    instrument,
982                    RequestKind::Query,
983                )
984            }))
985            .buffer_unordered(8)
986            .map(|result| {
987                let (inst, listing) = result?;
988                self.remember_live(&listing.orders);
989                let wrapped = listing
990                    .orders
991                    .into_iter()
992                    .map(|o| Order {
993                        key: o.key,
994                        side: o.side,
995                        price: o.price,
996                        quantity: o.quantity,
997                        kind: o.kind,
998                        time_in_force: o.time_in_force,
999                        state: OrderState::active(o.state),
1000                    })
1001                    .collect();
1002                Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1003                    inst,
1004                    wrapped,
1005                    listing.complete,
1006                    PositionReport::Unreported,
1007                    None,
1008                ))
1009            })
1010            .try_collect()
1011            .await?;
1012
1013        Ok(AccountSnapshot::new(
1014            ExchangeId::BinanceSpot,
1015            balances,
1016            instrument_snapshots,
1017        ))
1018    }
1019
1020    /// Returns a live stream of account events (fills, order updates, balance changes).
1021    ///
1022    /// # Reconnection
1023    ///
1024    /// The stream reconnects by itself, and ends once 10 reconnects in a row have failed or a
1025    /// rate-limit cooldown outlasts what it waits through. See the
1026    /// [module docs](super#account-stream-reconnection). After each reconnect it restates the
1027    /// account's balances: see [there](super#balance-restatement-after-a-reconnect).
1028    ///
1029    /// # Startup Race Window
1030    ///
1031    /// There is a brief window between the initial WS subscribe response and the
1032    /// internal event listener being registered inside the spawned connection manager.
1033    /// TRADE fills arriving in this gap are silently dropped. Callers that require fill
1034    /// completeness at startup MUST call [`ExecutionClient::fetch_trades`] with a
1035    /// 1–2 second lookback **unconditionally after `account_stream` returns**, before
1036    /// processing any events. Do not use the first event's arrival as a readiness
1037    /// signal — the first event may be a recovered fill sent before live WS events
1038    /// start. The dedup cache absorbs any duplicates from the overlapping time window.
1039    ///
1040    /// # A recovery that does not finish
1041    ///
1042    /// Recovery after a reconnect is bounded by a 30 s timeout, and an instrument's query can
1043    /// fail. Each instrument's gap, from the disconnect to just after the recovery began, is kept
1044    /// until its fills are forwarded. A gap not read is retried after 1, 2, 4, 8 and 16 minutes,
1045    /// whether the stream stays connected or reconnects in between. A retry reads only the gap,
1046    /// not fills the stream delivered live after it; the few seconds at its end that overlap live
1047    /// delivery are deduplicated. Each failed read is logged at `warn` with the instrument and the
1048    /// gap. After five failed retries the gap is given up, logged at `error`, and its fills are not
1049    /// delivered. The stream says so with an
1050    /// [`AccountEventKind::FillRecoveryGaveUp`] naming the instrument and the gap: read its fills
1051    /// with [`ExecutionClient::fetch_trades`].
1052    ///
1053    /// A recovered gap counts as read even when a fill in it could not be converted, which is
1054    /// logged, or its order's cumulative could not be looked up (see below).
1055    ///
1056    /// # A recovered fill advances the order too
1057    ///
1058    /// A fill that arrives live over the WebSocket carries the order's cumulative filled
1059    /// quantity in [`Trade::order_filled_quantity`] (`executionReport`'s `z`), so it advances
1060    /// the order by itself. A fill recovered after a disconnect comes from REST `myTrades`,
1061    /// which reports executions only, so recovery rebuilds the same figure by reading each
1062    /// recovered order's executions from its first: one extra request per order (another per
1063    /// further 1,000 executions), up to four orders at a time per instrument.
1064    ///
1065    /// Those lookups have their own time budget inside the recovery timeout, so they can never
1066    /// cost a fill. A fill whose order was not looked up in time, or whose lookup failed, goes
1067    /// out with `order_filled_quantity: None`, logged at `warn`. It then advances the position
1068    /// but not the order, which keeps whatever `filled_quantity` it held before the gap until
1069    /// the order's state is learned some other way.
1070    ///
1071    /// # Orders that ended while disconnected
1072    ///
1073    /// A reconnect also reports how each order the client holds as live on one of `instruments`
1074    /// ended, where it did, as an [`AccountEventKind::OrderSnapshot`] of its inactive state:
1075    /// filled, cancelled with what filled before, expired, or rejected. Like fill recovery, it
1076    /// covers only the instruments the stream was opened with. Binance's order history filters on when an
1077    /// order was created, so such an order is found by asking about it, not by time.
1078    ///
1079    /// - **Which orders.** The client holds an order as live from the response to placing it,
1080    ///   from a listing of open orders ([`account_snapshot`](ExecutionClient::account_snapshot),
1081    ///   [`fetch_open_orders`](ExecutionClient::fetch_open_orders)), and from its live reports on
1082    ///   any of its account streams, until it sees the order end. It holds up to 4,096 orders and
1083    ///   forgets the oldest past that, logged at `warn`. An order placed outside this client and
1084    ///   never listed or reported to it is not covered.
1085    /// - **Cost.** One `openOrders` request per instrument with an order held (weight 6), then one
1086    ///   `GET /api/v3/order` (weight 4) for each held order the listing no longer shows.
1087    /// - **Fills first.** An instrument is checked only once its fill gap is recovered or given up,
1088    ///   so an order's recovered fills arrive before how it ended. A fill that brings an order to
1089    ///   its full quantity ends it, and that order is not reported again.
1090    /// - **Keys.** Each snapshot carries [`StrategyId::unknown`], since Binance records no
1091    ///   strategy. The engine matches it to the order it tracks by client order id.
1092    /// - **Failures.** Each order's lookup is settled as it ends, and each instrument as soon as its
1093    ///   check ends. One whose listing or any lookup fails, or whose check is still running when a
1094    ///   pass reaches 30 s, is retried on the fill gaps' schedule (1, 2, 4, 8 and 16 minutes),
1095    ///   asking only about the orders still held, then given up, logged at `error`. Its orders are
1096    ///   asked about again at the next reconnect. An order Binance does not know (`-2013`) stops being held, logged
1097    ///   at `warn`; one it still reports live, or in a state this version cannot read, stays held.
1098    ///
1099    /// The same lookup is public as [`OrderStatusClient::fetch_ended_orders`].
1100    async fn account_stream(
1101        &self,
1102        // Only a reconnect's balance restatement uses `assets`: Binance pushes
1103        // outboundAccountPosition for every asset of the account, whatever is asked for, and
1104        // filtering it would hide balance updates for assets not in the initial list.
1105        assets: &[AssetNameExchange],
1106        instruments: &[InstrumentNameExchange],
1107    ) -> Result<Self::AccountStream, UnindexedClientError> {
1108        // Resilient account stream with auto-reconnection, heartbeat monitoring,
1109        // fill recovery, and event deduplication.
1110        //
1111        // Architecture: a persistent unbounded mpsc channel bridges events to the
1112        // consumer. A "connection manager" task owns the reconnection loop — on WS
1113        // disconnect or heartbeat timeout it tears down the old connection, backs off,
1114        // reconnects, recovers missed fills via REST, and re-subscribes. The consumer
1115        // sees a seamless BoxStream that only terminates when the consumer drops it or
1116        // max reconnect attempts are exhausted.
1117
1118        // unbounded channel — memory grows if the consumer is slow, but events
1119        // are never silently dropped. Silent data loss (corrupted position state) is a
1120        // worse failure mode than observable memory pressure. The WS callback uses
1121        // the synchronous send() which only fails if the receiver is dropped.
1122        let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1123        let dedup = new_dedup_cache();
1124        let ws_handle = self.ws_handle.clone();
1125        let rest = self.rest.clone();
1126        let rate_limiter = self.rate_limiter.clone();
1127        let known_live = self.known_live.clone();
1128        let instruments = instruments.to_vec();
1129        // all current Binance Spot symbols are ≤22 bytes (within SmolStr's 23-byte
1130        // inline limit), making clone() a stack memcpy with no heap allocation. Guard
1131        // this implicit invariant so future symbols that exceed the limit are caught early.
1132        debug_assert!(
1133            instruments.iter().all(|i| i.name().len() <= 23),
1134            "instrument name exceeds SmolStr inline capacity: {:?}",
1135            instruments.iter().find(|i| i.name().len() > 23)
1136        );
1137
1138        // Not during a rate-limit cooldown: connecting would be refused, and could lengthen a ban.
1139        self.rate_limiter.before_stream_open().await?;
1140
1141        // Verify initial connection succeeds before returning the stream.
1142        // This lets the caller distinguish "can't connect at all" from "connected
1143        // but later disconnected" (the latter is handled by auto-reconnect).
1144        let initial_ws = ws_handle.connect().await.map_err(|e| {
1145            self.rate_limiter.stream_open_error(&e, |e| {
1146                UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1147            })
1148        })?;
1149        // Closed if this future is dropped before the manager takes it over.
1150        let initial_ws = DisconnectOnDrop::new(initial_ws, ACCOUNT_STREAM_WS);
1151
1152        #[allow(clippy::expect_used)] // Builder has no required fields; infallible
1153        let params = UserDataStreamSubscribeSignatureParams::builder()
1154            .build()
1155            .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1156
1157        match initial_ws
1158            .user_data_stream_subscribe_signature(params)
1159            .await
1160        {
1161            Ok(_) => {}
1162            Err(e) => {
1163                // Awaiting the disconnect inline (rather than leaving it to the guard's drop)
1164                // ensures the socket is cleaned up before returning Err, matching the pattern
1165                // used in clear_ws_api.
1166                match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
1167                    Ok(Err(de)) => {
1168                        warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
1169                    }
1170                    Err(_) => {
1171                        warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
1172                    }
1173                    Ok(Ok(())) => {}
1174                }
1175                self.rate_limiter.after_failure(&e).await;
1176                return Err(self
1177                    .rate_limiter
1178                    .stream_open_error(&e, |e| UnindexedClientError::Internal(e.to_string())));
1179            }
1180        }
1181
1182        // race window — events arriving between the subscribe response above
1183        // and subscribe_on_ws_events() being called inside connection_manager are
1184        // silently dropped. The gap is bounded by Tokio scheduler latency; typically
1185        // milliseconds under load (no sub-millisecond guarantee).
1186        // account_snapshot reconciles open-order state, but TRADE fills in this window
1187        // are not recoverable without an explicit fetch_trades lookback. Callers that
1188        // require fill completeness at startup should call fetch_trades with a ~1s
1189        // lookback after account_stream returns.
1190
1191        // Spawn the connection manager task.
1192        // `initial_ws` is passed in so the first iteration skips the connect step.
1193        let cm_handle = tokio::spawn(connection_manager(
1194            tx,
1195            dedup,
1196            ws_handle,
1197            rest,
1198            rate_limiter,
1199            known_live,
1200            instruments,
1201            assets.to_vec(),
1202            Some(initial_ws),
1203        ));
1204
1205        // wrap the stream to abort connection_manager on drop, ensuring the
1206        // WS subscription and TCP connection are cleaned up even if the consumer
1207        // drops the stream without waiting for graceful shutdown.
1208        let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1209        let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1210        Ok(futures::StreamExt::boxed(guarded_stream))
1211    }
1212
1213    async fn cancel_order(
1214        &self,
1215        request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1216    ) -> UnindexedOrderResponseCancel {
1217        let instrument = request.key.instrument.clone();
1218        let key = OrderKey {
1219            exchange: request.key.exchange,
1220            instrument: instrument.clone(),
1221            strategy: request.key.strategy.clone(),
1222            cid: request.key.cid.clone(),
1223        };
1224
1225        let ws = match self.get_ws_api().await {
1226            Ok(ws) => ws,
1227            Err(unavailable) => {
1228                return UnindexedOrderResponseCancel {
1229                    key,
1230                    state: Err(unavailable.into_order_error()),
1231                };
1232            }
1233        };
1234
1235        // SDK constraint — OrderCancelParams::builder takes String, not &str.
1236        // Allocates one String for the symbol; unavoidable without SDK changes.
1237        let mut params_builder =
1238            OrderCancelParams::builder(request.key.instrument.name().to_string());
1239
1240        // Use exchange order ID if available and parseable, otherwise use client order ID
1241        if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1242            if let Ok(id) = order_id.0.parse::<i64>() {
1243                params_builder = params_builder.order_id(id);
1244            } else {
1245                // exchange order ID exists but isn't a valid i64 — fall back to cid.
1246                // This is unexpected; Binance orderId should always be numeric.
1247                // error! not warn!: corrupted order state may result in cancelling the wrong
1248                // order (if the clientOrderId doesn't match) or a silent no-op cancel.
1249                error!(
1250                    order_id = %order_id.0,
1251                    "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
1252                );
1253                params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1254            }
1255        } else {
1256            params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1257        }
1258
1259        let params = match params_builder.build() {
1260            Ok(p) => p,
1261            Err(e) => {
1262                error!(%e, "BinanceSpot failed to build cancel order params");
1263                return UnindexedOrderResponseCancel {
1264                    key,
1265                    state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1266                        e.to_string(),
1267                    ))),
1268                };
1269            }
1270        };
1271
1272        let sent_ms = unix_ms();
1273        let result = ws.order_cancel(params).await;
1274        // A WS-API response reports the shared spot pool's weight limit and usage.
1275        if let Ok(response) = &result {
1276            self.rate_limiter
1277                .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1278        }
1279        match result {
1280            Ok(response) => match response.data() {
1281                Ok(data) => {
1282                    let time_exchange = answer_time(
1283                        ExchangeId::BinanceSpot,
1284                        &instrument,
1285                        "cancel_order",
1286                        data.transact_time,
1287                    );
1288
1289                    let exchange_order_id = match data.order_id {
1290                        Some(id) => OrderId(format_smolstr!("{id}")),
1291                        None => {
1292                            return UnindexedOrderResponseCancel {
1293                                key,
1294                                state: Err(unreadable_answer(
1295                                    ExchangeId::BinanceSpot,
1296                                    "cancel answer has no orderId",
1297                                )),
1298                            };
1299                        }
1300                    };
1301
1302                    let filled_qty = binance_filled_qty(
1303                        ExchangeId::BinanceSpot,
1304                        &exchange_order_id,
1305                        data.executed_qty.as_deref(),
1306                    );
1307
1308                    self.known_live.lock().ended(&key.cid);
1309                    UnindexedOrderResponseCancel {
1310                        key,
1311                        state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1312                    }
1313                }
1314                Err(e) => UnindexedOrderResponseCancel {
1315                    key,
1316                    state: Err(unreadable_answer(ExchangeId::BinanceSpot, &e.to_string())),
1317                },
1318            },
1319            Err(e) => {
1320                // binance-sdk routes both transport failures and venue responses with status
1321                // >= 400 (ResponseError) through this outer Err path. Distinguish them so a
1322                // venue response (-2010, -1121, etc.) doesn't tear down a healthy WS session.
1323                // A venue response is mostly a rejection, but one that leaves the order's
1324                // status unknown (a venue-failure code, a 5xx) surfaces as Connectivity.
1325                // Check the venue response first (zero-alloc downcast) — order rejections are
1326                // the common case; rate limits during placement are rare.
1327                if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1328                    // A refused timestamp reads Binance's time before the caller can retry.
1329                    self.rate_limiter.after_failure(&e).await;
1330                    // Venue response — WS session is healthy, don't tear it down. A throttle
1331                    // (429, -1003, a WAF 403) backs off the shared limiter for the wait Binance
1332                    // sent, so REST calls and later orders back off too.
1333                    if let OrderError::Rejected(api @ ApiError::RateLimit { .. }) = &order_err {
1334                        self.rate_limiter.on_rate_limited(api.retry_after());
1335                    }
1336                    UnindexedOrderResponseCancel {
1337                        key,
1338                        state: Err(order_err),
1339                    }
1340                } else {
1341                    // Transport-level error — clear cached session so next call reconnects.
1342                    // Order status is unknown (may or may not have reached the matching engine).
1343                    self.clear_ws_api().await;
1344                    UnindexedOrderResponseCancel {
1345                        key,
1346                        state: Err(UnindexedOrderError::Connectivity(
1347                            ConnectivityError::Socket(format!("{e:#}")),
1348                        )),
1349                    }
1350                }
1351            }
1352        }
1353    }
1354
1355    async fn open_order(
1356        &self,
1357        request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1358    ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1359        let instrument = request.key.instrument.clone();
1360        let side = request.state.side;
1361        let price = request.state.price;
1362        let quantity = request.state.quantity;
1363        let kind = request.state.kind;
1364        let time_in_force = request.state.time_in_force;
1365        let cid = request.key.cid.clone();
1366
1367        let order_key = OrderKey::new(
1368            ExchangeId::BinanceSpot,
1369            instrument.clone(),
1370            request.key.strategy.clone(),
1371            cid.clone(),
1372        );
1373
1374        // A spot account never borrows, so `NoSideEffect` is the one effect it honours.
1375        if let Some(effect) = request
1376            .state
1377            .margin_effect
1378            .filter(|effect| *effect != MarginEffect::NoSideEffect)
1379        {
1380            return Order {
1381                key: order_key,
1382                side,
1383                price,
1384                quantity,
1385                kind,
1386                time_in_force,
1387                state: OrderState::inactive(OrderError::UnsupportedMarginEffect(effect)),
1388            };
1389        }
1390
1391        if let Err(refused) = self
1392            .rules
1393            .check(instrument.name(), side, kind, price, quantity)
1394            .await
1395        {
1396            return Order {
1397                key: order_key,
1398                side,
1399                price,
1400                quantity,
1401                kind,
1402                time_in_force,
1403                state: OrderState::inactive(refused),
1404            };
1405        }
1406
1407        let ws = match self.get_ws_api().await {
1408            Ok(ws) => ws,
1409            Err(unavailable) => {
1410                return Order {
1411                    key: order_key,
1412                    side,
1413                    price,
1414                    quantity,
1415                    kind,
1416                    time_in_force,
1417                    state: OrderState::inactive(unavailable.into_open_error()),
1418                };
1419            }
1420        };
1421
1422        let binance_side = match side {
1423            Side::Buy => OrderPlaceSideEnum::Buy,
1424            Side::Sell => OrderPlaceSideEnum::Sell,
1425        };
1426
1427        let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
1428            Some(converted) => converted,
1429            None => {
1430                return Order {
1431                    key: order_key,
1432                    side,
1433                    price,
1434                    quantity,
1435                    kind,
1436                    time_in_force,
1437                    state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1438                        "Binance Spot does not yet support OrderKind::{kind:?}"
1439                    ))),
1440                };
1441            }
1442        };
1443
1444        // market BUY sends base quantity, not quoteOrderQty. Callers must
1445        // specify how much of the base asset they want, not how much quote to spend.
1446        // The trait's OrderRequestOpen has a single `quantity` field so we can't
1447        // distinguish — this is a known semantic difference from Binance convention.
1448        // SDK constraint — OrderPlaceParams::builder takes String, not &str.
1449        // Allocates two Strings (symbol + client_order_id); unavoidable without SDK changes.
1450        let mut params_builder =
1451            OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
1452                .quantity(quantity)
1453                .new_client_order_id(cid.0.to_string());
1454
1455        // A trailing order's trailingDelta, fixed limit and activation price.
1456        match trailing_params(&kind, price) {
1457            Ok(Some(trailing)) => {
1458                params_builder = params_builder.trailing_delta(trailing.trailing_delta);
1459                if let Some(limit) = trailing.price {
1460                    params_builder = params_builder.price(limit);
1461                }
1462                if let Some(activation_price) = trailing.stop_price {
1463                    // Binance's activation price for a trailing order is its stopPrice.
1464                    params_builder = params_builder.stop_price(activation_price);
1465                }
1466            }
1467            Err(refused) => {
1468                return Order {
1469                    key: order_key,
1470                    side,
1471                    price,
1472                    quantity,
1473                    kind,
1474                    time_in_force,
1475                    state: OrderState::inactive(refused.into_order_error()),
1476                };
1477            }
1478            Ok(None) => {}
1479        }
1480
1481        // The other kinds' price and stopPrice, in a match with no wildcard, so that a new
1482        // OrderKind fails to compile until it is handled here.
1483        match kind {
1484            OrderKind::Limit => {
1485                params_builder = params_builder.price(price);
1486            }
1487            OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
1488                params_builder = params_builder.stop_price(trigger_price);
1489            }
1490            OrderKind::StopLimit { trigger_price }
1491            | OrderKind::TakeProfitLimit { trigger_price } => {
1492                params_builder = params_builder.price(price).stop_price(trigger_price);
1493            }
1494            // Trailing orders are set above. Market sends no price, and TrailingStopLimit and
1495            // ConditionalUnresolved are rejected by convert_order_kind_tif before this.
1496            OrderKind::TrailingStop { .. }
1497            | OrderKind::TrailingStopFixedLimit { .. }
1498            | OrderKind::Market
1499            | OrderKind::TrailingStopLimit { .. }
1500            | OrderKind::ConditionalUnresolved { .. } => {}
1501        }
1502
1503        if let Some(tif) = binance_tif {
1504            params_builder = params_builder.time_in_force(tif);
1505        }
1506
1507        let params = match params_builder.build() {
1508            Ok(p) => p,
1509            Err(e) => {
1510                error!(%e, "BinanceSpot failed to build new order params");
1511                return Order {
1512                    key: order_key,
1513                    side,
1514                    price,
1515                    quantity,
1516                    kind,
1517                    time_in_force,
1518                    state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1519                        e.to_string(),
1520                    ))),
1521                };
1522            }
1523        };
1524
1525        let sent_ms = unix_ms();
1526        let result = ws.order_place(params).await;
1527        // A WS-API response reports the shared spot pool's weight limit and usage.
1528        if let Ok(response) = &result {
1529            self.rate_limiter
1530                .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1531        }
1532        match result {
1533            Ok(response) => match response.data() {
1534                Ok(data) => {
1535                    let time_exchange = answer_time(
1536                        ExchangeId::BinanceSpot,
1537                        &instrument,
1538                        "open_order",
1539                        data.transact_time,
1540                    );
1541
1542                    let exchange_order_id = match data.order_id {
1543                        Some(id) => OrderId(format_smolstr!("{id}")),
1544                        None => {
1545                            return Order {
1546                                key: order_key,
1547                                side,
1548                                price,
1549                                quantity,
1550                                kind,
1551                                time_in_force,
1552                                state: OrderState::inactive(unreadable_answer(
1553                                    ExchangeId::BinanceSpot,
1554                                    "open_order answer has no orderId",
1555                                )),
1556                            };
1557                        }
1558                    };
1559
1560                    // Read from the response's status, so an order that ended in it (an IOC or
1561                    // FOK order that expired) is not reported, or held, as live. An `ACK`
1562                    // response, the default for order types other than MARKET and LIMIT, carries
1563                    // no status and reads as open.
1564                    let state = placed_order_state(
1565                        ExchangeId::BinanceSpot,
1566                        &instrument,
1567                        quantity,
1568                        PlacementResponse {
1569                            status: data.status.as_deref(),
1570                            order_id: exchange_order_id,
1571                            time_exchange,
1572                            executed_qty: data.executed_qty.as_deref(),
1573                            cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
1574                        },
1575                    );
1576                    self.known_live
1577                        .lock()
1578                        .placed(&order_key, quantity, kind, &state);
1579
1580                    Order {
1581                        key: order_key,
1582                        side,
1583                        price,
1584                        quantity,
1585                        kind,
1586                        time_in_force,
1587                        state,
1588                    }
1589                }
1590                Err(e) => Order {
1591                    key: order_key,
1592                    side,
1593                    price,
1594                    quantity,
1595                    kind,
1596                    time_in_force,
1597                    state: OrderState::inactive(unreadable_answer(
1598                        ExchangeId::BinanceSpot,
1599                        &e.to_string(),
1600                    )),
1601                },
1602            },
1603            Err(e) => {
1604                // binance-sdk routes both transport failures and venue responses with status
1605                // >= 400 (ResponseError) through this outer Err path. Distinguish them so a
1606                // venue response (-2010, -1121, etc.) doesn't tear down a healthy WS session.
1607                // A venue response is mostly a rejection, but one that leaves the order's
1608                // status unknown (a venue-failure code, a 5xx) surfaces as Connectivity.
1609                // Check the venue response first (zero-alloc downcast) — order rejections are
1610                // the common case; rate limits during placement are rare.
1611                if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1612                    // A refused timestamp reads Binance's time before the caller can retry.
1613                    self.rate_limiter.after_failure(&e).await;
1614                    // Venue response — WS session is healthy, don't tear it down. A throttle
1615                    // (429, -1003, a WAF 403) backs off the shared limiter for the wait Binance
1616                    // sent, so REST calls and later orders back off too.
1617                    if let OrderError::Rejected(api @ ApiError::RateLimit { .. }) = &order_err {
1618                        self.rate_limiter.on_rate_limited(api.retry_after());
1619                    }
1620                    Order {
1621                        key: order_key,
1622                        side,
1623                        price,
1624                        quantity,
1625                        kind,
1626                        time_in_force,
1627                        state: OrderState::inactive(order_err),
1628                    }
1629                } else {
1630                    // Transport-level error — clear cached session so next call reconnects.
1631                    // Order status is unknown (may or may not have reached the matching engine).
1632                    self.clear_ws_api().await;
1633                    Order {
1634                        key: order_key,
1635                        side,
1636                        price,
1637                        quantity,
1638                        kind,
1639                        time_in_force,
1640                        state: OrderState::inactive(OrderError::Connectivity(
1641                            ConnectivityError::Socket(format!("{e:#}")),
1642                        )),
1643                    }
1644                }
1645            }
1646        }
1647    }
1648
1649    /// Each balance is stamped 5 s before its request was sent, as is each in
1650    /// [`account_snapshot`](Self::account_snapshot): Binance's REST account view can trail its
1651    /// account stream, and a stream balance event the read may not yet show must still be more
1652    /// recent than the read. The send is timed on Binance's clock when this host's is ahead of it
1653    /// (see the [module docs](super#signed-requests-are-stamped-on-binances-clock)).
1654    async fn fetch_balances(
1655        &self,
1656        assets: &[AssetNameExchange],
1657    ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1658        let (response, sent) =
1659            rest_read_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1660                Box::pin(async move {
1661                    let params = GetAccountParams::builder().build()?;
1662                    rest.get_account(params).await
1663                })
1664            })
1665            .await
1666            .map_err(|e| classify_rest_query_error(&e, None))?;
1667
1668        let account = response.data().await.map_err(response_decode_error)?;
1669
1670        Ok(filter_and_convert_balances(
1671            account.balances.unwrap_or_default(),
1672            assets,
1673            self.rate_limiter.balance_read_time(sent),
1674        ))
1675    }
1676
1677    /// An empty `instruments` slice is the [`ExecutionClient`] "return all" sentinel: a single
1678    /// no-symbol `GET /api/v3/openOrders` call returns open orders across every instrument (each
1679    /// order's instrument is recovered from its own `symbol` field). A non-empty slice fetches the
1680    /// listed instruments concurrently, per-symbol.
1681    async fn fetch_open_orders(
1682        &self,
1683        instruments: &[InstrumentNameExchange],
1684    ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1685        // Empty slice = "return all" sentinel: a single no-symbol query is both correct (the
1686        // contract requires all instruments) and far cheaper than enumerating every symbol.
1687        if instruments.is_empty() {
1688            let orders =
1689                fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await?;
1690            self.remember_live(&orders);
1691            return Ok(orders);
1692        }
1693        // limit concurrency to avoid bursting Binance's request weight limits
1694        // (each GET /api/v3/openOrders for one symbol costs 6 weight; 8 concurrent = 48 weight).
1695        // try_fold into a flat Vec avoids the intermediate Vec<Vec<_>> that
1696        // try_collect().flatten() would allocate.
1697        use futures::{StreamExt as _, TryStreamExt as _};
1698        futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1699            fetch_open_orders_for_instrument(
1700                self.rest.clone(),
1701                self.rate_limiter.clone(),
1702                instrument,
1703                RequestKind::Query,
1704            )
1705        }))
1706            .buffer_unordered(8)
1707            .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1708                acc.extend(listing.orders);
1709                Ok(acc)
1710            })
1711            .await
1712            .inspect(|orders| self.remember_live(orders))
1713    }
1714
1715    /// **Documented deviation from the `ExecutionClient::fetch_trades` "return all" contract:**
1716    /// Binance's `myTrades` endpoint requires a symbol — there is no no-symbol "all trades" query
1717    /// (unlike open orders). An empty `instruments` slice therefore has nothing to query and
1718    /// reads nothing; callers wanting all trades must enumerate instruments explicitly.
1719    ///
1720    /// Each instrument is paged by trade id to the span's end within the call, with no page cap,
1721    /// so the read is always complete (`resume: None`).
1722    // `.iter().cloned()` is required: Rust async closures cannot satisfy the HRTB
1723    // `for<'a> FnMut(&'a InstrumentNameExchange) -> impl Future + 'static` needed by
1724    // the iterator machinery, even when the clone is moved inside the closure body.
1725    #[allow(clippy::redundant_iter_cloned)]
1726    async fn fetch_trades(
1727        &self,
1728        start: DateTime<Utc>,
1729        end: DateTime<Utc>,
1730        instruments: &[InstrumentNameExchange],
1731    ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1732        use futures::StreamExt;
1733
1734        if instruments.is_empty() {
1735            debug!(
1736                "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1737            );
1738            return Ok(TradesRead::complete(Vec::new()));
1739        }
1740        if start > end {
1741            return Ok(TradesRead::complete(Vec::new()));
1742        }
1743        // Binance stamps trades in whole milliseconds; the exact span is applied after the read.
1744        let span = MyTradesFrom::Span {
1745            start: start.timestamp_millis(),
1746            end: end.timestamp_millis(),
1747        };
1748        // Vec::new() — capacity(instruments.len()) would be misleading since this accumulates
1749        // up to BINANCE_MAX_TRADES * instruments.len() trades total.
1750        let mut all_trades = Vec::new();
1751
1752        // Binance requires per-symbol queries for trade history.
1753        // Limit concurrency to avoid bursting Binance's request weight limits
1754        // (each GET /api/v3/myTrades costs 20 weight; 8 concurrent = 160 weight).
1755        let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1756            let rest = self.rest.clone();
1757            let rate_limiter = self.rate_limiter.clone();
1758            async move {
1759                let pages =
1760                    paginate_my_trades(&rest, &rate_limiter, &inst, span, RequestKind::Query)
1761                        .await?;
1762                Ok::<_, UnindexedClientError>((inst, pages))
1763            }
1764        }))
1765        .buffer_unordered(8);
1766        while let Some(result) = stream.next().await {
1767            let (instrument, trades_data) = result?;
1768            for t in trades_data {
1769                if let Some(trade) = convert_my_trade(&t, &instrument)
1770                    && (start..=end).contains(&trade.time_exchange)
1771                {
1772                    all_trades.push(trade);
1773                }
1774            }
1775        }
1776
1777        Ok(TradesRead::complete(all_trades))
1778    }
1779}
1780
1781// ---------------------------------------------------------------------------
1782// Connection manager (reconnection, heartbeat, fill recovery)
1783// ---------------------------------------------------------------------------
1784
1785/// Looks each order up with `GET /api/v3/order` by symbol and client order id, weight 4 each, up
1786/// to eight at a time. Binance's `FILLED` is reported with its average price, `CANCELED` with
1787/// what filled before it, `EXPIRED` and `EXPIRED_IN_MATCH` (self-trade prevention) as expired, and
1788/// `REJECTED` as [`OpenFailed`](crate::order::state::InactiveOrderState::OpenFailed). An order
1789/// Binance does not know under the key's symbol (`-2013`, or `-1121` for a symbol that does not
1790/// exist) is omitted. A row whose status this version does not know is omitted too, with a
1791/// warning, as is a `PENDING_CANCEL` one, which Binance does not use.
1792///
1793/// The account stream runs the same lookup itself after a reconnect; see
1794/// [`account_stream`](ExecutionClient::account_stream).
1795impl OrderStatusClient for BinanceSpot {
1796    async fn fetch_ended_orders(
1797        &self,
1798        orders: &[UnindexedOrderKey],
1799    ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1800        fetch_ended_by_key(orders, |key| {
1801            fetch_order_lookup(
1802                self.rest.clone(),
1803                self.rate_limiter.clone(),
1804                key,
1805                RequestKind::Query,
1806            )
1807        })
1808        .await
1809    }
1810}
1811
1812/// Names the account stream's connection in [`DisconnectOnDrop`]'s logs.
1813const ACCOUNT_STREAM_WS: &str = "BinanceSpot account stream";
1814
1815/// Connect to Binance WS and subscribe to the user data stream.
1816/// On a subscribe failure, the dropped guard disconnects the WS so the TCP connection is not
1817/// leaked.
1818async fn connect_and_subscribe(
1819    ws_handle: &WebsocketApiHandle,
1820) -> anyhow::Result<DisconnectOnDrop<WebsocketApi>> {
1821    let ws = DisconnectOnDrop::new(ws_handle.connect().await?, ACCOUNT_STREAM_WS);
1822    #[allow(clippy::expect_used)] // Builder has no required fields; infallible
1823    let params = UserDataStreamSubscribeSignatureParams::builder()
1824        .build()
1825        .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1826    match ws.user_data_stream_subscribe_signature(params).await {
1827        Ok(_) => Ok(ws),
1828        Err(e) => {
1829            // The disconnect runs in the background: the reconnect loop backs off and retries
1830            // without waiting for it.
1831            warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1832            Err(e)
1833        }
1834    }
1835}
1836
1837/// Long-running task that manages the WebSocket lifecycle for account_stream.
1838///
1839/// Drives the reconnection loop: connect → subscribe → stream events → on disconnect
1840/// → backoff → fill recovery → reconnect. The `tx` channel persists across reconnections
1841/// so the consumer sees a seamless event stream.
1842///
1843/// Terminates when:
1844/// - The consumer drops the stream (receiver side of `tx` is closed)
1845/// - Max reconnect attempts are exhausted
1846/// - A reconnect faces a rate-limit cooldown longer than it waits through
1847///
1848/// # Panics
1849///
1850/// This function is spawned via `tokio::spawn`. If it panics, Tokio surfaces the panic
1851/// via the `JoinHandle`. Because the handle is transferred to `AbortOnDropStream` and
1852/// dropped, the panic is discarded at drop — the consumer will observe the
1853/// `UnboundedReceiverStream` ending (yielding `None`), indistinguishable from normal
1854/// max-reconnect exhaustion. The cleanup at the end of the loop body is skipped on a panic or an
1855/// abort, but the connection is still closed by its [`DisconnectOnDrop`] guard, and the
1856/// subscription unsubscribed by its [`UnsubscribeOnDrop`]. If you change this to `.await`
1857/// the handle, check `JoinError::is_panic()`.
1858// inherent complexity from reconnection loop (connect → subscribe → callback →
1859// fill recovery → heartbeat monitor → cleanup → backoff). Not worth splitting further.
1860#[allow(clippy::cognitive_complexity)]
1861async fn connection_manager(
1862    tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1863    dedup: SharedDedupCache,
1864    ws_handle: WebsocketApiHandle,
1865    rest: Arc<RestApi>,
1866    rate_limiter: Arc<RateLimitTracker>,
1867    known: SharedKnownLiveOrders,
1868    instruments: Vec<InstrumentNameExchange>,
1869    // The assets whose balances a reconnect restates; empty for all of them.
1870    assets: Vec<AssetNameExchange>,
1871    initial_ws: Option<DisconnectOnDrop<WebsocketApi>>,
1872) {
1873    let mut budget = ReconnectBudget::new("Binance", BackoffLimits::ACCOUNT_STREAM);
1874    let mut disconnect: Option<Disconnect> = None;
1875    // Gaps not yet recovered: read at reconnect, and retried on a timer while connected, once due.
1876    let mut unrecovered = UnrecoveredFills::default();
1877    // Instruments whose known-live orders a reconnect has yet to check, likewise.
1878    let mut unchecked = UncheckedOrders::default();
1879    // The balances a reconnect has yet to restate, read once its fills have been recovered.
1880    let mut restatement = BalanceRestatement::default();
1881    // The venue's clock and the latest balance time, from the events the stream delivers.
1882    let clock = StreamClock::shared(rate_limiter.signing_clock());
1883    let mut current_ws = initial_ws;
1884
1885    loop {
1886        // --- Connect (skip on first iteration if initial_ws was provided) ---
1887        let ws = match current_ws.take() {
1888            Some(ws) => ws,
1889            None => {
1890                // Not during a rate-limit cooldown: a short one is waited out without spending an
1891                // attempt, and a longer one ends the stream, since a connect would be refused.
1892                if let Err(reason) = rate_limiter.before_stream_connect().await {
1893                    emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
1894                    break;
1895                }
1896                match connect_and_subscribe(&ws_handle).await {
1897                    // the budget is NOT reset here — resetting on TCP-connect success
1898                    // would lock retry intervals at the first backoff wait forever when the
1899                    // server closes within the first heartbeat window (auth rejection,
1900                    // server-side close). Reset is deferred to the monitor loop after the
1901                    // first heartbeat interval survives (proven-stable connection).
1902                    Ok(ws) => ws,
1903                    Err(e) => {
1904                        error!(%e, "BinanceSpot WS connect/subscribe failed");
1905                        // A rate-limit refusal starts a cooldown, so REST and orders back off too.
1906                        rate_limiter.on_stream_refused(&e);
1907                        // A refused subscribe timestamp reads Binance's time before the retry.
1908                        rate_limiter.after_failure(&e).await;
1909                        if let Err(reason) =
1910                            retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
1911                        {
1912                            // Signal terminal stream death in-band before tx drops.
1913                            emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
1914                            break;
1915                        }
1916                        continue;
1917                    }
1918                }
1919            }
1920        };
1921        info!("BinanceSpot account_stream connected and subscribed");
1922
1923        // --- Set up WS event callback BEFORE fill recovery ---
1924        // binance-sdk silently drops WS messages with no registered subscriber.
1925        // Register the callback first so events arriving during fill recovery (REST)
1926        // are captured. The dedup cache prevents duplicates between live events and
1927        // recovered fills.
1928        let (signal_tx, signal_rx) = oneshot::channel::<()>();
1929        let mut signal_tx_opt = Some(signal_tx);
1930        // Set by every frame and ping. Starts false: only one of those proves the connection
1931        // stable. The monitor loop grants the first window a grace instead, so a slow first
1932        // ping from Binance does not trigger a false-positive timeout.
1933        let heartbeat_flag = Arc::new(AtomicBool::new(false));
1934        let hb_callback = heartbeat_flag.clone();
1935        let dedup_callback = dedup.clone();
1936        let known_callback = known.clone();
1937        let clock_callback = clock.clone();
1938        // tx: used directly by recover_fills and the heartbeat/consumer-drop monitor.
1939        // event_tx: cloned into the WS callback closure (taken on first send failure or
1940        // stream termination so the callback becomes a no-op after disconnect).
1941        let mut event_tx = Some(tx.clone());
1942        // 32: covers typical multi-asset accounts without excessive over-allocation;
1943        // outboundAccountPosition emits one entry per asset, so 8 would reallocate
1944        // for accounts with more than 8 assets.
1945        let mut event_buf = Vec::with_capacity(32);
1946
1947        // Safety — `signal_tx_opt.take()` and `event_tx.take()` are non-atomic,
1948        // which is safe because binance-sdk spawns one tokio::spawn'd task per
1949        // subscription; that task processes events sequentially from an internal channel,
1950        // so FnMut callbacks are never invoked concurrently per subscription. If the SDK
1951        // changes to concurrent callbacks, these `Option::take()` calls would need a Mutex.
1952        // verified against binance-sdk =77.2.0. Re-verify on SDK upgrade.
1953        let subscription = ws.subscribe_on_ws_events(move |event| {
1954            let Some(ref sender) = event_tx else { return };
1955            match event {
1956                WebsocketEvent::Message(json_str) => {
1957                    // Release: pairs with Acquire swap in monitor task so the stored
1958                    // `true` is visible before the monitor swaps in `false`.
1959                    hb_callback.store(true, Ordering::Release);
1960                    // Borrowed discriminator + matched-variant parse (no full Value DOM).
1961                    // RPC responses (the subscribe ack) are ignored inside the converter, and
1962                    // unrecognised frames are logged there (throttled warn).
1963                    let outcome = convert_user_data_events(&json_str, &mut event_buf);
1964                    StreamClock::observe_frame(
1965                        &clock_callback,
1966                        outcome.event_time,
1967                        &event_buf,
1968                        tokio::time::Instant::now(),
1969                    );
1970                    let stream_terminated = outcome.terminated;
1971                    for ev in event_buf.drain(..) {
1972                        // Dedup check
1973                        if let Some(key) = dedup_key_from_event(&ev)
1974                            && is_duplicate(&dedup_callback, key)
1975                        {
1976                            trace!("BinanceSpot dedup: skipping duplicate event");
1977                            continue;
1978                        }
1979                        // Held across the send, so an order this reports ending and a reconnect's
1980                        // check of it reach the stream in the order they were decided, and the
1981                        // check reports only an order not already reported.
1982                        let known = KnownLiveOrders::observes(&ev.kind).then(|| {
1983                            let mut known = known_callback.lock();
1984                            known.observe(&ev);
1985                            known
1986                        });
1987                        if sender.send(ev).is_err() {
1988                            drop(known);
1989                            warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1990                            event_tx.take();
1991                            if let Some(s) = signal_tx_opt.take() {
1992                                let _ = s.send(());
1993                            }
1994                            return;
1995                        }
1996                    }
1997                    // eventStreamTerminated arrives as a JSON message,
1998                    // not a WS close frame — signal reconnect explicitly.
1999                    if stream_terminated {
2000                        event_tx.take();
2001                        if let Some(s) = signal_tx_opt.take() {
2002                            let _ = s.send(());
2003                        }
2004                    }
2005                }
2006                WebsocketEvent::Ping | WebsocketEvent::Pong => {
2007                    // SDK handles ping/pong at protocol level; we just track activity.
2008                    // Release: pairs with Acquire swap in monitor task (same as Message handler).
2009                    hb_callback.store(true, Ordering::Release);
2010                }
2011                WebsocketEvent::Error(e) => {
2012                    // warn! not error! — a transient WS error that triggers
2013                    // auto-reconnect is recoverable. error! is reserved for failures
2014                    // that exhaust reconnect attempts (logged in connection_manager).
2015                    warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
2016                    event_tx.take();
2017                    if let Some(s) = signal_tx_opt.take() {
2018                        let _ = s.send(());
2019                    }
2020                }
2021                WebsocketEvent::Close(code, reason) => {
2022                    warn!(code, %reason, "BinanceSpot WebSocket closed");
2023                    event_tx.take();
2024                    if let Some(s) = signal_tx_opt.take() {
2025                        let _ = s.send(());
2026                    }
2027                }
2028                _ => {}
2029            }
2030        });
2031        // Unsubscribed on drop too, so an abort does not leak the callback task.
2032        let subscription = UnsubscribeOnDrop::new(subscription);
2033
2034        // --- Recovery after reconnect (each with a timeout to avoid blocking forever) ---
2035        // Runs after subscribe_on_ws_events so live events during recovery are captured.
2036        //
2037        // Fills first, then how the orders held as live ended: an order whose fills are not all
2038        // recovered is not checked yet. Each gap and check is opened before it is read, so one
2039        // that fails or times out is kept for a retry.
2040        if let Some(disconnect) = disconnect.take() {
2041            let end = disconnect.end(&clock.lock().venue(), HostMoment::now());
2042            unrecovered.open(&instruments, disconnect.start, end);
2043            // Only the instruments this stream recovers fills for, so fills-first holds for each.
2044            let held = known.lock().instruments_among(&instruments);
2045            unchecked.open(held);
2046            restatement.open();
2047        }
2048        recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known).await;
2049        recover_spot_ended_orders(
2050            &rest,
2051            &rate_limiter,
2052            &known,
2053            &mut unchecked,
2054            &unrecovered,
2055            &tx,
2056        )
2057        .await;
2058        // The balances are restated beside the monitor below, which reads them at once: after the
2059        // fills just recovered, without holding back the heartbeat. A gap whose read failed does
2060        // not hold them back either.
2061
2062        // --- Monitor: wait for disconnect, heartbeat timeout, or consumer drop ---
2063        enum DisconnectReason {
2064            Signal,
2065            HeartbeatTimeout,
2066            ConsumerDropped,
2067        }
2068        let reason = {
2069            let mut signal_rx = signal_rx;
2070            // Gaps, order checks and a restatement a recovery did not finish are retried as they
2071            // fall due, alongside the monitor, so a disconnect is still seen at once. An order
2072            // check waiting on a gap runs right after the gap's retry. It never completes; dropping
2073            // it when the monitor ends loses nothing, since each gap, each instrument's order
2074            // check, and the restatement is settled in one step as soon as its read ends.
2075            let retry_gaps = async {
2076                loop {
2077                    // First, the restatement a reconnect has just opened, due at once.
2078                    restate("BinanceSpot", &clock, &mut restatement, &tx, |floor| {
2079                        read_spot_restatement(&rest, &rate_limiter, &assets, &clock, floor)
2080                    })
2081                    .await;
2082                    let next_check = unchecked.next_due(&unrecovered);
2083                    match unrecovered
2084                        .next_due()
2085                        .into_iter()
2086                        .chain(next_check)
2087                        .chain(restatement.next_due())
2088                        .min()
2089                    {
2090                        Some(due) => tokio::time::sleep_until(due).await,
2091                        None => std::future::pending::<()>().await,
2092                    }
2093                    recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known)
2094                        .await;
2095                    recover_spot_ended_orders(
2096                        &rest,
2097                        &rate_limiter,
2098                        &known,
2099                        &mut unchecked,
2100                        &unrecovered,
2101                        &tx,
2102                    )
2103                    .await;
2104                }
2105            };
2106            tokio::pin!(retry_gaps);
2107            // The first window is a grace: a slow first ping is not a heartbeat timeout.
2108            let mut grace = true;
2109            loop {
2110                tokio::select! {
2111                    // Biased: a consumer drop is terminal and wins; the gap retry is polled last.
2112                    biased;
2113                    _ = tx.closed() => {
2114                        debug!("BinanceSpot account_stream consumer dropped, terminating");
2115                        break DisconnectReason::ConsumerDropped;
2116                    }
2117                    _ = &mut signal_rx => {
2118                        warn!("BinanceSpot WS disconnected, will attempt reconnect");
2119                        break DisconnectReason::Signal;
2120                    }
2121                    _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2122                        // AcqRel on the swap: the Acquire half synchronizes with the
2123                        // callback's Release store of `true`, so if we read `true` we
2124                        // know the callback's side effects are visible. The Release half
2125                        // is a no-op here since no other thread reads the `false` we
2126                        // write back — but AcqRel is the semantically correct ordering
2127                        // for a read-modify-write and protects against future readers.
2128                        let in_grace = std::mem::take(&mut grace);
2129                        if heartbeat_flag.swap(false, Ordering::AcqRel) {
2130                            // Activity detected: connection is proven stable for one full
2131                            // heartbeat window — safe to reset the reconnect budget so a
2132                            // stable connection doesn't carry over prior failure counts.
2133                            budget.reset();
2134                            continue;
2135                        }
2136                        if in_grace {
2137                            // Not a timeout yet, but no reset either: a venue that accepts the
2138                            // connection and stays silent spends the budget.
2139                            continue;
2140                        }
2141                        warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2142                        break DisconnectReason::HeartbeatTimeout;
2143                    }
2144                    // Last: the arms above end the monitor and win when ready together.
2145                    () = &mut retry_gaps => {}
2146                }
2147            }
2148        };
2149        let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2150
2151        // record the disconnect BEFORE cleanup so fill recovery covers
2152        // the full gap. For heartbeat timeouts the connection may have died up to two
2153        // heartbeat windows ago, so look back that far (see `disconnect_lookback`).
2154        // For signal-based disconnects (WS close/error/stream terminated), look back a
2155        // small margin to cover Tokio scheduling jitter between the WS close event and
2156        // the monitor task seeing it. Dedup cache handles any resulting duplicates.
2157        if should_reconnect {
2158            let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
2159            disconnect = Some(Disconnect::seen(
2160                &clock.lock().venue(),
2161                HostMoment::now(),
2162                disconnect_lookback(heartbeat_timed_out),
2163            ));
2164        }
2165
2166        // --- Cleanup current connection ---
2167        // must explicitly unsubscribe — Subscription::drop only detaches the
2168        // internal JoinHandle, it doesn't abort it.
2169        // unsubscribe() aborts the callback task without waiting for it (verified against
2170        // binance-sdk =77.2.0): a callback already running finishes, and none starts once the
2171        // task sees the abort. That is benign: the callback holds its own clone of the
2172        // `heartbeat_flag` Arc, so dropping ours here is safe, and an event it still forwards
2173        // reaches the consumer through the dedup cache like any other from this connection.
2174        // Re-verify on SDK upgrade if the callback invocation model changes.
2175        subscription.unsubscribe();
2176        // binance-sdk WebsocketApi has no Drop impl that closes the TCP connection. Disconnect
2177        // explicitly to report the result; the guard's drop is the fallback.
2178        if let Err(e) = ws.disconnect().await {
2179            warn!(%e, "BinanceSpot failed to disconnect WebSocket");
2180        }
2181
2182        if !should_reconnect || tx.is_closed() {
2183            // Consumer dropped the receiver — no StreamTerminated emit (the channel is already
2184            // closed, so it would be a no-op; see emit_stream_terminated docs).
2185            debug!("BinanceSpot connection manager exiting");
2186            break;
2187        }
2188        // Surrender after repeated reconnects: the most recent failure is the disconnect that
2189        // triggered this round (ConsumerDropped already broke out above).
2190        let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
2191        let last_error = || disconnect_error(heartbeat_timed_out);
2192        if let Err(reason) = retry_or_end(&mut budget, &rate_limiter, last_error).await {
2193            emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
2194            break;
2195        }
2196    }
2197}
2198
2199/// Read the spot account's balances to restate after a reconnect, as a
2200/// [`BalanceSnapshot`](AccountEventKind::BalanceSnapshot) for each of `assets` (all of them when
2201/// empty), stamped by [`restatement_time`] for a read started with `floor` (see [`restate`]).
2202async fn read_spot_restatement(
2203    rest: &Arc<RestApi>,
2204    rate_limiter: &Arc<RateLimitTracker>,
2205    assets: &[AssetNameExchange],
2206    clock: &SharedStreamClock,
2207    floor: Option<DateTime<Utc>>,
2208) -> Result<Vec<UnindexedAccountEvent>, UnindexedClientError> {
2209    let (response, sent) = rest_read_with_retry(rest, rate_limiter, RequestKind::Query, |rest| {
2210        Box::pin(async move {
2211            let params = GetAccountParams::builder().build()?;
2212            rest.get_account(params).await
2213        })
2214    })
2215    .await
2216    .map_err(|e| classify_rest_query_error(&e, None))?;
2217    let account = response.data().await.map_err(response_decode_error)?;
2218    let time = restatement_time(&clock.lock(), sent, floor);
2219    Ok(
2220        filter_and_convert_balances(account.balances.unwrap_or_default(), assets, time)
2221            .into_iter()
2222            .map(|balance| {
2223                UnindexedAccountEvent::new(
2224                    ExchangeId::BinanceSpot,
2225                    AccountEventKind::BalanceSnapshot(
2226                        rustrade_integration::collection::snapshot::Snapshot(balance),
2227                    ),
2228                )
2229            })
2230            .collect(),
2231    )
2232}
2233
2234/// Recover the fills of every gap in `unrecovered` that is due: the fills missed while the stream
2235/// was disconnected.
2236///
2237/// Reads each gap's span by REST and sends its trades through the dedup cache, so a trade already
2238/// delivered is not sent again. A gap whose fills are all forwarded leaves `unrecovered`; one whose
2239/// read fails or does not finish is retried later, until it is given up (see
2240/// [`UnrecoveredFills`]). Each gap is settled as soon as its read ends, so dropping this future
2241/// part-way, as a disconnect during a retry does, loses nothing.
2242///
2243/// `myTrades` reports executions only, with no cumulative, so each recovered trade's
2244/// `order_filled_quantity` is rebuilt from its order's executions by
2245/// [`recovered_order_totals`]. Nor does it carry a client order id, so each recovered trade's
2246/// `client_order_id` is then read from its order by [`recovered_order_cids`], with whatever time
2247/// the walks left. A trade whose order could not be looked up keeps `None` in either.
2248///
2249/// The whole recovery is bounded by [`FILL_RECOVERY_TIMEOUT_SECS`]. Fills forwarded before the
2250/// deadline stay delivered. Its reads are [`RequestKind::Essential`]: they wait out a rate-limit
2251/// cooldown but not the pause near the weight limit, which could outlast the budget.
2252async fn recover_fills(
2253    rest: &Arc<RestApi>,
2254    rate_limiter: &Arc<RateLimitTracker>,
2255    unrecovered: &mut UnrecoveredFills,
2256    tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2257    dedup: &SharedDedupCache,
2258    known: &SharedKnownLiveOrders,
2259) {
2260    use futures::StreamExt;
2261
2262    let due = unrecovered.due(tokio::time::Instant::now());
2263    let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2264        return;
2265    };
2266    info!(
2267        gaps = due.len(),
2268        oldest = %gap_time(oldest),
2269        "BinanceSpot recovering fills missed while disconnected"
2270    );
2271
2272    // Which gaps of `due` have been settled, recovered or failed, and how many have started: the
2273    // stream starts them in order, eight at a time, so those from `started` on were never read.
2274    let mut settled = vec![false; due.len()];
2275    let started = AtomicUsize::new(0);
2276    let recovery = async {
2277        let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2278        let mut recovered = 0u32;
2279        let mut duplicates = 0u32;
2280
2281        // limit concurrency to avoid bursting Binance's request weight limits
2282        // (each GET /api/v3/myTrades costs 20 weight; 8 concurrent = 160 weight).
2283        // pagination is critical for fill recovery — a missed page means a gap read short.
2284        // paginate_my_trades handles the full cursor-based pagination loop shared with
2285        // fetch_trades.
2286        let mut stream =
2287            futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2288                started.store(index + 1, Ordering::Relaxed);
2289                let rest = rest.clone();
2290                let rl = rate_limiter.clone();
2291                async move {
2292                    let span = MyTradesFrom::Span {
2293                        start: gap.start_ms,
2294                        end: gap.end_ms,
2295                    };
2296                    let raw =
2297                        match paginate_my_trades(&rest, &rl, &inst, span, RequestKind::Essential)
2298                            .await
2299                        {
2300                            Ok(pages) => pages,
2301                            Err(e) => return (index, Err(e)),
2302                        };
2303                    // `myTrades` carries no cumulative, so each recovered fill's is rebuilt from
2304                    // its order's executions; without it the fill advances the position but not
2305                    // the order.
2306                    let totals = recovered_order_totals(
2307                        ExchangeId::BinanceSpot,
2308                        &inst,
2309                        &raw,
2310                        order_executions_deadline,
2311                        |order_id| {
2312                            paginate_my_trades(
2313                                &rest,
2314                                &rl,
2315                                &inst,
2316                                MyTradesFrom::Order(order_id),
2317                                RequestKind::Essential,
2318                            )
2319                        },
2320                    )
2321                    .await;
2322                    // After the walks, so these reads only use time the walks left: the walks
2323                    // matter more, since a fill without a cumulative leaves its order behind.
2324                    let cids = recovered_order_cids(
2325                        ExchangeId::BinanceSpot,
2326                        &inst,
2327                        &raw,
2328                        order_executions_deadline,
2329                        |order_id| fetch_order_cid(&rest, &rl, &inst, order_id),
2330                    )
2331                    .await;
2332                    let trades: Vec<_> = raw
2333                        .iter()
2334                        .filter_map(|t| {
2335                            let mut trade = convert_my_trade(t, &inst)?;
2336                            trade.order_filled_quantity =
2337                                t.id.and_then(|id| totals.get(&id).copied());
2338                            trade.client_order_id =
2339                                t.order_id.and_then(|id| cids.get(&id).cloned());
2340                            Some(trade)
2341                        })
2342                        .collect();
2343                    (index, Ok(trades))
2344                }
2345            }))
2346            .buffer_unordered(8);
2347        while let Some((index, result)) = stream.next().await {
2348            let (inst, gap) = &due[index];
2349            settled[index] = true;
2350            let trades = match result {
2351                Ok(trades) => trades,
2352                Err(e) => {
2353                    gap_failed(
2354                        unrecovered,
2355                        tx,
2356                        ExchangeId::BinanceSpot,
2357                        inst,
2358                        gap,
2359                        FillRecoveryFailure::Request(e.to_string()),
2360                    );
2361                    continue;
2362                }
2363            };
2364            for trade in trades {
2365                // Construct the event first so dedup_key_from_event can be reused,
2366                // keeping key construction in one place.
2367                let event = UnindexedAccountEvent::new(
2368                    ExchangeId::BinanceSpot,
2369                    AccountEventKind::Trade(trade),
2370                );
2371                // Only Trade events are deduped during recovery — we don't recover NEW/CANCELLED
2372                // lifecycle events here (those require fetch_open_orders reconciliation).
2373                if let Some(key) = dedup_key_from_event(&event)
2374                    && is_duplicate(dedup, key)
2375                {
2376                    duplicates += 1;
2377                    continue;
2378                }
2379                // A recovered fill that completes its order ends it, so a later check of how the
2380                // orders held as live ended does not report it again.
2381                let sent = {
2382                    let mut known = known.lock();
2383                    known.observe(&event);
2384                    tx.send(event)
2385                };
2386                if sent.is_err() {
2387                    // early return on consumer drop — no point recovering remaining
2388                    // gaps if the receiver is gone.
2389                    debug!("BinanceSpot fill recovery: consumer dropped during recovery");
2390                    return;
2391                }
2392                recovered += 1;
2393            }
2394            unrecovered.recovered(inst, gap);
2395        }
2396        info!(recovered, duplicates, "BinanceSpot fill recovery complete");
2397    };
2398    // A timeout drops `recovery` at an await, between gaps: one gap's fills are sent without
2399    // awaiting, so each is either fully forwarded and settled, or not forwarded at all. A gap
2400    // whose read started and did not finish has failed; one never started stays due as it was.
2401    if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2402        .await
2403        .is_err()
2404    {
2405        let started = started.load(Ordering::Relaxed);
2406        for ((inst, gap), _) in due[..started]
2407            .iter()
2408            .zip(&settled)
2409            .filter(|(_, settled)| !**settled)
2410        {
2411            gap_failed(
2412                unrecovered,
2413                tx,
2414                ExchangeId::BinanceSpot,
2415                inst,
2416                gap,
2417                FillRecoveryFailure::TimedOut {
2418                    timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2419                },
2420            );
2421        }
2422    }
2423}
2424
2425// ---------------------------------------------------------------------------
2426// Type conversion helpers
2427// ---------------------------------------------------------------------------
2428
2429/// Filter Binance account balances to the requested assets and convert to rustrade types.
2430///
2431/// If `assets` is empty, **all** balances are returned (no filter applied). This matches
2432/// the `ExecutionClient::account_snapshot` contract where an empty slice means "all assets."
2433///
2434/// Zero-balance entries (`free == 0` and `locked == 0`) are **intentionally included**.
2435/// Binance's `GET /api/v3/account` returns every asset the account has ever touched,
2436/// including those with zero balance. The engine or caller is responsible for filtering
2437/// if zero-balance assets are not desired.
2438fn convert_balance_entry(
2439    b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
2440    now: chrono::DateTime<Utc>,
2441) -> Option<AssetBalance<AssetNameExchange>> {
2442    let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2443    let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2444        Some(v) => v,
2445        None => {
2446            warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
2447            return None;
2448        }
2449    };
2450    let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2451        Some(v) => v,
2452        None => {
2453            warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
2454            return None;
2455        }
2456    };
2457    Some(AssetBalance::new(
2458        asset_name,
2459        Balance::new(free + locked, free),
2460        now,
2461    ))
2462}
2463
2464/// Filter `balances` to `assets` (all of them when `assets` is empty) and convert them, each
2465/// stamped `time`.
2466fn filter_and_convert_balances(
2467    balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
2468    assets: &[AssetNameExchange],
2469    time: chrono::DateTime<Utc>,
2470) -> Vec<AssetBalance<AssetNameExchange>> {
2471    // Empty assets slice means "return all" — skip building the set entirely.
2472    if assets.is_empty() {
2473        return balances
2474            .into_iter()
2475            .filter_map(|b| convert_balance_entry(b, time))
2476            .collect();
2477    }
2478    // For small slices (≤16 assets), linear scan avoids allocation and hashing overhead.
2479    // For larger slices, HashSet O(1) lookup amortizes the construction cost.
2480    if assets.len() <= 16 {
2481        return balances
2482            .into_iter()
2483            .filter_map(|b| {
2484                let asset_name_str = b.asset.as_deref()?;
2485                if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
2486                    return None;
2487                }
2488                convert_balance_entry(b, time)
2489            })
2490            .collect();
2491    }
2492    use std::collections::HashSet;
2493    let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
2494    balances
2495        .into_iter()
2496        .filter_map(|b| {
2497            let asset_name_str = b.asset.as_deref()?;
2498            if !asset_set.contains(asset_name_str) {
2499                return None;
2500            }
2501            convert_balance_entry(b, time)
2502        })
2503        .collect()
2504}
2505
2506/// Convert a Binance myTrades REST response into a rustrade Trade.
2507fn convert_my_trade(
2508    t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
2509    instrument: &InstrumentNameExchange,
2510) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
2511    let trade_id_raw = match t.id {
2512        Some(id) => id,
2513        None => {
2514            warn!(%instrument, "BinanceSpot trade missing id");
2515            return None;
2516        }
2517    };
2518    let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
2519    let order_id = match t.order_id {
2520        Some(id) => OrderId(format_smolstr!("{}", id)),
2521        None => {
2522            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
2523            return None;
2524        }
2525    };
2526    let side = match t.is_buyer {
2527        Some(is_buyer) => {
2528            if is_buyer {
2529                Side::Buy
2530            } else {
2531                Side::Sell
2532            }
2533        }
2534        None => {
2535            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
2536            return None;
2537        }
2538    };
2539    let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2540        Some(v) => v,
2541        None => {
2542            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
2543            return None;
2544        }
2545    };
2546    let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2547        Some(v) => v,
2548        None => {
2549            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2550            return None;
2551        }
2552    };
2553    let commission = t
2554        .commission
2555        .as_deref()
2556        .and_then(|s| Decimal::from_str(s).ok())
2557        .unwrap_or(Decimal::ZERO);
2558    // The trade carries no other time, so without `time` it is kept, stamped with this host's
2559    // clock and marked so.
2560    let (time_exchange, time_source) = match t
2561        .time
2562        .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2563    {
2564        Some(ts) => (ts, TimeSource::Venue),
2565        None => {
2566            warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2567            (Utc::now(), TimeSource::Host)
2568        }
2569    };
2570
2571    // Use actual commission asset from Binance (e.g., BNB, USDT, BTC).
2572    // fees_quote is set to None here; the indexer will compute it if fee is in
2573    // quote or base asset. For third-party assets (BNB), downstream must convert.
2574    // "UNKNOWN" fallback (rare: API omits commission_asset): the indexer keeps the trade with
2575    // its fee zeroed, and warns.
2576    let fee_asset = t
2577        .commission_asset
2578        .as_deref()
2579        .map(AssetNameExchange::from)
2580        .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2581
2582    Some(
2583        Trade::new(
2584            trade_id,
2585            order_id,
2586            instrument.clone(),
2587            StrategyId::unknown(), // Binance doesn't carry strategy IDs
2588            time_exchange,
2589            side,
2590            price,
2591            quantity,
2592            // `myTrades` reports executions only -- no cumulative, no order status.
2593            None,
2594            AssetFees::new(fee_asset, commission, None),
2595        )
2596        .with_time_source(time_source),
2597    )
2598}
2599
2600/// Convert a raw BinanceSpot user-data WS frame to rustrade AccountEvents.
2601///
2602/// Pushes into the provided buffer to avoid per-message heap allocation.
2603/// A single Binance event (e.g., outboundAccountPosition) may map to multiple
2604/// rustrade events (one per asset balance).
2605///
2606/// Returns whether the stream should be considered terminated (requires reconnect), and the
2607/// event's time `E`, when Binance sent it, to sample the stream's clock with.
2608///
2609/// # Frame shape
2610///
2611/// The subscription is WS-API `userDataStream.subscribe.signature`, so each pushed event arrives
2612/// wrapped as `{ "subscriptionId", "event": { "e", .. } }`, and binance-sdk passes the frame on
2613/// unchanged; [`parse_user_data_frame`] unwraps it, as for margin. RPC responses (the subscribe
2614/// acknowledgement) are ignored. A frame of any other shape, including an event without a
2615/// readable `e` tag, is ignored and logged by [`log_unrecognised_frame`] (throttled `warn`), so a
2616/// change in delivery cannot drop events silently. Unknown event types inside the envelope are
2617/// ignored at `trace`.
2618///
2619/// # Hot path
2620///
2621/// Reads the `e` discriminator from a borrowed view of the frame, then deserializes **only**
2622/// the matched variant straight from the inner event slice — avoiding the full
2623/// `serde_json::Value` DOM that `UserDataStreamEventsResponse`'s `#[serde(try_from = "Value")]`
2624/// would build for every inbound frame.
2625fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> UserDataOutcome {
2626    let (event_type, event, event_time) = match parse_user_data_frame(frame) {
2627        UserDataFrame::Response => return UserDataOutcome::default(),
2628        UserDataFrame::Event {
2629            event_type,
2630            event,
2631            event_time,
2632            ..
2633        } => (event_type, event, event_time),
2634        UserDataFrame::Unrecognised => {
2635            static SEEN: AtomicU64 = AtomicU64::new(0);
2636            log_unrecognised_frame("BinanceSpot", &SEEN, frame);
2637            return UserDataOutcome::default();
2638        }
2639    };
2640    UserDataOutcome {
2641        terminated: convert_user_data_event(event_type, event, buf),
2642        event_time,
2643    }
2644}
2645
2646/// What [`convert_user_data_events`] found in a frame.
2647#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
2648struct UserDataOutcome {
2649    /// The venue ended the stream: reconnect.
2650    terminated: bool,
2651    /// The event's time `E`, by Binance's clock.
2652    event_time: Option<DateTime<Utc>>,
2653}
2654
2655/// Convert one user-data event of type `event_type`, appending its events to `buf`. Returns `true`
2656/// if the stream should be considered terminated.
2657fn convert_user_data_event(
2658    event_type: &str,
2659    event: &str,
2660    buf: &mut Vec<UnindexedAccountEvent>,
2661) -> bool {
2662    // Tag values match `UserDataStreamEventsResponse`'s `try_from = "Value"` arms (binance-sdk).
2663    match event_type {
2664        "executionReport" => {
2665            // Single typed pass straight from the inner event — no intermediate DOM, and only the
2666            // matched branch deserializes its payload. `ExecutionReport` ignores the unknown `e` tag.
2667            match serde_json::from_str::<ExecutionReport>(event) {
2668                Ok(report) => {
2669                    convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
2670                }
2671                Err(e) => {
2672                    warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable executionReport, dropping")
2673                }
2674            }
2675            false
2676        }
2677        "outboundAccountPosition" => {
2678            match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2679                event,
2680            ) {
2681                Ok(position) => convert_account_position(position, buf),
2682                Err(e) => {
2683                    warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2684                }
2685            }
2686            false
2687        }
2688        "balanceUpdate" => {
2689            // balanceUpdate events are for deposits/withdrawals;
2690            // outboundAccountPosition covers balance changes from trades.
2691            // deposit/withdrawal balance changes are not forwarded to the consumer.
2692            // A caller should call fetch_balances or account_snapshot periodically to
2693            // reconcile balances after external transfers.
2694            // No log here: this is the per-frame receive hot path.
2695            false
2696        }
2697        "eventStreamTerminated" => {
2698            // Binance sends eventStreamTerminated as a JSON message, not a WS
2699            // close frame. Without signalling reconnect here, the stream silently dies
2700            // while heartbeat ping/pong keeps the connection alive.
2701            warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2702            true
2703        }
2704        "listStatus" | "externalLockUpdate" => {
2705            // OCO/OTO list status (each leg's own executionReport carries the order state) and
2706            // balance locked or unlocked by an external system (e.g. as collateral elsewhere):
2707            // ignored knowingly, never tear down the stream.
2708            trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2709            false
2710        }
2711        _ => {
2712            static SEEN: UnhandledEvents = UnhandledEvents::new();
2713            log_unhandled_event("BinanceSpot", &SEEN, event_type, event);
2714            false
2715        }
2716    }
2717}
2718
2719/// Convert a Binance outboundAccountPosition to balance stream-update events.
2720///
2721/// Emits one `BalanceStreamUpdate` event per asset (the WS message is a `free`/`locked` partial,
2722/// not a full snapshot). Pushes into the provided buffer to avoid per-message allocation.
2723fn convert_account_position(
2724    position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2725    buf: &mut Vec<UnindexedAccountEvent>,
2726) {
2727    // `u` (last account update time) rather than `E` (event time), for more accurate balance
2728    // timestamps.
2729    let Some(time_exchange) =
2730        account_position_time(ExchangeId::BinanceSpot, position.u, position.e_uppercase)
2731    else {
2732        return;
2733    };
2734
2735    for b in position.b_uppercase.unwrap_or_default() {
2736        let asset = match b.a {
2737            Some(a) => AssetNameExchange::new(a),
2738            None => {
2739                warn!("BinanceSpot account position entry missing asset name");
2740                continue;
2741            }
2742        };
2743        let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2744            Some(v) => v,
2745            None => {
2746                warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2747                continue;
2748            }
2749        };
2750        let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2751            Some(v) => v,
2752            None => {
2753                warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2754                continue;
2755            }
2756        };
2757        // WS `outboundAccountPosition` is a free/locked partial (no debt), so emit a
2758        // `BalanceStreamUpdate` rather than a full `BalanceSnapshot`. Spot carries no margin debt,
2759        // so nothing is preserved here — but routing it through the update path keeps one
2760        // consistent model (REST → snapshot, WS → update) and protects margin debt downstream.
2761        let update =
2762            AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2763        buf.push(UnindexedAccountEvent::new(
2764            ExchangeId::BinanceSpot,
2765            AccountEventKind::BalanceStreamUpdate(
2766                rustrade_integration::collection::snapshot::Snapshot::new(update),
2767            ),
2768        ));
2769    }
2770}
2771
2772/// Convert rustrade OrderKind + TimeInForce to Binance SDK order type and TIF.
2773///
2774/// Returns `None` for unsupported combinations, which `open_order` converts to
2775/// `UnsupportedOrderType` rejection.
2776///
2777/// # Conditional Orders
2778///
2779/// - `Stop` → `STOP_LOSS` (market order triggered at stop price)
2780/// - `StopLimit` → `STOP_LOSS_LIMIT` (limit order triggered at stop price)
2781/// - `TakeProfit` → `TAKE_PROFIT` (market order triggered at take-profit price)
2782/// - `TakeProfitLimit` → `TAKE_PROFIT_LIMIT` (limit order triggered at take-profit price)
2783///
2784/// # Trailing Stop Orders
2785///
2786/// Binance implements trailing stops via `STOP_LOSS` with `trailingDelta` parameter.
2787///
2788/// **Supported offset types** (`open_order` refuses an offset that does not come to a positive
2789/// whole number of basis points with `InvalidPrecision`; see the shared
2790/// `trailing_delta_basis_points`):
2791/// - `BasisPoints`: used directly (1 basis point = 0.01%)
2792/// - `Percentage`: converted to basis points (multiplied by 100)
2793///
2794/// **Unsupported:**
2795/// - `Absolute`: returns `None` → `UnsupportedOrderType`. The caller (`open_order`)
2796///   must surface this so end users can convert absolute offsets to basis points
2797///   themselves: `basis_points = (absolute / price) * 10000`
2798/// - `TrailingStopLimit`: Binance's trailing `STOP_LOSS_LIMIT` keeps its limit price fixed while
2799///   the stop trails, where `TrailingStopLimit`'s limit follows the stop. That order is
2800///   `TrailingStopFixedLimit`, which converts to `STOP_LOSS_LIMIT`.
2801///
2802fn convert_order_kind_tif(
2803    kind: OrderKind,
2804    tif: TimeInForce,
2805) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2806    // The decision logic (which kind/TIF combinations Binance supports, GTD coercion,
2807    // trailing-offset handling) lives in the shared classifier so spot and margin share it;
2808    // this adapter only maps the venue-neutral result onto spot's WS-API enum types.
2809    let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2810    let type_enum = match binance_type {
2811        BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2812        BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2813        BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2814        BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2815        BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2816        BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2817        BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2818    };
2819    let tif_enum = binance_tif.map(|t| match t {
2820        BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2821        BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2822        BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2823    });
2824    Some((type_enum, tif_enum))
2825}
2826
2827#[cfg(test)]
2828#[allow(clippy::unwrap_used, clippy::expect_used)] // Test code: panics on bad input are acceptable
2829mod tests {
2830    use super::*;
2831    use crate::client::binance::shared::*;
2832    use crate::client::dedup::{DedupEventKind, DedupKey};
2833    use crate::error::StreamTerminationReason;
2834    use crate::order::{TrailingOffsetType, id::ClientOrderId};
2835    use binance_sdk::common::errors::WebsocketError;
2836    use smol_str::SmolStr;
2837
2838    /// No WS-API session could be connected, so an open was never sent: the order was not
2839    /// placed. A cancel keeps the connectivity error, and a rate limit is a rejection for both.
2840    #[test]
2841    fn an_unconnected_session_fails_an_open_as_not_placed() {
2842        let open = WsApiUnavailable::Connect("refused".into()).into_open_error();
2843        assert!(matches!(
2844            &open,
2845            OrderError::NotPlaced(NotPlacedReason::NotSent(ConnectivityError::Socket(m))) if m == "refused"
2846        ));
2847        assert!(!open.is_outcome_unknown());
2848
2849        let cancel = WsApiUnavailable::Connect("refused".into()).into_order_error();
2850        assert!(matches!(cancel, OrderError::Connectivity(_)));
2851
2852        let limited = || WsApiUnavailable::RateLimited {
2853            msg: "banned".into(),
2854            retry_after: None,
2855        };
2856        assert_eq!(limited().into_open_error(), limited().into_order_error());
2857        assert!(matches!(
2858            limited().into_open_error(),
2859            OrderError::Rejected(ApiError::RateLimit { .. })
2860        ));
2861    }
2862
2863    /// A spot account never borrows, so it honours `NoSideEffect` alone, and refuses a borrow or
2864    /// a repay before it opens a connection.
2865    #[tokio::test]
2866    async fn open_order_refuses_a_borrow_or_repay_effect_unsent() {
2867        use crate::{
2868            client::ExecutionClient,
2869            margin::MarginEffect,
2870            order::{request::RequestOpen, state::InactiveOrderState},
2871        };
2872
2873        let client = BinanceSpot::new(
2874            BinanceSpotConfig::testnet("key".into(), "secret".into()).with_server_time_sync(false),
2875        );
2876        let instrument = InstrumentNameExchange::new("BTCUSDT");
2877
2878        for effect in [MarginEffect::Borrow, MarginEffect::Repay] {
2879            let order = client
2880                .open_order(OrderRequestOpen {
2881                    key: OrderKey::new(
2882                        ExchangeId::BinanceSpot,
2883                        &instrument,
2884                        StrategyId::new("strategy"),
2885                        ClientOrderId::new("cid"),
2886                    ),
2887                    state: RequestOpen::new(
2888                        Side::Buy,
2889                        Some(Decimal::from(50_000)),
2890                        Decimal::ONE,
2891                        OrderKind::Limit,
2892                        TimeInForce::GoodUntilCancelled { post_only: false },
2893                    )
2894                    .with_margin_effect(effect),
2895                })
2896                .await;
2897
2898            assert!(
2899                matches!(
2900                    order.state,
2901                    OrderState::Inactive(InactiveOrderState::OpenFailed(
2902                        OrderError::UnsupportedMarginEffect(refused)
2903                    )) if refused == effect
2904                ),
2905                "{order:?}"
2906            );
2907        }
2908    }
2909
2910    /// `load_rules` returns the rules it read.
2911    #[tokio::test]
2912    async fn load_rules_returns_the_rules_it_read() {
2913        let server = wiremock::MockServer::start().await;
2914        wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
2915            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2916                serde_json::json!({"symbols": [{
2917                    "symbol": "BTCUSDT", "status": "TRADING",
2918                    "filters": [
2919                        {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
2920                    ],
2921                }]}),
2922            ))
2923            .mount(&server)
2924            .await;
2925        let mut client = BinanceSpot::new(
2926            BinanceSpotConfig::testnet("key".into(), "secret".into()).with_server_time_sync(false),
2927        );
2928        client.rules = Arc::new(RulesLoader::new(
2929            PreSendRules::Enforce,
2930            Arc::new(SpotRestApi::from_config(
2931                ConfigurationRestApi::builder()
2932                    .api_key("key")
2933                    .api_secret("secret")
2934                    .base_path(server.uri())
2935                    .build()
2936                    .unwrap(),
2937            )),
2938            Arc::new(RateLimitTracker::new(WeightPool::Spot)),
2939        ));
2940        let btcusdt = InstrumentNameExchange::new("BTCUSDT");
2941
2942        let loaded = client.load_rules(std::slice::from_ref(&btcusdt)).await;
2943        let rules = loaded.ok().and_then(|mut loaded| loaded.remove(&btcusdt));
2944        assert_eq!(
2945            rules.map(|rules| rules.quantity.step),
2946            Some(Some(Decimal::new(1, 3)))
2947        );
2948    }
2949
2950    /// An order that breaks the symbol's rules is refused before a connection opens, with the
2951    /// limit it broke; a refusal costs no order request.
2952    #[tokio::test]
2953    async fn open_order_refuses_an_order_breaking_the_symbols_rules_unsent() {
2954        use crate::{
2955            client::ExecutionClient,
2956            error::{Bound, BoundsViolation, OrderField},
2957            order::{request::RequestOpen, state::InactiveOrderState},
2958        };
2959
2960        let server = wiremock::MockServer::start().await;
2961        wiremock::Mock::given(wiremock::matchers::method("GET"))
2962            .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
2963            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2964                serde_json::json!({"symbols": [{
2965                    "symbol": "BTCUSDT", "status": "TRADING",
2966                    "filters": [
2967                        {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
2968                    ],
2969                }]}),
2970            ))
2971            .expect(1)
2972            .mount(&server)
2973            .await;
2974        let mut client = BinanceSpot::new(
2975            BinanceSpotConfig::testnet("key".into(), "secret".into()).with_server_time_sync(false),
2976        );
2977        client.rules = Arc::new(RulesLoader::new(
2978            PreSendRules::Enforce,
2979            Arc::new(SpotRestApi::from_config(
2980                ConfigurationRestApi::builder()
2981                    .api_key("key")
2982                    .api_secret("secret")
2983                    .base_path(server.uri())
2984                    .build()
2985                    .unwrap(),
2986            )),
2987            Arc::new(RateLimitTracker::new(WeightPool::Spot)),
2988        ));
2989        let instrument = InstrumentNameExchange::new("BTCUSDT");
2990
2991        let order = client
2992            .open_order(OrderRequestOpen {
2993                key: OrderKey::new(
2994                    ExchangeId::BinanceSpot,
2995                    &instrument,
2996                    StrategyId::new("strategy"),
2997                    ClientOrderId::new("cid"),
2998                ),
2999                state: RequestOpen::new(
3000                    Side::Buy,
3001                    Some(Decimal::from(50_000)),
3002                    Decimal::from(101),
3003                    OrderKind::Limit,
3004                    TimeInForce::GoodUntilCancelled { post_only: false },
3005                ),
3006            })
3007            .await;
3008
3009        assert!(
3010            matches!(
3011                order.state,
3012                OrderState::Inactive(InactiveOrderState::OpenFailed(
3013                    OrderError::OutOfBounds(violation)
3014                )) if violation == BoundsViolation::new(
3015                    OrderField::Quantity,
3016                    Bound::Max,
3017                    Decimal::from(100),
3018                    Decimal::from(101),
3019                )
3020            ),
3021            "{order:?}"
3022        );
3023    }
3024
3025    /// A trailing stop's offset is held to `TRAILING_DELTA`'s range for its side, the range above
3026    /// for a buy and below for a sell, and refused unsent.
3027    #[tokio::test]
3028    async fn open_order_refuses_a_trailing_offset_outside_its_sides_range_unsent() {
3029        use crate::{
3030            client::ExecutionClient,
3031            error::{Bound, BoundsViolation, OrderField},
3032            order::{TrailingOffsetType, request::RequestOpen, state::InactiveOrderState},
3033        };
3034
3035        let server = wiremock::MockServer::start().await;
3036        wiremock::Mock::given(wiremock::matchers::method("GET"))
3037            .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
3038            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3039                serde_json::json!({"symbols": [{
3040                    "symbol": "BTCUSDT", "status": "TRADING",
3041                    "filters": [
3042                        {"filterType": "TRAILING_DELTA", "minTrailingAboveDelta": 10,
3043                         "maxTrailingAboveDelta": 2000, "minTrailingBelowDelta": 10,
3044                         "maxTrailingBelowDelta": 1000},
3045                    ],
3046                }]}),
3047            ))
3048            .expect(1)
3049            .mount(&server)
3050            .await;
3051        let mut client = BinanceSpot::new(
3052            BinanceSpotConfig::testnet("key".into(), "secret".into()).with_server_time_sync(false),
3053        );
3054        client.rules = Arc::new(RulesLoader::new(
3055            PreSendRules::Enforce,
3056            Arc::new(SpotRestApi::from_config(
3057                ConfigurationRestApi::builder()
3058                    .api_key("key")
3059                    .api_secret("secret")
3060                    .base_path(server.uri())
3061                    .build()
3062                    .unwrap(),
3063            )),
3064            Arc::new(RateLimitTracker::new(WeightPool::Spot)),
3065        ));
3066        let instrument = InstrumentNameExchange::new("BTCUSDT");
3067        let order = |cid: &str, side: Side, offset: Decimal| OrderRequestOpen {
3068            key: OrderKey::new(
3069                ExchangeId::BinanceSpot,
3070                &instrument,
3071                StrategyId::new("strategy"),
3072                ClientOrderId::new(cid),
3073            ),
3074            state: RequestOpen::new(
3075                side,
3076                None,
3077                Decimal::ONE,
3078                OrderKind::TrailingStop {
3079                    offset,
3080                    offset_type: TrailingOffsetType::BasisPoints,
3081                    activation_price: None,
3082                },
3083                TimeInForce::GoodUntilCancelled { post_only: false },
3084            ),
3085        };
3086        let refused_at = |order: Order<_, _, _>| match order.state {
3087            OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::OutOfBounds(
3088                violation,
3089            ))) => Some(violation),
3090            _ => None,
3091        };
3092
3093        let buy = client
3094            .open_order(order("a", Side::Buy, Decimal::from(2500)))
3095            .await;
3096        assert_eq!(
3097            refused_at(buy),
3098            Some(BoundsViolation::new(
3099                OrderField::TrailingOffset,
3100                Bound::Max,
3101                Decimal::from(2000),
3102                Decimal::from(2500),
3103            ))
3104        );
3105        let sell = client
3106            .open_order(order("b", Side::Sell, Decimal::from(1500)))
3107            .await;
3108        assert_eq!(
3109            refused_at(sell),
3110            Some(BoundsViolation::new(
3111                OrderField::TrailingOffset,
3112                Bound::Max,
3113                Decimal::from(1000),
3114                Decimal::from(1500),
3115            ))
3116        );
3117    }
3118
3119    #[test]
3120    fn test_binance_spot_config_new_uses_testnet_by_default() {
3121        let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
3122        assert!(cfg.testnet);
3123        assert_eq!(cfg.api_key(), "my_key");
3124    }
3125
3126    #[test]
3127    fn test_binance_spot_config_production_is_explicit() {
3128        let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
3129        assert!(!cfg.testnet);
3130    }
3131
3132    #[test]
3133    fn test_binance_spot_config_debug_redacts_credentials() {
3134        let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
3135        let debug = format!("{cfg:?}");
3136
3137        assert!(!debug.contains("my_key"), "api_key should be redacted");
3138        assert!(
3139            !debug.contains("my_secret"),
3140            "secret_key should be redacted"
3141        );
3142        assert!(debug.contains("testnet: true"));
3143    }
3144
3145    #[test]
3146    fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
3147        let cfg: BinanceSpotConfig = serde_json::from_str(
3148            r#"{
3149        "api_key": "my_key",
3150        "secret_key": "my_secret"
3151    }"#,
3152        )
3153        .unwrap();
3154
3155        assert!(cfg.testnet);
3156        assert_eq!(cfg.api_key(), "my_key");
3157    }
3158
3159    #[test]
3160    fn test_binance_spot_config_deserialize_testnet_true() {
3161        let cfg: BinanceSpotConfig = serde_json::from_str(
3162            r#"{
3163        "api_key": "my_key",
3164        "secret_key": "my_secret",
3165        "testnet": true
3166    }"#,
3167        )
3168        .unwrap();
3169
3170        assert!(cfg.testnet);
3171    }
3172
3173    #[test]
3174    fn test_binance_spot_config_deserialize_testnet_false() {
3175        let cfg: BinanceSpotConfig = serde_json::from_str(
3176            r#"{
3177        "api_key": "my_key",
3178        "secret_key": "my_secret",
3179        "testnet": false
3180    }"#,
3181        )
3182        .unwrap();
3183
3184        assert!(!cfg.testnet);
3185    }
3186
3187    #[test]
3188    #[serial_test::serial]
3189    fn test_binance_spot_config_from_env_defaults_to_testnet() {
3190        temp_env::with_vars(
3191            [
3192                ("BINANCE_API_KEY", Some("my_key")),
3193                ("BINANCE_SECRET_KEY", Some("my_secret")),
3194                ("BINANCE_TESTNET", None),
3195            ],
3196            || {
3197                let cfg = BinanceSpotConfig::from_env().unwrap();
3198                assert!(cfg.testnet);
3199                assert_eq!(cfg.api_key(), "my_key");
3200            },
3201        );
3202    }
3203
3204    #[test]
3205    #[serial_test::serial]
3206    fn test_binance_spot_config_from_env_accepts_explicit_production() {
3207        temp_env::with_vars(
3208            [
3209                ("BINANCE_API_KEY", Some("my_key")),
3210                ("BINANCE_SECRET_KEY", Some("my_secret")),
3211                ("BINANCE_TESTNET", Some("false")),
3212            ],
3213            || {
3214                let cfg = BinanceSpotConfig::from_env().unwrap();
3215                assert!(!cfg.testnet);
3216            },
3217        );
3218    }
3219
3220    #[test]
3221    #[serial_test::serial]
3222    fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
3223        temp_env::with_vars(
3224            [
3225                ("BINANCE_API_KEY", Some("my_key")),
3226                ("BINANCE_SECRET_KEY", Some("my_secret")),
3227                ("BINANCE_TESTNET", Some("maybe")),
3228            ],
3229            || {
3230                let err = BinanceSpotConfig::from_env().unwrap_err();
3231                assert!(
3232                    matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
3233                );
3234            },
3235        );
3236    }
3237
3238    #[test]
3239    #[serial_test::serial]
3240    fn test_binance_spot_config_from_env_requires_credentials() {
3241        temp_env::with_vars(
3242            [
3243                ("BINANCE_API_KEY", None),
3244                ("BINANCE_SECRET_KEY", Some("my_secret")),
3245                ("BINANCE_TESTNET", None),
3246            ],
3247            || {
3248                let err = BinanceSpotConfig::from_env().unwrap_err();
3249                assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
3250            },
3251        );
3252    }
3253
3254    #[test]
3255    fn test_parse_side() {
3256        assert_eq!(parse_side("BUY"), Some(Side::Buy));
3257        assert_eq!(parse_side("SELL"), Some(Side::Sell));
3258        assert_eq!(parse_side("buy"), None);
3259        assert_eq!(parse_side("UNKNOWN"), None);
3260    }
3261
3262    #[test]
3263    fn test_parse_time_in_force() {
3264        assert_eq!(
3265            parse_time_in_force("GTC"),
3266            TimeInForce::GoodUntilCancelled { post_only: false }
3267        );
3268        assert_eq!(
3269            parse_time_in_force("GTX"),
3270            TimeInForce::GoodUntilCancelled { post_only: true }
3271        );
3272        assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
3273        assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
3274        assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
3275        // Unknown defaults to GTC
3276        assert_eq!(
3277            parse_time_in_force("UNKNOWN"),
3278            TimeInForce::GoodUntilCancelled { post_only: false }
3279        );
3280    }
3281
3282    #[test]
3283    fn test_convert_order_kind_tif() {
3284        // binance-sdk enums don't derive PartialEq, so use matches!
3285        assert!(matches!(
3286            convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
3287            Some((OrderPlaceTypeEnum::Market, None))
3288        ));
3289        assert!(matches!(
3290            convert_order_kind_tif(
3291                OrderKind::Limit,
3292                TimeInForce::GoodUntilCancelled { post_only: false }
3293            ),
3294            Some((
3295                OrderPlaceTypeEnum::Limit,
3296                Some(OrderPlaceTimeInForceEnum::Gtc)
3297            ))
3298        ));
3299        assert!(matches!(
3300            convert_order_kind_tif(
3301                OrderKind::Limit,
3302                TimeInForce::GoodUntilCancelled { post_only: true }
3303            ),
3304            Some((OrderPlaceTypeEnum::LimitMaker, None))
3305        ));
3306        assert!(matches!(
3307            convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
3308            Some((
3309                OrderPlaceTypeEnum::Limit,
3310                Some(OrderPlaceTimeInForceEnum::Fok)
3311            ))
3312        ));
3313        assert!(matches!(
3314            convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
3315            Some((
3316                OrderPlaceTypeEnum::Limit,
3317                Some(OrderPlaceTimeInForceEnum::Ioc)
3318            ))
3319        ));
3320        // GoodUntilEndOfDay is unsupported on Binance (no native EOD order) — surfaced as
3321        // unsupported rather than silently coerced to GTC, which would drop the EOD semantics.
3322        assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
3323    }
3324
3325    #[test]
3326    fn test_convert_order_kind_tif_conditional() {
3327        use rust_decimal::Decimal;
3328
3329        assert!(matches!(
3330            convert_order_kind_tif(
3331                OrderKind::Stop {
3332                    trigger_price: Decimal::from(100)
3333                },
3334                TimeInForce::GoodUntilCancelled { post_only: false }
3335            ),
3336            Some((OrderPlaceTypeEnum::StopLoss, None))
3337        ));
3338        assert!(matches!(
3339            convert_order_kind_tif(
3340                OrderKind::StopLimit {
3341                    trigger_price: Decimal::from(100)
3342                },
3343                TimeInForce::GoodUntilCancelled { post_only: false }
3344            ),
3345            Some((
3346                OrderPlaceTypeEnum::StopLossLimit,
3347                Some(OrderPlaceTimeInForceEnum::Gtc)
3348            ))
3349        ));
3350        assert!(matches!(
3351            convert_order_kind_tif(
3352                OrderKind::TakeProfit {
3353                    trigger_price: Decimal::from(150)
3354                },
3355                TimeInForce::ImmediateOrCancel
3356            ),
3357            Some((OrderPlaceTypeEnum::TakeProfit, None))
3358        ));
3359        assert!(matches!(
3360            convert_order_kind_tif(
3361                OrderKind::TakeProfitLimit {
3362                    trigger_price: Decimal::from(150)
3363                },
3364                TimeInForce::FillOrKill
3365            ),
3366            Some((
3367                OrderPlaceTypeEnum::TakeProfitLimit,
3368                Some(OrderPlaceTimeInForceEnum::Fok)
3369            ))
3370        ));
3371    }
3372
3373    #[test]
3374    fn test_convert_order_kind_tif_trailing() {
3375        use rust_decimal::Decimal;
3376
3377        // TrailingStop with BasisPoints/Percentage → StopLoss (trailingDelta set separately)
3378        assert!(matches!(
3379            convert_order_kind_tif(
3380                OrderKind::TrailingStop {
3381                    offset: Decimal::from(100),
3382                    offset_type: TrailingOffsetType::BasisPoints,
3383                    activation_price: None,
3384                },
3385                TimeInForce::GoodUntilCancelled { post_only: false }
3386            ),
3387            Some((OrderPlaceTypeEnum::StopLoss, None))
3388        ));
3389        assert!(matches!(
3390            convert_order_kind_tif(
3391                OrderKind::TrailingStop {
3392                    offset: Decimal::from(5),
3393                    offset_type: TrailingOffsetType::Percentage,
3394                    activation_price: None,
3395                },
3396                TimeInForce::GoodUntilCancelled { post_only: false }
3397            ),
3398            Some((OrderPlaceTypeEnum::StopLoss, None))
3399        ));
3400
3401        // TrailingStop with Absolute → unsupported (returns None)
3402        assert!(
3403            convert_order_kind_tif(
3404                OrderKind::TrailingStop {
3405                    offset: Decimal::from(10),
3406                    offset_type: TrailingOffsetType::Absolute,
3407                    activation_price: None,
3408                },
3409                TimeInForce::GoodUntilCancelled { post_only: false }
3410            )
3411            .is_none()
3412        );
3413
3414        // TrailingStopLimit → unsupported (Binance's trailing limit is fixed, not offset)
3415        assert!(
3416            convert_order_kind_tif(
3417                OrderKind::TrailingStopLimit {
3418                    offset: Decimal::from(100),
3419                    offset_type: TrailingOffsetType::BasisPoints,
3420                    stop_price: Decimal::from(95),
3421                    limit_offset: Decimal::from(10),
3422                },
3423                TimeInForce::GoodUntilCancelled { post_only: false }
3424            )
3425            .is_none()
3426        );
3427    }
3428
3429    /// A fixed-limit trailing stop is a STOP_LOSS_LIMIT, timed as a stop-limit order is; an
3430    /// Absolute offset cannot be sent.
3431    #[test]
3432    fn a_fixed_limit_trailing_stop_converts_to_stop_loss_limit() {
3433        let fixed_limit = |offset_type| OrderKind::TrailingStopFixedLimit {
3434            offset: Decimal::from(100),
3435            offset_type,
3436            activation_price: Some(Decimal::from(50_000)),
3437        };
3438        assert!(matches!(
3439            convert_order_kind_tif(
3440                fixed_limit(TrailingOffsetType::BasisPoints),
3441                TimeInForce::GoodUntilCancelled { post_only: false }
3442            ),
3443            Some((
3444                OrderPlaceTypeEnum::StopLossLimit,
3445                Some(OrderPlaceTimeInForceEnum::Gtc)
3446            ))
3447        ));
3448        assert!(
3449            convert_order_kind_tif(
3450                fixed_limit(TrailingOffsetType::Absolute),
3451                TimeInForce::GoodUntilCancelled { post_only: false }
3452            )
3453            .is_none()
3454        );
3455    }
3456
3457    #[test]
3458    fn test_parse_binance_api_error() {
3459        let instrument = InstrumentNameExchange::new("BTCUSDT");
3460
3461        assert!(matches!(
3462            parse_binance_api_error("Insufficient balance".into(), &instrument),
3463            ApiError::BalanceInsufficient(None, _)
3464        ));
3465        assert!(matches!(
3466            parse_binance_api_error("Not enough funds".into(), &instrument),
3467            ApiError::BalanceInsufficient(None, _)
3468        ));
3469        assert_eq!(
3470            parse_binance_api_error("Rate limit exceeded".into(), &instrument),
3471            ApiError::rate_limited(None)
3472        );
3473        assert_eq!(
3474            parse_binance_api_error("Error code -1015".into(), &instrument),
3475            ApiError::rate_limited(None)
3476        );
3477        // -1003: too many requests — must also map to RateLimit (not OrderRejected)
3478        assert_eq!(
3479            parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
3480            ApiError::rate_limited(None)
3481        );
3482        // -2011 maps to OrderAlreadyCancelled
3483        assert_eq!(
3484            parse_binance_api_error("Error code -2011".into(), &instrument),
3485            ApiError::OrderAlreadyCancelled
3486        );
3487        // -2013 maps to OrderAlreadyCancelled (benign race condition on cancel)
3488        assert!(matches!(
3489            parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
3490            ApiError::OrderAlreadyCancelled
3491        ));
3492        // -2013 without "unknown order" text still matches via code
3493        assert!(matches!(
3494            parse_binance_api_error("Order does not exist -2013".into(), &instrument),
3495            ApiError::OrderAlreadyCancelled
3496        ));
3497        // "Unknown order" text without a code falls through to text heuristic
3498        assert!(matches!(
3499            parse_binance_api_error("Unknown order encountered".into(), &instrument),
3500            ApiError::OrderRejected(_)
3501        ));
3502        assert!(matches!(
3503            parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
3504            ApiError::InstrumentInvalid(_, _)
3505        ));
3506        // -2010 is read by its text: Binance gives it for many reasons besides balance
3507        assert!(matches!(
3508            parse_binance_api_error(
3509                "Server-side response error (code -2010): Account has insufficient balance".into(),
3510                &instrument
3511            ),
3512            ApiError::BalanceInsufficient(None, _)
3513        ));
3514        assert!(matches!(
3515            parse_binance_api_error(
3516                "Server-side response error (code -2010): Order would trigger immediately.".into(),
3517                &instrument
3518            ),
3519            ApiError::OrderRejected(_)
3520        ));
3521        assert!(matches!(
3522            parse_binance_api_error("Some other error".into(), &instrument),
3523            ApiError::OrderRejected(_)
3524        ));
3525    }
3526
3527    #[test]
3528    fn test_contains_error_code_suffix_guard() {
3529        // Suffix digit guard: "-2013" must not match "-20130" or "-20131"
3530        assert!(
3531            !contains_error_code("-20130", "-2013"),
3532            "-20130 should not match -2013"
3533        );
3534        assert!(
3535            !contains_error_code("-20131", "-2013"),
3536            "-20131 should not match -2013"
3537        );
3538        // Exact match and match with trailing text should succeed
3539        assert!(contains_error_code("-2013", "-2013"), "exact match");
3540        assert!(
3541            contains_error_code("Error -2013: text", "-2013"),
3542            "match with trailing text"
3543        );
3544    }
3545
3546    #[test]
3547    fn test_contains_error_code_prefix_guard() {
3548        // Prefix digit guard: "-2013" must not match a string where the code
3549        // is immediately preceded by a digit (e.g. "1-2013" in some error context).
3550        assert!(
3551            !contains_error_code("1-2013", "-2013"),
3552            "1-2013 should not match -2013"
3553        );
3554        assert!(
3555            !contains_error_code("error 1-2013 text", "-2013"),
3556            "embedded 1-2013 should not match"
3557        );
3558        // Non-digit prefix should still match
3559        assert!(
3560            contains_error_code("code=-2013,", "-2013"),
3561            "=-2013 prefix should match"
3562        );
3563        assert!(
3564            contains_error_code(" -2013 ", "-2013"),
3565            "space prefix should match"
3566        );
3567    }
3568
3569    #[test]
3570    fn test_contains_error_code_second_occurrence_valid() {
3571        // When the first occurrence fails the suffix digit guard (e.g. "-2013" inside
3572        // "-20130"), the function must continue scanning and find the later valid occurrence.
3573        assert!(
3574            contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
3575            "second valid occurrence must match when first fails digit guard"
3576        );
3577        // Symmetric: first valid, second is a longer code — first must still match
3578        assert!(
3579            contains_error_code("-2013 or -20130", "-2013"),
3580            "first valid occurrence must match when second is a longer code"
3581        );
3582    }
3583
3584    #[test]
3585    fn test_dedup_cache() {
3586        let cache = new_dedup_cache();
3587        let key = DedupKey {
3588            instrument: SmolStr::from("BTCUSDT"),
3589            id: SmolStr::from("12345"),
3590            kind: DedupEventKind::Trade,
3591        };
3592
3593        // First time: not a duplicate
3594        assert!(!is_duplicate(&cache, key));
3595        // Second time: is a duplicate
3596        let key = DedupKey {
3597            instrument: SmolStr::from("BTCUSDT"),
3598            id: SmolStr::from("12345"),
3599            kind: DedupEventKind::Trade,
3600        };
3601        assert!(is_duplicate(&cache, key));
3602
3603        // Different key (same id, different kind): not a duplicate
3604        let key2 = DedupKey {
3605            instrument: SmolStr::from("BTCUSDT"),
3606            id: SmolStr::from("12345"),
3607            kind: DedupEventKind::OrderState {
3608                filled_quantity: Decimal::ZERO,
3609            },
3610        };
3611        assert!(!is_duplicate(&cache, key2));
3612    }
3613
3614    /// Two order-state snapshots for one order differ only by cumulative filled quantity, and
3615    /// must not be deduplicated against each other -- otherwise the acknowledgement (`z = 0`)
3616    /// swallows every fill snapshot that follows it.
3617    #[test]
3618    fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
3619        let cache = new_dedup_cache();
3620        let state = |filled: &str| DedupKey {
3621            instrument: SmolStr::from("BTCUSDT"),
3622            id: SmolStr::from("12345"),
3623            kind: DedupEventKind::OrderState {
3624                filled_quantity: Decimal::from_str(filled).unwrap(),
3625            },
3626        };
3627
3628        // Acknowledgement, then two fills on the same order: three distinct states.
3629        assert!(!is_duplicate(&cache, state("0")), "ack is new");
3630        assert!(
3631            !is_duplicate(&cache, state("1")),
3632            "first fill is not the ack"
3633        );
3634        assert!(
3635            !is_duplicate(&cache, state("2")),
3636            "second fill is not the first"
3637        );
3638
3639        // A re-delivered snapshot still collides, which is what keeps a replayed
3640        // acknowledgement from resurrecting an order that has since retired.
3641        assert!(
3642            is_duplicate(&cache, state("0")),
3643            "replayed ack is a duplicate"
3644        );
3645        assert!(
3646            is_duplicate(&cache, state("2")),
3647            "replayed fill is a duplicate"
3648        );
3649
3650        // Decimal hashes normalised, so the venue's chosen representation does not split a key.
3651        assert!(
3652            is_duplicate(&cache, state("2.00")),
3653            "2.00 must be the same key as 2"
3654        );
3655    }
3656
3657    /// Drive frames through the same two stages the live WebSocket callback does: convert, then
3658    /// apply the dedup gate. Returns what a consumer would actually receive.
3659    ///
3660    /// Every other test in this file calls `convert_execution_report` directly. That is the wrong
3661    /// altitude to prove a fill reaches anyone: the converter can be perfectly correct while the
3662    /// gate downstream of it discards what the converter produced.
3663    fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
3664        use crate::client::dedup::dedup_key_from_event;
3665
3666        let cache = new_dedup_cache();
3667        let mut delivered = Vec::new();
3668        let mut buf = Vec::new();
3669        for frame in frames {
3670            let _ = convert_user_data_events(frame, &mut buf);
3671            for ev in buf.drain(..) {
3672                if let Some(key) = dedup_key_from_event(&ev)
3673                    && is_duplicate(&cache, key)
3674                {
3675                    continue;
3676                }
3677                delivered.push(ev);
3678            }
3679        }
3680        delivered
3681    }
3682
3683    // Wrapped as the WS-API subscription delivers them: `{ subscriptionId, event }`.
3684    const WS_NEW: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport","s":"BTCUSDT",
3685        "i":12345,"c":"client-1","x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2",
3686        "p":"100","z":"0","T":1700000000000}}"#;
3687
3688    const WS_PARTIAL_FILL: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport",
3689        "s":"BTCUSDT","i":12345,"c":"client-1","x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY",
3690        "o":"LIMIT","f":"GTC","q":"2","p":"100","z":"1","l":"1","L":"100","t":555,"n":"0.1",
3691        "N":"USDT","T":1700000001000}}"#;
3692
3693    /// A fill's order snapshot must survive the dedup gate that sits between the converter and the
3694    /// consumer.
3695    ///
3696    /// The acknowledgement and the fill describe one order id, so a key built from the id alone
3697    /// makes the second look like a replay of the first. `Open::filled_quantity` then never leaves
3698    /// the `0` the acknowledgement carried, which is the defect the converter change was meant to
3699    /// fix -- fixed in the converter, undone one stage later.
3700    #[test]
3701    fn ws_fill_snapshot_survives_the_dedup_gate() {
3702        use crate::order::state::ActiveOrderState;
3703
3704        let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
3705
3706        assert_eq!(
3707            delivered.len(),
3708            3,
3709            "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
3710        );
3711
3712        let snapshots: Vec<Decimal> = delivered
3713            .iter()
3714            .filter_map(|ev| match &ev.kind {
3715                AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
3716                    OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
3717                    _ => None,
3718                },
3719                _ => None,
3720            })
3721            .collect();
3722
3723        assert_eq!(
3724            snapshots,
3725            vec![Decimal::ZERO, Decimal::ONE],
3726            "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
3727        );
3728    }
3729
3730    /// The gate must still collapse a genuinely re-delivered frame, because a replayed
3731    /// acknowledgement for a retired order is re-inserted as a live resting order by the engine.
3732    #[test]
3733    fn ws_replayed_frame_is_still_deduplicated() {
3734        let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
3735
3736        assert_eq!(
3737            delivered.len(),
3738            3,
3739            "re-delivering both frames must add nothing, got: {delivered:?}"
3740        );
3741    }
3742
3743    #[test]
3744    fn test_is_rate_limit_error() {
3745        // Matches actual binance-sdk TooManyRequestsError Display output
3746        assert!(is_rate_limit_error(&anyhow::anyhow!(
3747            "Too many requests. You are being rate-limited. Please slow down."
3748        )));
3749        // Matches actual binance-sdk RateLimitBanError Display output
3750        assert!(is_rate_limit_error(&anyhow::anyhow!(
3751            "The IP address has been banned for exceeding rate limits. Contact support."
3752        )));
3753        // Binance error codes in the msg body
3754        assert!(is_rate_limit_error(&anyhow::anyhow!(
3755            "Error -1015: too many new orders"
3756        )));
3757        assert!(is_rate_limit_error(&anyhow::anyhow!(
3758            "Error -1003: too many requests"
3759        )));
3760        // Non-rate-limit errors
3761        assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
3762        assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
3763        assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
3764        // Digit-boundary false-positive guard: longer codes must NOT match
3765        assert!(
3766            !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
3767            "-10150 should not match -1015"
3768        );
3769        assert!(
3770            !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
3771            "-10030 should not match -1003"
3772        );
3773    }
3774
3775    #[test]
3776    fn test_classify_ws_order_error_recognises_venue_responses_only() {
3777        let instrument = InstrumentNameExchange::new("BTCUSDT");
3778
3779        // A ResponseError from binance-sdk (the venue answered with status >= 400) is classified
3780        let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
3781            code: -2010,
3782            message: "Account has insufficient balance for requested action.".into(),
3783            retry_after: None,
3784        });
3785        assert!(
3786            matches!(
3787                classify_ws_order_error(&rejection, &instrument),
3788                Some(OrderError::Rejected(ApiError::BalanceInsufficient(None, _)))
3789            ),
3790            "ResponseError should be classified as a venue response"
3791        );
3792
3793        // A transport error (e.g. connection reset) is NOT a venue response
3794        let transport = anyhow::anyhow!("connection reset by peer");
3795        assert!(
3796            classify_ws_order_error(&transport, &instrument).is_none(),
3797            "plain transport error should not be classified as a venue response"
3798        );
3799
3800        // A rate-limit error string (not a WebsocketError) is NOT a venue response
3801        let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
3802        assert!(
3803            classify_ws_order_error(&rate_limit, &instrument).is_none(),
3804            "rate-limit string error should not be classified as a venue response"
3805        );
3806    }
3807
3808    #[test]
3809    fn test_dedup_key_from_event_trade_includes_instrument() {
3810        use crate::order::id::{OrderId, StrategyId};
3811        use crate::trade::{AssetFees, Trade, TradeId};
3812        use chrono::Utc;
3813        use rust_decimal::Decimal;
3814        use rustrade_instrument::Side;
3815
3816        let instrument = InstrumentNameExchange::new("BTCUSDT");
3817        let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3818            TradeId::new("9001"),
3819            OrderId::new("4242"),
3820            instrument.clone(),
3821            StrategyId::unknown(),
3822            Utc::now(),
3823            Side::Buy,
3824            Decimal::ZERO,
3825            Decimal::ZERO,
3826            None,
3827            AssetFees::new(
3828                AssetNameExchange::from("USDT"),
3829                Decimal::ZERO,
3830                Some(Decimal::ZERO),
3831            ),
3832        );
3833        let event =
3834            UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
3835
3836        let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
3837        assert_eq!(key.kind, DedupEventKind::Trade);
3838        assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
3839        assert_eq!(
3840            key.instrument.as_str(),
3841            "BTCUSDT",
3842            "key.instrument should be the symbol"
3843        );
3844
3845        // Same trade ID on a different instrument must produce a different key (cross-symbol collision prevention)
3846        let instrument2 = InstrumentNameExchange::new("ETHUSDT");
3847        let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3848            TradeId::new("9001"),
3849            OrderId::new("7777"),
3850            instrument2,
3851            StrategyId::unknown(),
3852            Utc::now(),
3853            Side::Buy,
3854            Decimal::ZERO,
3855            Decimal::ZERO,
3856            None,
3857            AssetFees::new(
3858                AssetNameExchange::from("USDT"),
3859                Decimal::ZERO,
3860                Some(Decimal::ZERO),
3861            ),
3862        );
3863        let event2 =
3864            UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
3865        let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
3866        assert_ne!(
3867            key, key2,
3868            "same trade ID on different symbols must produce distinct keys"
3869        );
3870    }
3871
3872    // ---------------------------------------------------------------------------
3873    // convert_account_position tests
3874    // ---------------------------------------------------------------------------
3875
3876    fn make_balance_inner(
3877        asset: &str,
3878        free: &str,
3879        locked: &str,
3880    ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3881        binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3882            a: Some(asset.to_string()),
3883            f: Some(free.to_string()),
3884            l: Some(locked.to_string()),
3885        }
3886    }
3887
3888    #[test]
3889    fn test_convert_account_position_happy_path() {
3890        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3891            u: Some(1_700_000_000_000),
3892            b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
3893            ..Default::default()
3894        };
3895        let mut buf = Vec::new();
3896        convert_account_position(position, &mut buf);
3897
3898        assert_eq!(buf.len(), 1);
3899        match &buf[0].kind {
3900            AccountEventKind::BalanceStreamUpdate(snap) => {
3901                let update = &snap.0;
3902                assert_eq!(update.asset.as_ref(), "BTC");
3903                // free = 1.5, locked = 0.5; total = free + locked = 2.0
3904                let expected_free = Decimal::from_str("1.5").unwrap();
3905                let expected_locked = Decimal::from_str("0.5").unwrap();
3906                assert_eq!(update.update.free, expected_free);
3907                assert_eq!(update.update.locked, expected_locked);
3908                assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3909            }
3910            other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3911        }
3912    }
3913
3914    /// A position is at its update time `u`, or at its event time `E` when it has no `u`.
3915    #[test]
3916    fn test_convert_account_position_is_at_u_else_at_e() {
3917        let time_of = |u, e| {
3918            let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3919                u,
3920                e_uppercase: e,
3921                b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3922            };
3923            let mut buf = Vec::new();
3924            convert_account_position(position, &mut buf);
3925            match &buf[..] {
3926                [event] => match &event.kind {
3927                    AccountEventKind::BalanceStreamUpdate(snap) => snap.0.time_exchange,
3928                    other => panic!("expected BalanceStreamUpdate, got {other:?}"),
3929                },
3930                other => panic!("expected one event, got {other:?}"),
3931            }
3932        };
3933        let at = |ms| Utc.timestamp_millis_opt(ms).unwrap();
3934        assert_eq!(time_of(Some(2_000), Some(3_000)), at(2_000));
3935        assert_eq!(time_of(None, Some(3_000)), at(3_000));
3936    }
3937
3938    #[test]
3939    fn test_convert_account_position_missing_asset_name_skipped() {
3940        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3941            u: Some(1_700_000_000_000),
3942            b_uppercase: Some(vec![
3943                binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3944                    a: None, // missing asset name
3945                    f: Some("1.0".to_string()),
3946                    l: Some("0.0".to_string()),
3947                },
3948                make_balance_inner("USDT", "100.0", "0.0"), // valid
3949            ]),
3950            ..Default::default()
3951        };
3952        let mut buf = Vec::new();
3953        convert_account_position(position, &mut buf);
3954        // The entry with missing asset name is skipped; USDT entry is kept
3955        assert_eq!(buf.len(), 1);
3956        match &buf[0].kind {
3957            AccountEventKind::BalanceStreamUpdate(snap) => {
3958                assert_eq!(snap.0.asset.as_ref(), "USDT");
3959            }
3960            other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3961        }
3962    }
3963
3964    #[test]
3965    fn test_convert_account_position_unparseable_free_skipped() {
3966        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3967            u: Some(1_700_000_000_000),
3968            b_uppercase: Some(vec![
3969                binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3970                    a: Some("BTC".to_string()),
3971                    f: Some("not-a-number".to_string()),
3972                    l: Some("0.0".to_string()),
3973                },
3974                make_balance_inner("ETH", "1.0", "0.0"), // valid
3975            ]),
3976            ..Default::default()
3977        };
3978        let mut buf = Vec::new();
3979        convert_account_position(position, &mut buf);
3980        // BTC skipped due to unparseable free; ETH kept
3981        assert_eq!(buf.len(), 1);
3982        match &buf[0].kind {
3983            AccountEventKind::BalanceStreamUpdate(snap) => {
3984                assert_eq!(snap.0.asset.as_ref(), "ETH");
3985            }
3986            other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3987        }
3988    }
3989
3990    #[test]
3991    fn test_convert_account_position_empty_balances() {
3992        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3993            u: Some(1_700_000_000_000),
3994            b_uppercase: Some(vec![]),
3995            ..Default::default()
3996        };
3997        let mut buf = Vec::new();
3998        convert_account_position(position, &mut buf);
3999        assert!(
4000            buf.is_empty(),
4001            "empty balance list should produce no events"
4002        );
4003    }
4004
4005    /// A frame with neither an update time `u` nor an event time `E` is dropped, rather than
4006    /// stamped with this host's time; one with either is delivered.
4007    #[test]
4008    fn an_account_position_without_a_venue_time_is_dropped() {
4009        let converted = |u, e_uppercase| {
4010            let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4011                u,
4012                e_uppercase,
4013                b_uppercase: Some(vec![make_balance_inner("USDT", "100.0", "0.0")]),
4014            };
4015            let mut buf = Vec::new();
4016            convert_account_position(position, &mut buf);
4017            buf.len()
4018        };
4019        assert_eq!(converted(None, None), 0);
4020        assert_eq!(converted(Some(i64::MAX), None), 0);
4021        assert_eq!(converted(Some(1_700_000_000_000), None), 1);
4022        assert_eq!(converted(None, Some(1_700_000_000_000)), 1);
4023    }
4024
4025    #[test]
4026    fn test_convert_account_position_b_field_none() {
4027        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4028            u: Some(1_700_000_000_000),
4029            b_uppercase: None, // no B field at all
4030            ..Default::default()
4031        };
4032        let mut buf = Vec::new();
4033        convert_account_position(position, &mut buf);
4034        assert!(buf.is_empty(), "None B field should produce no events");
4035    }
4036
4037    // 7b: convert_execution_report round-trip tests
4038    // ExecutionReport derives Default with all-Option fields, making it easy to
4039    // construct targeted test cases.
4040    fn make_base_report() -> ExecutionReport {
4041        ExecutionReport {
4042            symbol: Some("BTCUSDT".to_string()),
4043            order_id: Some(12345),
4044            client_order_id: Some("client-1".to_string()),
4045            transaction_time: Some(1_700_000_000_000),
4046            ..Default::default()
4047        }
4048    }
4049
4050    /// Run the shared converter over one report and collect what it appends.
4051    fn convert(report: ExecutionReport) -> Vec<UnindexedAccountEvent> {
4052        let mut buf = Vec::new();
4053        convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
4054        buf
4055    }
4056
4057    /// The one event produced by a report that maps to a single `AccountEvent`.
4058    ///
4059    /// Asserts nothing followed it, so a test written for a single-event report fails loudly if
4060    /// that report ever starts producing two.
4061    fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
4062        assert!(
4063            events.len() <= 1,
4064            "expected at most a single event, got: {events:?}"
4065        );
4066        events.pop()
4067    }
4068
4069    #[test]
4070    fn test_convert_execution_report_new() {
4071        let report = ExecutionReport {
4072            execution_type: Some("NEW".to_string()),
4073            side: Some("BUY".to_string()),
4074            order_type: Some("LIMIT".to_string()),
4075            price: Some("50000.00".to_string()),
4076            order_quantity: Some("0.01".to_string()),
4077            time_in_force: Some("GTC".to_string()),
4078            cumulative_filled_quantity: Some("0".to_string()),
4079            ..make_base_report()
4080        };
4081
4082        let event = sole_event(convert(report)).expect("NEW event should produce Some");
4083        assert_eq!(event.exchange, ExchangeId::BinanceSpot);
4084        match &event.kind {
4085            AccountEventKind::OrderSnapshot(snap) => {
4086                let order = &snap.0;
4087                assert_eq!(order.side, Side::Buy);
4088                assert_eq!(order.kind, OrderKind::Limit);
4089                assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
4090                assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
4091                assert_eq!(order.key.cid.0.as_str(), "client-1");
4092                assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
4093            }
4094            other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
4095        }
4096    }
4097
4098    /// A trailing STOP_LOSS_LIMIT reported on the stream is a fixed-limit trailing stop, with its
4099    /// limit price and its stopPrice as the activation price.
4100    #[test]
4101    fn a_trailing_stop_loss_limit_report_keeps_its_limit_and_activation_price() {
4102        let report = ExecutionReport {
4103            execution_type: Some("NEW".to_string()),
4104            side: Some("SELL".to_string()),
4105            order_type: Some("STOP_LOSS_LIMIT".to_string()),
4106            price: Some("49000.00".to_string()),
4107            stop_price: Some("50000.00".to_string()),
4108            trailing_delta: Some(WireInt::Int(25)),
4109            order_quantity: Some("0.01".to_string()),
4110            time_in_force: Some("GTC".to_string()),
4111            cumulative_filled_quantity: Some("0".to_string()),
4112            ..make_base_report()
4113        };
4114
4115        let event = sole_event(convert(report)).expect("NEW event should produce Some");
4116        let AccountEventKind::OrderSnapshot(snap) = &event.kind else {
4117            panic!("NEW should yield OrderSnapshot, got {:?}", event.kind);
4118        };
4119        assert_eq!(
4120            snap.0.kind,
4121            OrderKind::TrailingStopFixedLimit {
4122                offset: Decimal::from(25),
4123                offset_type: TrailingOffsetType::BasisPoints,
4124                activation_price: Some(Decimal::from(50_000)),
4125            }
4126        );
4127        assert_eq!(snap.0.price, Some(Decimal::from(49_000)));
4128    }
4129
4130    /// A report without its order type (`o`) cannot say what kind the order is, so its snapshot
4131    /// is dropped, as a REST row without one is, rather than read as a `Limit`. A fill it reports
4132    /// is still emitted.
4133    #[test]
4134    fn a_report_without_an_order_type_has_no_order_snapshot() {
4135        let new = ExecutionReport {
4136            execution_type: Some("NEW".to_string()),
4137            side: Some("BUY".to_string()),
4138            price: Some("50000.00".to_string()),
4139            order_quantity: Some("0.01".to_string()),
4140            ..make_base_report()
4141        };
4142        assert!(convert(new).is_empty());
4143
4144        let fill = ExecutionReport {
4145            execution_type: Some("TRADE".to_string()),
4146            order_status: Some("PARTIALLY_FILLED".to_string()),
4147            side: Some("BUY".to_string()),
4148            trade_id: Some(9999),
4149            last_executed_price: Some("50000.00".to_string()),
4150            last_executed_quantity: Some("0.005".to_string()),
4151            commission_amount: Some("0".to_string()),
4152            cumulative_filled_quantity: Some("0.005".to_string()),
4153            order_type: Some("LIMIT".to_string()),
4154            price: Some("50000.00".to_string()),
4155            order_quantity: Some("0.01".to_string()),
4156            ..make_base_report()
4157        };
4158        let kinds = |report| {
4159            convert(report)
4160                .into_iter()
4161                .map(|event| match event.kind {
4162                    AccountEventKind::Trade(_) => "trade",
4163                    AccountEventKind::OrderSnapshot(_) => "snapshot",
4164                    _ => "other",
4165                })
4166                .collect::<Vec<_>>()
4167        };
4168        assert_eq!(kinds(fill.clone()), ["trade", "snapshot"]);
4169        let untyped = ExecutionReport {
4170            order_type: None,
4171            ..fill
4172        };
4173        assert_eq!(kinds(untyped), ["trade"]);
4174    }
4175
4176    #[test]
4177    fn test_convert_execution_report_trade() {
4178        // No `X` (order status): the report says nothing about whether the order is still
4179        // working, so only the execution is emitted.
4180        let report = ExecutionReport {
4181            execution_type: Some("TRADE".to_string()),
4182            side: Some("BUY".to_string()),
4183            trade_id: Some(9999),
4184            last_executed_price: Some("50000.00".to_string()),
4185            last_executed_quantity: Some("0.01".to_string()),
4186            commission_amount: Some("0.000001".to_string()),
4187            ..make_base_report()
4188        };
4189
4190        let event = sole_event(convert(report)).expect("TRADE event should produce Some");
4191        assert_eq!(event.exchange, ExchangeId::BinanceSpot);
4192        match &event.kind {
4193            AccountEventKind::Trade(trade) => {
4194                assert_eq!(trade.side, Side::Buy);
4195                assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
4196                assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
4197                assert_eq!(trade.id.0.as_str(), "9999");
4198                assert_eq!(trade.order_id.0.as_str(), "12345");
4199                assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
4200            }
4201            other => panic!("TRADE should yield Trade, got {other:?}"),
4202        }
4203    }
4204
4205    /// A fill is at its transaction time `T`, else at its report's event time `E`, both the
4206    /// venue's; with neither it is at this host's time, and says so.
4207    #[test]
4208    fn a_fills_time_is_its_transaction_time_then_its_event_time_then_this_hosts() {
4209        let trade = |transaction_time, event_time| {
4210            let report = ExecutionReport {
4211                execution_type: Some("TRADE".to_string()),
4212                side: Some("BUY".to_string()),
4213                trade_id: Some(9999),
4214                last_executed_price: Some("50000.00".to_string()),
4215                last_executed_quantity: Some("0.01".to_string()),
4216                transaction_time,
4217                event_time,
4218                ..make_base_report()
4219            };
4220            match sole_event(convert(report)).map(|event| event.kind) {
4221                Some(AccountEventKind::Trade(trade)) => trade,
4222                other => panic!("TRADE should yield Trade, got {other:?}"),
4223            }
4224        };
4225        let at = |ms| Utc.timestamp_millis_opt(ms).unwrap();
4226
4227        let fill = trade(Some(1_700_000_000_000), Some(1_700_000_000_005));
4228        assert_eq!(
4229            (fill.time_exchange, fill.time_source),
4230            (at(1_700_000_000_000), crate::trade::TimeSource::Venue)
4231        );
4232        for transaction_time in [None, Some(i64::MAX)] {
4233            let fill = trade(transaction_time, Some(1_700_000_000_005));
4234            assert_eq!(
4235                (fill.time_exchange, fill.time_source),
4236                (at(1_700_000_000_005), crate::trade::TimeSource::Venue),
4237                "{transaction_time:?}"
4238            );
4239        }
4240        let before = Utc::now();
4241        let fill = trade(None, None);
4242        assert_eq!(fill.time_source, crate::trade::TimeSource::Host);
4243        assert!((before..=Utc::now()).contains(&fill.time_exchange));
4244    }
4245
4246    /// The `(Trade, OrderSnapshot)` pair a fill report must produce, in that order.
4247    fn trade_events(
4248        events: Vec<UnindexedAccountEvent>,
4249    ) -> (
4250        Trade<AssetNameExchange, InstrumentNameExchange>,
4251        crate::order::Order<
4252            ExchangeId,
4253            InstrumentNameExchange,
4254            OrderState<AssetNameExchange, InstrumentNameExchange>,
4255        >,
4256    ) {
4257        let [first, second]: [UnindexedAccountEvent; 2] = events
4258            .try_into()
4259            .expect("a fill report must produce an execution and an order snapshot");
4260        let AccountEventKind::Trade(trade) = first.kind else {
4261            panic!("first event must be the Trade, got {:?}", first.kind);
4262        };
4263        let AccountEventKind::OrderSnapshot(snap) = second.kind else {
4264            panic!(
4265                "second event must be the OrderSnapshot, got {:?}",
4266                second.kind
4267            );
4268        };
4269        (trade, snap.0)
4270    }
4271
4272    /// A `PARTIALLY_FILLED` report carries the order's cumulative filled quantity in `z`. The
4273    /// execution alone never reaches `Open::filled_quantity`, so without the snapshot a half-done
4274    /// order reads as having nothing filled until REST reconciliation refreshes it.
4275    #[test]
4276    fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
4277        let report = ExecutionReport {
4278            execution_type: Some("TRADE".to_string()),
4279            order_status: Some("PARTIALLY_FILLED".to_string()),
4280            side: Some("BUY".to_string()),
4281            order_type: Some("LIMIT".to_string()),
4282            price: Some("50000.00".to_string()),
4283            order_quantity: Some("0.01".to_string()),
4284            time_in_force: Some("GTC".to_string()),
4285            trade_id: Some(9999),
4286            last_executed_price: Some("50000.00".to_string()),
4287            last_executed_quantity: Some("0.004".to_string()),
4288            cumulative_filled_quantity: Some("0.004".to_string()),
4289            ..make_base_report()
4290        };
4291
4292        let (trade, order) = trade_events(convert(report));
4293        assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
4294
4295        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4296        else {
4297            panic!("expected an Open snapshot, got {:?}", order.state);
4298        };
4299        assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
4300        assert_eq!(
4301            open.quantity_remaining(order.quantity),
4302            Decimal::from_str("0.006").unwrap()
4303        );
4304    }
4305
4306    /// A `FILLED` report reports nothing left to fill, which is how the engine learns the order
4307    /// is done rather than leaving it as a resting order that no longer exists at the venue.
4308    #[test]
4309    fn a_filled_report_reports_nothing_left_to_fill() {
4310        let report = ExecutionReport {
4311            execution_type: Some("TRADE".to_string()),
4312            order_status: Some("FILLED".to_string()),
4313            side: Some("BUY".to_string()),
4314            order_type: Some("LIMIT".to_string()),
4315            price: Some("50000.00".to_string()),
4316            order_quantity: Some("0.01".to_string()),
4317            time_in_force: Some("GTC".to_string()),
4318            trade_id: Some(10_000),
4319            last_executed_price: Some("50000.00".to_string()),
4320            last_executed_quantity: Some("0.006".to_string()),
4321            cumulative_filled_quantity: Some("0.01".to_string()),
4322            ..make_base_report()
4323        };
4324
4325        let (_trade, order) = trade_events(convert(report));
4326        let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4327        else {
4328            panic!("expected an Open snapshot, got {:?}", order.state);
4329        };
4330        assert!(
4331            open.quantity_remaining(order.quantity).is_zero(),
4332            "a completed order must report nothing left to fill so the engine retires it"
4333        );
4334    }
4335
4336    /// A fill whose order the exchange no longer reports as working must not be written back into
4337    /// engine state as an `Open` order. The execution still counts -- it moved the position --
4338    /// but resurrecting the order would leave a resting order that does not exist at the venue.
4339    #[test]
4340    fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
4341        for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
4342            let report = ExecutionReport {
4343                execution_type: Some("TRADE".to_string()),
4344                order_status: Some(status.to_string()),
4345                side: Some("BUY".to_string()),
4346                order_type: Some("LIMIT".to_string()),
4347                price: Some("50000.00".to_string()),
4348                order_quantity: Some("0.01".to_string()),
4349                time_in_force: Some("GTC".to_string()),
4350                trade_id: Some(9999),
4351                last_executed_price: Some("50000.00".to_string()),
4352                last_executed_quantity: Some("0.004".to_string()),
4353                cumulative_filled_quantity: Some("0.004".to_string()),
4354                ..make_base_report()
4355            };
4356            let events = convert(report);
4357            assert_eq!(
4358                events.len(),
4359                1,
4360                "status {status} must produce the execution alone, got: {events:?}"
4361            );
4362            assert!(
4363                matches!(events[0].kind, AccountEventKind::Trade(_)),
4364                "the execution must still be reported for status {status}"
4365            );
4366        }
4367    }
4368
4369    #[test]
4370    fn test_convert_execution_report_trade_missing_last_price() {
4371        // l_uppercase (L = last filled price) missing: must drop the fill
4372        let report = ExecutionReport {
4373            execution_type: Some("TRADE".to_string()),
4374            side: Some("BUY".to_string()),
4375            trade_id: Some(9999),
4376            last_executed_price: None, // missing L field
4377            last_executed_quantity: Some("0.01".to_string()),
4378            ..make_base_report()
4379        };
4380        assert!(
4381            sole_event(convert(report)).is_none(),
4382            "missing last price (L) should return None"
4383        );
4384    }
4385
4386    #[test]
4387    fn test_convert_execution_report_trade_missing_last_qty() {
4388        // l (last filled qty) missing: must drop the fill
4389        let report = ExecutionReport {
4390            execution_type: Some("TRADE".to_string()),
4391            side: Some("BUY".to_string()),
4392            trade_id: Some(9999),
4393            last_executed_price: Some("50000.00".to_string()),
4394            last_executed_quantity: None, // missing l field
4395            ..make_base_report()
4396        };
4397        assert!(
4398            sole_event(convert(report)).is_none(),
4399            "missing last qty (l) should return None"
4400        );
4401    }
4402
4403    #[test]
4404    fn test_convert_execution_report_canceled() {
4405        let report = ExecutionReport {
4406            execution_type: Some("CANCELED".to_string()),
4407            ..make_base_report()
4408        };
4409
4410        let event = sole_event(convert(report)).expect("CANCELED should produce Some");
4411        assert!(
4412            matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
4413            "CANCELED should yield OrderCancelled with Ok state"
4414        );
4415    }
4416
4417    /// A cancel report's `c` is the cancel request's id, so the cancel is keyed by the order's own
4418    /// id in `C`, and by `c` only when `C` is empty or absent.
4419    #[test]
4420    fn a_cancel_report_is_keyed_by_the_cancelled_orders_id() {
4421        let cancelled_cid = |orig: Option<&str>| {
4422            let report = ExecutionReport {
4423                execution_type: Some("CANCELED".to_string()),
4424                client_order_id: Some("cancel-request".to_string()),
4425                orig_client_order_id: orig.map(str::to_string),
4426                ..make_base_report()
4427            };
4428            let event = sole_event(convert(report)).expect("CANCELED should produce Some");
4429            let AccountEventKind::OrderCancelled(response) = event.kind else {
4430                panic!("expected OrderCancelled, got {:?}", event.kind);
4431            };
4432            response.key.cid
4433        };
4434
4435        assert_eq!(
4436            cancelled_cid(Some("client-1")),
4437            ClientOrderId::new("client-1")
4438        );
4439        assert_eq!(
4440            cancelled_cid(Some("")),
4441            ClientOrderId::new("cancel-request")
4442        );
4443        assert_eq!(cancelled_cid(None), ClientOrderId::new("cancel-request"));
4444    }
4445
4446    /// Only a cancel report names the order in `C`: every other report names it in `c`, an amend
4447    /// by the id the amend gave it.
4448    #[test]
4449    fn only_a_cancel_report_reads_the_orders_id_from_c_upper() {
4450        for exec_type in ["NEW", "TRADE", "EXPIRED", "TRADE_PREVENTION", "REPLACED"] {
4451            let report = ExecutionReport {
4452                execution_type: Some(exec_type.to_string()),
4453                orig_client_order_id: Some("client-0".to_string()),
4454                ..make_base_report()
4455            };
4456            assert_eq!(report.order_client_id(), Some("client-1"), "{exec_type}");
4457        }
4458    }
4459
4460    /// A cancel report carries what filled (`z`), and an unknown one stays unknown, not zero.
4461    #[test]
4462    fn a_cancel_report_carries_its_fill_or_none() {
4463        let cancelled = |z: Option<&str>| {
4464            let report = ExecutionReport {
4465                execution_type: Some("CANCELED".to_string()),
4466                cumulative_filled_quantity: z.map(str::to_string),
4467                ..make_base_report()
4468            };
4469            let event = sole_event(convert(report)).expect("CANCELED should produce Some");
4470            let AccountEventKind::OrderCancelled(response) = event.kind else {
4471                panic!("expected OrderCancelled, got {:?}", event.kind);
4472            };
4473            let Ok(cancelled) = response.state else {
4474                panic!("expected Ok, got {:?}", response.state);
4475            };
4476            cancelled.filled_quantity
4477        };
4478
4479        assert_eq!(cancelled(Some("0.5")), Some(Decimal::new(5, 1)));
4480        assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
4481        assert_eq!(cancelled(None), None);
4482        assert_eq!(cancelled(Some("not a number")), None);
4483    }
4484
4485    #[test]
4486    fn test_convert_execution_report_expired() {
4487        let report = ExecutionReport {
4488            execution_type: Some("EXPIRED".to_string()),
4489            ..make_base_report()
4490        };
4491        let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
4492        assert!(
4493            matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
4494            "EXPIRED should yield OrderCancelled with Ok state"
4495        );
4496    }
4497
4498    /// Self-trade prevention that ends an order removes it; one that leaves it live (the
4499    /// `DECREMENT` mode), or names no status or an unknown one, produces nothing.
4500    #[test]
4501    fn a_trade_prevention_report_removes_the_order_only_once_it_has_ended() {
4502        let prevented = |status: Option<&str>| {
4503            convert(ExecutionReport {
4504                execution_type: Some("TRADE_PREVENTION".to_string()),
4505                order_status: status.map(str::to_string),
4506                ..make_base_report()
4507            })
4508        };
4509
4510        for ended in ["EXPIRED_IN_MATCH", "EXPIRED", "FILLED"] {
4511            let event = sole_event(prevented(Some(ended))).expect(ended);
4512            let AccountEventKind::OrderCancelled(response) = event.kind else {
4513                panic!("{ended}: expected OrderCancelled, got {:?}", event.kind);
4514            };
4515            assert_eq!(response.key.cid, ClientOrderId::new("client-1"), "{ended}");
4516            let Ok(cancelled) = response.state else {
4517                panic!("{ended}: expected Ok, got {:?}", response.state);
4518            };
4519            assert_eq!(cancelled.id, OrderId::new("12345"), "{ended}");
4520        }
4521        for not_ended in [
4522            Some("NEW"),
4523            Some("PARTIALLY_FILLED"),
4524            Some("SOME_FUTURE_STATUS"),
4525            None,
4526        ] {
4527            assert!(prevented(not_ended).is_empty(), "{not_ended:?}");
4528        }
4529    }
4530
4531    /// `EXPIRED_IN_MATCH` is an order status, never an execution type.
4532    #[test]
4533    fn expired_in_match_as_an_execution_type_produces_nothing() {
4534        let report = ExecutionReport {
4535            execution_type: Some("EXPIRED_IN_MATCH".to_string()),
4536            ..make_base_report()
4537        };
4538        assert!(convert(report).is_empty());
4539    }
4540
4541    #[test]
4542    fn test_convert_execution_report_rejected() {
4543        let report = ExecutionReport {
4544            execution_type: Some("REJECTED".to_string()),
4545            reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
4546            ..make_base_report()
4547        };
4548
4549        let event = sole_event(convert(report)).expect("REJECTED should produce Some");
4550        assert!(
4551            matches!(
4552                event.kind,
4553                AccountEventKind::OrderCancelled(ref r) if matches!(
4554                    &r.state,
4555                    Err(OrderError::Rejected(ApiError::BalanceInsufficient(None, reason)))
4556                        if reason == "INSUFFICIENT_FUNDS"
4557                )
4558            ),
4559            "a REJECTED shortfall should go through the classifier, got {:?}",
4560            event.kind
4561        );
4562    }
4563
4564    #[test]
4565    fn test_convert_execution_report_rejected_for_another_reason() {
4566        let report = ExecutionReport {
4567            execution_type: Some("REJECTED".to_string()),
4568            reject_reason: Some("WOULD_MATCH_IMMEDIATELY".to_string()),
4569            ..make_base_report()
4570        };
4571
4572        let event = sole_event(convert(report)).expect("REJECTED should produce Some");
4573        assert!(
4574            matches!(
4575                event.kind,
4576                AccountEventKind::OrderCancelled(ref r) if matches!(
4577                    &r.state,
4578                    Err(OrderError::Rejected(ApiError::OrderRejected(reason)))
4579                        if reason == "WOULD_MATCH_IMMEDIATELY"
4580                )
4581            ),
4582            "an unrecognised REJECTED reason should stay OrderRejected, got {:?}",
4583            event.kind
4584        );
4585    }
4586
4587    #[test]
4588    fn test_convert_execution_report_missing_exec_type() {
4589        // Missing x field: must drop the event
4590        let report = ExecutionReport {
4591            symbol: Some("BTCUSDT".to_string()),
4592            ..Default::default()
4593        };
4594        assert!(
4595            sole_event(convert(report)).is_none(),
4596            "missing execution type should return None"
4597        );
4598    }
4599
4600    #[test]
4601    fn test_convert_execution_report_missing_symbol() {
4602        // Missing s field with valid x: must drop the event
4603        let report = ExecutionReport {
4604            execution_type: Some("NEW".to_string()),
4605            ..Default::default()
4606        };
4607        assert!(
4608            sole_event(convert(report)).is_none(),
4609            "missing symbol should return None"
4610        );
4611    }
4612
4613    #[test]
4614    fn test_convert_execution_report_missing_order_id() {
4615        // Missing i field (orderId): shared early-exit path for all exec types
4616        let report = ExecutionReport {
4617            execution_type: Some("NEW".to_string()),
4618            symbol: Some("BTCUSDT".to_string()),
4619            order_id: None,
4620            ..Default::default()
4621        };
4622        assert!(
4623            sole_event(convert(report)).is_none(),
4624            "missing orderId should return None"
4625        );
4626    }
4627
4628    #[test]
4629    fn test_convert_execution_report_trade_missing_trade_id() {
4630        // Missing t field (tradeId) on a TRADE event: must drop the fill
4631        let report = ExecutionReport {
4632            execution_type: Some("TRADE".to_string()),
4633            trade_id: None,
4634            ..make_base_report()
4635        };
4636        assert!(
4637            sole_event(convert(report)).is_none(),
4638            "TRADE event missing tradeId should return None"
4639        );
4640    }
4641
4642    /// An amended order is still live, so an amend report must not retire it.
4643    #[test]
4644    fn an_amend_report_produces_nothing() {
4645        for exec_type in ["REPLACED", "REPLACE"] {
4646            let report = ExecutionReport {
4647                execution_type: Some(exec_type.to_string()),
4648                order_status: Some("NEW".to_string()),
4649                orig_client_order_id: Some("client-0".to_string()),
4650                ..make_base_report()
4651            };
4652            assert!(convert(report).is_empty(), "{exec_type}");
4653        }
4654    }
4655
4656    // ---------------------------------------------------------------------------
4657    // filter_and_convert_balances tests
4658    // ---------------------------------------------------------------------------
4659
4660    fn make_balance(
4661        asset: &str,
4662        free: &str,
4663        locked: &str,
4664    ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4665        binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4666            asset: Some(asset.to_string()),
4667            free: Some(free.to_string()),
4668            locked: Some(locked.to_string()),
4669        }
4670    }
4671
4672    #[test]
4673    fn test_filter_balances_empty_assets_returns_all() {
4674        let balances = vec![
4675            make_balance("BTC", "1.0", "0.0"),
4676            make_balance("USDT", "500.0", "50.0"),
4677        ];
4678        let result = filter_and_convert_balances(balances, &[], Utc::now());
4679        assert_eq!(result.len(), 2, "empty filter should return all balances");
4680    }
4681
4682    #[test]
4683    fn test_filter_balances_matching_asset_returned() {
4684        let balances = vec![
4685            make_balance("BTC", "1.5", "0.5"),
4686            make_balance("ETH", "10.0", "0.0"),
4687        ];
4688        let assets = vec![AssetNameExchange::new("BTC")];
4689        let result = filter_and_convert_balances(balances, &assets, Utc::now());
4690        assert_eq!(result.len(), 1);
4691        assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
4692        // total = free + locked = 1.5 + 0.5
4693        assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
4694        assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
4695    }
4696
4697    #[test]
4698    fn test_filter_balances_non_matching_asset_filtered_out() {
4699        let balances = vec![
4700            make_balance("BTC", "1.0", "0.0"),
4701            make_balance("ETH", "2.0", "0.0"),
4702        ];
4703        let assets = vec![AssetNameExchange::new("USDT")];
4704        let result = filter_and_convert_balances(balances, &assets, Utc::now());
4705        assert!(
4706            result.is_empty(),
4707            "non-matching asset should be filtered out"
4708        );
4709    }
4710
4711    #[test]
4712    fn test_filter_balances_missing_asset_field_skipped() {
4713        let balances = vec![
4714            binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4715                asset: None,
4716                free: Some("1.0".to_string()),
4717                locked: Some("0.0".to_string()),
4718            },
4719            make_balance("USDT", "100.0", "0.0"),
4720        ];
4721        let result = filter_and_convert_balances(balances, &[], Utc::now());
4722        assert_eq!(
4723            result.len(),
4724            1,
4725            "entry with missing asset should be skipped"
4726        );
4727        assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
4728    }
4729
4730    #[test]
4731    fn test_filter_balances_unparseable_free_skipped() {
4732        let balances = vec![
4733            binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4734                asset: Some("BTC".to_string()),
4735                free: Some("not-a-number".to_string()),
4736                locked: Some("0.0".to_string()),
4737            },
4738        ];
4739        let result = filter_and_convert_balances(balances, &[], Utc::now());
4740        assert!(
4741            result.is_empty(),
4742            "unparseable free field should be skipped"
4743        );
4744    }
4745
4746    #[test]
4747    fn test_filter_balances_zero_balance_included() {
4748        // Zero-balance entries are intentionally passed through — the caller decides
4749        // whether to filter them. Binance returns all ever-touched assets including zeroes.
4750        let balances = vec![
4751            make_balance("BTC", "0.00000000", "0.00000000"),
4752            make_balance("USDT", "100.0", "0.0"),
4753        ];
4754        let result = filter_and_convert_balances(balances, &[], Utc::now());
4755        assert_eq!(result.len(), 2, "zero-balance entries must be included");
4756        let btc = result
4757            .iter()
4758            .find(|b| b.asset == AssetNameExchange::new("BTC"))
4759            .unwrap();
4760        assert_eq!(btc.balance.total, Decimal::ZERO);
4761        assert_eq!(btc.balance.free, Decimal::ZERO);
4762    }
4763
4764    #[test]
4765    fn test_filter_balances_duplicate_assets_in_response() {
4766        // if the API response contains two entries for the same asset,
4767        // filter_and_convert_balances emits two AssetBalance entries. Callers
4768        // are responsible for deduplication if this matters for their use case.
4769        let balances = vec![
4770            make_balance("BTC", "1.0", "0.0"),
4771            make_balance("BTC", "2.0", "0.0"),
4772        ];
4773        let result = filter_and_convert_balances(balances, &[], Utc::now());
4774        assert_eq!(
4775            result.len(),
4776            2,
4777            "duplicate asset entries produce two AssetBalance entries"
4778        );
4779    }
4780
4781    // ---------------------------------------------------------------------------
4782    // convert_my_trade tests
4783    // ---------------------------------------------------------------------------
4784
4785    fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
4786        binance_sdk::spot::rest_api::MyTradesResponseInner {
4787            id: Some(9001),
4788            order_id: Some(4242),
4789            price: Some("50000.00".to_string()),
4790            qty: Some("0.01".to_string()),
4791            commission: Some("0.05".to_string()),
4792            time: Some(1_700_000_000_000),
4793            is_buyer: Some(true),
4794            ..Default::default()
4795        }
4796    }
4797
4798    #[test]
4799    fn test_convert_my_trade_happy_path() {
4800        let instrument = InstrumentNameExchange::new("BTCUSDT");
4801        let trade =
4802            convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
4803        assert_eq!(trade.instrument, instrument);
4804        assert_eq!(trade.side, Side::Buy);
4805        assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
4806        assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
4807    }
4808
4809    /// A trade is at its `time`, the venue's; without one it is kept, at this host's time, and
4810    /// says so.
4811    #[test]
4812    fn a_rest_trade_without_a_time_is_at_this_hosts_time() {
4813        let instrument = InstrumentNameExchange::new("BTCUSDT");
4814        let trade = convert_my_trade(&make_base_trade(), &instrument).unwrap();
4815        assert_eq!(
4816            (trade.time_exchange, trade.time_source),
4817            (
4818                Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
4819                TimeSource::Venue
4820            )
4821        );
4822        let before = Utc::now();
4823        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4824            time: None,
4825            ..make_base_trade()
4826        };
4827        let trade = convert_my_trade(&t, &instrument).unwrap();
4828        assert_eq!(trade.time_source, TimeSource::Host);
4829        assert!((before..=Utc::now()).contains(&trade.time_exchange));
4830    }
4831
4832    #[test]
4833    fn test_convert_my_trade_sell_side() {
4834        let instrument = InstrumentNameExchange::new("BTCUSDT");
4835        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4836            is_buyer: Some(false),
4837            ..make_base_trade()
4838        };
4839        let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
4840        assert_eq!(trade.side, Side::Sell);
4841    }
4842
4843    #[test]
4844    fn test_convert_my_trade_missing_id_returns_none() {
4845        let instrument = InstrumentNameExchange::new("BTCUSDT");
4846        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4847            id: None,
4848            ..make_base_trade()
4849        };
4850        assert!(
4851            convert_my_trade(&t, &instrument).is_none(),
4852            "missing id should return None"
4853        );
4854    }
4855
4856    #[test]
4857    fn test_convert_my_trade_missing_order_id_returns_none() {
4858        let instrument = InstrumentNameExchange::new("BTCUSDT");
4859        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4860            order_id: None,
4861            ..make_base_trade()
4862        };
4863        assert!(
4864            convert_my_trade(&t, &instrument).is_none(),
4865            "missing orderId should return None"
4866        );
4867    }
4868
4869    #[test]
4870    fn test_convert_my_trade_missing_is_buyer_returns_none() {
4871        let instrument = InstrumentNameExchange::new("BTCUSDT");
4872        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4873            is_buyer: None,
4874            ..make_base_trade()
4875        };
4876        assert!(
4877            convert_my_trade(&t, &instrument).is_none(),
4878            "missing isBuyer should return None"
4879        );
4880    }
4881
4882    #[test]
4883    fn test_convert_my_trade_commission_none_defaults_to_zero() {
4884        let instrument = InstrumentNameExchange::new("BTCUSDT");
4885        let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4886            commission: None,
4887            ..make_base_trade()
4888        };
4889        let trade =
4890            convert_my_trade(&t, &instrument).expect("None commission should still convert");
4891        assert_eq!(trade.fees.fees, Decimal::ZERO);
4892    }
4893
4894    // ---------------------------------------------------------------------------
4895    // convert_open_order tests
4896    // ---------------------------------------------------------------------------
4897
4898    fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4899        binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4900            order_id: Some(12345),
4901            client_order_id: Some("cid-abc".to_string()),
4902            side: Some("BUY".to_string()),
4903            r#type: Some("LIMIT".to_string()),
4904            price: Some("50000.00".to_string()),
4905            orig_qty: Some("0.01".to_string()),
4906            executed_qty: Some("0.0".to_string()),
4907            time_in_force: Some("GTC".to_string()),
4908            time: Some(1_700_000_000_000),
4909            status: Some("NEW".to_string()),
4910            ..Default::default()
4911        }
4912    }
4913
4914    #[test]
4915    fn test_convert_open_order_all_orders_response_converts_identically() {
4916        // `convert_open_order` is generic over `BinanceOrderFields`; the live open-orders path feeds
4917        // it `GetOpenOrdersResponseInner` (what every other test here uses) while `allOrders`
4918        // would feed it `AllOrdersResponseInner`. Pin that the second impl reads the same fields,
4919        // so the two endpoint structs cannot drift apart unnoticed. This guards field drift only:
4920        // an `allOrders` row for a finished order must not convert, which
4921        // `test_convert_open_order_refuses_an_all_orders_row_that_is_not_live` pins.
4922        let instrument = InstrumentNameExchange::new("BTCUSDT");
4923        let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4924            order_id: Some(12345),
4925            client_order_id: Some("cid-abc".to_string()),
4926            side: Some("BUY".to_string()),
4927            r#type: Some("LIMIT".to_string()),
4928            price: Some("50000.00".to_string()),
4929            orig_qty: Some("0.01".to_string()),
4930            executed_qty: Some("0.0".to_string()),
4931            time_in_force: Some("GTC".to_string()),
4932            time: Some(1_700_000_000_000),
4933            status: Some("NEW".to_string()),
4934            ..Default::default()
4935        };
4936        let from_all_orders =
4937            convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument, &|_| None)
4938                .expect("valid order should convert");
4939        let from_open_orders = convert_open_order(
4940            &make_base_open_order(),
4941            ExchangeId::BinanceSpot,
4942            &instrument,
4943            &|_| None,
4944        )
4945        .expect("valid order should convert");
4946        assert_eq!(from_all_orders, from_open_orders);
4947    }
4948
4949    /// An `allOrders` row for an order that is no longer live must not become an `Open` order.
4950    ///
4951    /// The case that matters is a cancelled order that had partly filled: as `Open` it would rest
4952    /// in engine state with quantity remaining, and nothing at the exchange would ever fill or
4953    /// cancel it.
4954    #[test]
4955    fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
4956        let instrument = InstrumentNameExchange::new("BTCUSDT");
4957        for status in [
4958            "CANCELED",
4959            "FILLED",
4960            "EXPIRED",
4961            "EXPIRED_IN_MATCH",
4962            "REJECTED",
4963            "PENDING_CANCEL",
4964            "SOME_FUTURE_STATUS",
4965        ] {
4966            let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4967                order_id: Some(12345),
4968                client_order_id: Some("cid-abc".to_string()),
4969                side: Some("BUY".to_string()),
4970                r#type: Some("LIMIT".to_string()),
4971                price: Some("50000.00".to_string()),
4972                orig_qty: Some("0.01".to_string()),
4973                executed_qty: Some("0.004".to_string()),
4974                time_in_force: Some("GTC".to_string()),
4975                time: Some(1_700_000_000_000),
4976                status: Some(status.to_string()),
4977                ..Default::default()
4978            };
4979            assert_eq!(
4980                convert_open_order(&row, ExchangeId::BinanceSpot, &instrument, &|_| None),
4981                None,
4982                "{status}"
4983            );
4984        }
4985    }
4986
4987    #[test]
4988    fn test_convert_open_order_admits_every_live_status() {
4989        let instrument = InstrumentNameExchange::new("BTCUSDT");
4990        for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
4991            let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4992                status: Some(status.to_string()),
4993                ..make_base_open_order()
4994            };
4995            assert!(
4996                convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_some(),
4997                "{status}"
4998            );
4999        }
5000    }
5001
5002    /// A listing is complete only while it shows every row under the id its order was placed with.
5003    ///
5004    /// The engine retires an order a complete snapshot leaves out, so a row that is dropped, or kept
5005    /// under its `orderId` alone, must make the listing incomplete: either can hide a live order.
5006    #[test]
5007    fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
5008        let instrument = InstrumentNameExchange::new("BTCUSDT");
5009        let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5010            order_id: Some(67890),
5011            client_order_id: Some("cid-def".to_string()),
5012            ..make_base_open_order()
5013        };
5014
5015        let listing = convert_open_order_listing(
5016            &[make_base_open_order(), second()],
5017            ExchangeId::BinanceSpot,
5018            &instrument,
5019            &|_| None,
5020        );
5021        assert_eq!(listing.orders.len(), 2);
5022        assert!(listing.complete);
5023
5024        let empty = convert_open_order_listing::<
5025            binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
5026        >(&[], ExchangeId::BinanceSpot, &instrument, &|_| None);
5027        assert!(empty.orders.is_empty());
5028        assert!(empty.complete, "no open orders is a complete answer");
5029
5030        let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5031            side: None,
5032            ..second()
5033        };
5034        let listing = convert_open_order_listing(
5035            &[make_base_open_order(), dropped],
5036            ExchangeId::BinanceSpot,
5037            &instrument,
5038            &|_| None,
5039        );
5040        assert_eq!(listing.orders.len(), 1);
5041        assert!(!listing.complete, "a dropped row may be a live order");
5042
5043        let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5044            client_order_id: None,
5045            ..second()
5046        };
5047        let listing = convert_open_order_listing(
5048            &[make_base_open_order(), without_cid],
5049            ExchangeId::BinanceSpot,
5050            &instrument,
5051            &|_| None,
5052        );
5053        assert_eq!(listing.orders.len(), 2, "the row is still converted");
5054        assert!(
5055            !listing.complete,
5056            "an order kept under its orderId cannot be found under the cid it was placed with"
5057        );
5058    }
5059
5060    #[test]
5061    fn test_convert_open_order_missing_status_returns_none() {
5062        let instrument = InstrumentNameExchange::new("BTCUSDT");
5063        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5064            status: None,
5065            ..make_base_open_order()
5066        };
5067        assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_none());
5068    }
5069
5070    /// A REST order snapshot is stamped with when the order last changed, not when it was created.
5071    ///
5072    /// The engine discards an `Open` snapshot older than the state it already tracks, so a
5073    /// snapshot carrying `time` is thrown away as soon as a WebSocket fill has advanced the
5074    /// tracked order past creation -- the exact order a reconciliation fetch is meant to repair.
5075    #[test]
5076    fn test_convert_open_order_prefers_update_time_over_creation_time() {
5077        let instrument = InstrumentNameExchange::new("BTCUSDT");
5078
5079        let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5080            update_time: Some(1_700_000_005_000),
5081            ..make_base_open_order()
5082        };
5083        let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument, &|_| None)
5084            .expect("valid order should convert");
5085        assert_eq!(
5086            order.state.time_exchange,
5087            Utc.timestamp_millis_opt(1_700_000_005_000)
5088                .single()
5089                .unwrap(),
5090            "update_time must win over time"
5091        );
5092
5093        // Falls back to `time` when the venue omits `update_time`, rather than to `now` -- which
5094        // would be worse than creation time, since it is not a venue-reported instant at all.
5095        let order = convert_open_order(
5096            &make_base_open_order(),
5097            ExchangeId::BinanceSpot,
5098            &instrument,
5099            &|_| None,
5100        )
5101        .expect("valid order should convert");
5102        assert_eq!(
5103            order.state.time_exchange,
5104            Utc.timestamp_millis_opt(1_700_000_000_000)
5105                .single()
5106                .unwrap(),
5107            "absent update_time falls back to time"
5108        );
5109    }
5110
5111    #[test]
5112    fn test_convert_open_order_happy_path() {
5113        let instrument = InstrumentNameExchange::new("BTCUSDT");
5114        let order = convert_open_order(
5115            &make_base_open_order(),
5116            ExchangeId::BinanceSpot,
5117            &instrument,
5118            &|_| None,
5119        )
5120        .expect("valid order should convert");
5121        assert_eq!(order.key.instrument, instrument);
5122        assert_eq!(order.side, Side::Buy);
5123        assert_eq!(order.kind, OrderKind::Limit);
5124        assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
5125        assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
5126        assert_eq!(order.state.filled_quantity, Decimal::ZERO);
5127    }
5128
5129    #[test]
5130    fn test_convert_open_order_owned_symbol_recovers_instrument() {
5131        // The no-symbol "return all" path derives the instrument from each order's own `symbol`.
5132        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5133            symbol: Some("ETHUSDT".to_string()),
5134            ..make_base_open_order()
5135        };
5136        let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot, &|_| None)
5137            .expect("valid order should convert");
5138        assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
5139        assert_eq!(order.side, Side::Buy);
5140    }
5141
5142    #[test]
5143    fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
5144        // make_base_open_order() leaves `symbol` unset — drop rather than guess the instrument.
5145        let o = make_base_open_order();
5146        assert!(o.symbol.is_none());
5147        assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot, &|_| None).is_none());
5148    }
5149
5150    #[test]
5151    fn test_convert_open_order_missing_order_id_returns_none() {
5152        let instrument = InstrumentNameExchange::new("BTCUSDT");
5153        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5154            order_id: None,
5155            ..make_base_open_order()
5156        };
5157        assert!(
5158            convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_none(),
5159            "missing orderId should return None"
5160        );
5161    }
5162
5163    #[test]
5164    fn test_convert_open_order_missing_side_returns_none() {
5165        let instrument = InstrumentNameExchange::new("BTCUSDT");
5166        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5167            side: None,
5168            ..make_base_open_order()
5169        };
5170        assert!(
5171            convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_none(),
5172            "missing side should return None"
5173        );
5174    }
5175
5176    #[test]
5177    fn test_convert_open_order_missing_type_returns_none() {
5178        let instrument = InstrumentNameExchange::new("BTCUSDT");
5179        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5180            r#type: None,
5181            ..make_base_open_order()
5182        };
5183        assert!(
5184            convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_none(),
5185            "missing type should return None"
5186        );
5187    }
5188
5189    #[test]
5190    fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
5191        let instrument = InstrumentNameExchange::new("BTCUSDT");
5192        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5193            executed_qty: None,
5194            ..make_base_open_order()
5195        };
5196        let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None)
5197            .expect("None executedQty should still convert");
5198        assert_eq!(
5199            order.state.filled_quantity,
5200            Decimal::ZERO,
5201            "None executedQty should default to zero"
5202        );
5203    }
5204
5205    #[test]
5206    fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
5207        let instrument = InstrumentNameExchange::new("BTCUSDT");
5208        let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5209            executed_qty: Some("bad-value".to_string()),
5210            ..make_base_open_order()
5211        };
5212        let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None)
5213            .expect("unparseable executedQty should still convert");
5214        assert_eq!(
5215            order.state.filled_quantity,
5216            Decimal::ZERO,
5217            "unparseable executedQty should default to zero"
5218        );
5219    }
5220
5221    // ---------------------------------------------------------------------------
5222    // dedup_key_from_event — non-Open active state paths
5223    // ---------------------------------------------------------------------------
5224
5225    #[test]
5226    fn test_dedup_key_from_event_non_open_states_return_none() {
5227        use crate::order::state::{
5228            ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
5229        };
5230        use rustrade_integration::collection::snapshot::Snapshot;
5231
5232        let key = OrderKey::new(
5233            ExchangeId::BinanceSpot,
5234            InstrumentNameExchange::new("BTCUSDT"),
5235            StrategyId::unknown(),
5236            ClientOrderId::new("cid1"),
5237        );
5238
5239        // OpenInFlight → None (order is not yet acknowledged by exchange)
5240        let event = UnindexedAccountEvent::new(
5241            ExchangeId::BinanceSpot,
5242            AccountEventKind::OrderSnapshot(Snapshot(Order::new(
5243                key.clone(),
5244                Side::Buy,
5245                None, // Market orders have no limit price
5246                Decimal::ZERO,
5247                OrderKind::Market,
5248                TimeInForce::ImmediateOrCancel,
5249                OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
5250                    ActiveOrderState::OpenInFlight(OpenInFlight::new(Utc::now())),
5251                ),
5252            ))),
5253        );
5254        assert!(
5255            dedup_key_from_event(&event).is_none(),
5256            "OpenInFlight should return None — dedup not meaningful before exchange ack"
5257        );
5258
5259        // CancelInFlight → None
5260        let event = UnindexedAccountEvent::new(
5261            ExchangeId::BinanceSpot,
5262            AccountEventKind::OrderSnapshot(Snapshot(Order::new(
5263                key.clone(),
5264                Side::Buy,
5265                None, // Market orders have no limit price
5266                Decimal::ZERO,
5267                OrderKind::Market,
5268                TimeInForce::ImmediateOrCancel,
5269                OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
5270                    ActiveOrderState::CancelInFlight(CancelInFlight::new(None, Utc::now())),
5271                ),
5272            ))),
5273        );
5274        assert!(
5275            dedup_key_from_event(&event).is_none(),
5276            "CancelInFlight should return None"
5277        );
5278
5279        // Inactive(FullyFilled) → None
5280        let event = UnindexedAccountEvent::new(
5281            ExchangeId::BinanceSpot,
5282            AccountEventKind::OrderSnapshot(Snapshot(Order::new(
5283                key,
5284                Side::Buy,
5285                None, // Market orders have no limit price
5286                Decimal::ONE,
5287                OrderKind::Market,
5288                TimeInForce::ImmediateOrCancel,
5289                OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
5290                    InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
5291                        OrderId::new("123"),
5292                        Utc::now(),
5293                        Decimal::ONE, // FullyFilled must have non-zero filled_quantity
5294                        None,
5295                    )),
5296                ),
5297            ))),
5298        );
5299        assert!(
5300            dedup_key_from_event(&event).is_none(),
5301            "Inactive state should return None"
5302        );
5303    }
5304
5305    #[test]
5306    fn test_dedup_key_from_event_cancelled_error_returns_none() {
5307        // A REJECTED execution report produces OrderCancelled with Err state.
5308        // Verify that dedup_key_from_event returns None for such events (no dedup needed).
5309        let report = ExecutionReport {
5310            execution_type: Some("REJECTED".to_string()),
5311            reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
5312            ..make_base_report()
5313        };
5314        let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
5315        assert!(
5316            matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
5317            "prerequisite: event is OrderCancelled with Err"
5318        );
5319        assert!(
5320            dedup_key_from_event(&event).is_none(),
5321            "OrderCancelled with Err state should return None"
5322        );
5323    }
5324
5325    // ---------------------------------------------------------------------------
5326    // convert_user_data_events tests
5327    // ---------------------------------------------------------------------------
5328
5329    /// Build a raw user-data wire frame from an SDK event struct.
5330    ///
5331    /// The SDK event structs carry only the `E` (event time) field, not the lowercase `e`
5332    /// discriminator, so the tag must be injected. The event is then wrapped as the WS-API
5333    /// subscription delivers it, `{ "subscriptionId": 0, "event": { "e": "<type>", .. } }`.
5334    fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
5335        let mut value = serde_json::to_value(event).expect("event serializes to Value");
5336        value
5337            .as_object_mut()
5338            .expect("event serializes to a JSON object")
5339            .insert(
5340                "e".to_string(),
5341                serde_json::Value::String(event_type.to_string()),
5342            );
5343        serde_json::json!({ "subscriptionId": 0, "event": value }).to_string()
5344    }
5345
5346    #[test]
5347    fn test_convert_user_data_events_execution_report_pushes_to_buf() {
5348        let report = ExecutionReport {
5349            execution_type: Some("NEW".to_string()),
5350            side: Some("BUY".to_string()),
5351            order_type: Some("LIMIT".to_string()),
5352            price: Some("50000.00".to_string()),
5353            order_quantity: Some("0.01".to_string()),
5354            time_in_force: Some("GTC".to_string()),
5355            cumulative_filled_quantity: Some("0".to_string()),
5356            ..make_base_report()
5357        };
5358        let frame = user_data_frame("executionReport", &report);
5359        let mut buf = Vec::new();
5360        let terminated = convert_user_data_events(&frame, &mut buf).terminated;
5361        assert!(
5362            !terminated,
5363            "ExecutionReport should not signal stream termination"
5364        );
5365        assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
5366        assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
5367    }
5368
5369    #[test]
5370    fn test_convert_user_data_events_account_position_pushes_to_buf() {
5371        let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
5372            u: Some(1_700_000_000_000),
5373            b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
5374            ..Default::default()
5375        };
5376        let frame = user_data_frame("outboundAccountPosition", &position);
5377        let mut buf = Vec::new();
5378        let terminated = convert_user_data_events(&frame, &mut buf).terminated;
5379        assert!(
5380            !terminated,
5381            "OutboundAccountPosition should not signal stream termination"
5382        );
5383        assert_eq!(
5384            buf.len(),
5385            1,
5386            "OutboundAccountPosition should push one balance event"
5387        );
5388    }
5389
5390    #[test]
5391    fn test_convert_user_data_events_balance_update_ignored() {
5392        let update = binance_sdk::spot::websocket_api::BalanceUpdate {
5393            ..Default::default()
5394        };
5395        let frame = user_data_frame("balanceUpdate", &update);
5396        let mut buf = Vec::new();
5397        let terminated = convert_user_data_events(&frame, &mut buf).terminated;
5398        assert!(
5399            !terminated,
5400            "BalanceUpdate should not signal stream termination"
5401        );
5402        assert!(buf.is_empty(), "BalanceUpdate should push no events");
5403    }
5404
5405    #[test]
5406    fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
5407        // No payload struct — the terminal event is just the discriminator and its time.
5408        let frame =
5409            r#"{"subscriptionId":0,"event":{"e":"eventStreamTerminated","E":1700000000000}}"#;
5410        let mut buf = Vec::new();
5411        let terminated = convert_user_data_events(frame, &mut buf).terminated;
5412        assert!(
5413            terminated,
5414            "eventStreamTerminated must signal stream termination"
5415        );
5416        assert!(
5417            buf.is_empty(),
5418            "eventStreamTerminated should push no events"
5419        );
5420    }
5421
5422    #[test]
5423    fn test_convert_user_data_events_unknown_event_ignored() {
5424        // Knowingly ignored types (listStatus, externalLockUpdate) and types with no arm at all
5425        // (logged by log_unhandled_event): no events pushed, stream not terminated.
5426        for e in ["listStatus", "externalLockUpdate", "someFutureEvent"] {
5427            let frame = format!(
5428                r#"{{"subscriptionId":0,"event":{{"e":"{e}","E":1700000000000,"s":"BTCUSDT"}}}}"#
5429            );
5430            let mut buf = Vec::new();
5431            let terminated = convert_user_data_events(&frame, &mut buf).terminated;
5432            assert!(!terminated, "{e} must not signal termination");
5433            assert!(buf.is_empty(), "{e} should push no events");
5434        }
5435    }
5436
5437    /// Regression: the WS-API subscription wraps every event, and binance-sdk passes the frame on
5438    /// unchanged. The converter used to read `e` from the top level, found none in the envelope,
5439    /// and dropped every event without a log. An enveloped fill must reach the buffer, and a frame
5440    /// that is not an envelope must not be read as one.
5441    #[test]
5442    fn test_convert_user_data_events_reads_the_ws_api_envelope() {
5443        let fill = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000001000,
5444            "s":"BTCUSDT","c":"client-1","S":"SELL","o":"MARKET","f":"GTC","q":"0.5","p":"0",
5445            "x":"TRADE","X":"FILLED","i":777,"l":"0.5","z":"0.5","L":"40000","n":"0.02",
5446            "N":"USDT","T":1700000001000,"t":888}}"#;
5447        let mut buf = Vec::new();
5448        assert!(!convert_user_data_events(fill, &mut buf).terminated);
5449        assert!(
5450            buf.iter()
5451                .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
5452            "the enveloped fill must produce its trade, got: {buf:?}"
5453        );
5454
5455        // The pre-fix fixture shape: a bare event with no envelope is not an event frame.
5456        let bare = r#"{"e":"executionReport","s":"BTCUSDT","i":777,"x":"NEW","X":"NEW"}"#;
5457        buf.clear();
5458        assert!(!convert_user_data_events(bare, &mut buf).terminated);
5459        assert!(buf.is_empty());
5460    }
5461
5462    /// Every pushed event's `E` is returned to sample the stream's clock, an order's as much as
5463    /// a fill's, so the clock has a sample before the stream's first fill.
5464    #[test]
5465    fn test_convert_user_data_events_returns_the_events_time() {
5466        let new_order = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000002000,
5467            "s":"BTCUSDT","c":"client-1","S":"BUY","o":"LIMIT","f":"GTC","q":"0.5","p":"40000",
5468            "x":"NEW","X":"NEW","i":777,"l":"0","z":"0","L":"0","n":"0","T":1700000001000}}"#;
5469        let mut buf = Vec::new();
5470        let outcome = convert_user_data_events(new_order, &mut buf);
5471        assert_eq!(
5472            outcome.event_time,
5473            DateTime::<Utc>::from_timestamp_millis(1_700_000_002_000)
5474        );
5475
5476        let ack = r#"{"id":"abc","status":200,"result":[]}"#;
5477        assert_eq!(convert_user_data_events(ack, &mut buf).event_time, None);
5478    }
5479
5480    #[test]
5481    fn test_convert_user_data_events_non_user_data_frame_ignored() {
5482        // RPC responses such as the subscribe ack carry a top-level `id` — ignored, not mis-parsed.
5483        let frame = r#"{"id":"abc","status":200,"result":[]}"#;
5484        let mut buf = Vec::new();
5485        let terminated = convert_user_data_events(frame, &mut buf).terminated;
5486        assert!(
5487            !terminated,
5488            "non-user-data frame must not signal termination"
5489        );
5490        assert!(buf.is_empty(), "non-user-data frame should push no events");
5491    }
5492
5493    // ---------------------------------------------------------------------------
5494    // RateLimitTracker tests
5495    // ---------------------------------------------------------------------------
5496
5497    #[tokio::test]
5498    async fn test_rate_limit_tracker_not_blocked_initially() {
5499        tokio::time::pause();
5500        let tracker = RateLimitTracker::new(WeightPool::Spot);
5501        // wait_if_blocked should return immediately (no cooldown set)
5502        tokio::time::timeout(
5503            std::time::Duration::from_millis(1),
5504            tracker.wait_if_blocked(RequestKind::Order),
5505        )
5506        .await
5507        .expect("wait_if_blocked should return immediately when not blocked")
5508        .expect("no cooldown too long to sleep through");
5509    }
5510
5511    #[tokio::test]
5512    async fn test_rate_limit_tracker_blocks_until_deadline() {
5513        tokio::time::pause();
5514        let tracker = RateLimitTracker::new(WeightPool::Spot);
5515        let delay = Duration::from_secs(5);
5516        tracker.on_rate_limited(Some(delay));
5517
5518        // Should not complete immediately
5519        assert!(
5520            tokio::time::timeout(
5521                Duration::from_millis(1),
5522                tracker.wait_if_blocked(RequestKind::Order)
5523            )
5524            .await
5525            .is_err(),
5526            "wait_if_blocked should block while cooldown is active"
5527        );
5528
5529        // Advance past cooldown
5530        tokio::time::advance(delay + Duration::from_millis(1)).await;
5531        tokio::time::timeout(
5532            Duration::from_millis(1),
5533            tracker.wait_if_blocked(RequestKind::Order),
5534        )
5535        .await
5536        .expect("wait_if_blocked should return after cooldown expires")
5537        .expect("no cooldown too long to sleep through");
5538    }
5539
5540    #[tokio::test]
5541    async fn test_rate_limit_tracker_cooldown_extends_to_max() {
5542        tokio::time::pause();
5543        let tracker = RateLimitTracker::new(WeightPool::Spot);
5544
5545        // Set initial 5s cooldown
5546        tracker.on_rate_limited(Some(Duration::from_secs(5)));
5547        // Extend with a longer 10s cooldown — deadline should be pushed out
5548        tracker.on_rate_limited(Some(Duration::from_secs(10)));
5549
5550        // Advance past the initial 5s — should still be blocked
5551        tokio::time::advance(Duration::from_secs(6)).await;
5552        assert!(
5553            tokio::time::timeout(
5554                Duration::from_millis(1),
5555                tracker.wait_if_blocked(RequestKind::Order)
5556            )
5557            .await
5558            .is_err(),
5559            "cooldown should have been extended to 10s"
5560        );
5561
5562        // Advance past the extended 10s deadline
5563        tokio::time::advance(Duration::from_secs(5)).await;
5564        tokio::time::timeout(
5565            Duration::from_millis(1),
5566            tracker.wait_if_blocked(RequestKind::Order),
5567        )
5568        .await
5569        .expect("wait_if_blocked should return after extended cooldown expires")
5570        .expect("no cooldown too long to sleep through");
5571    }
5572
5573    #[tokio::test]
5574    async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
5575        tokio::time::pause();
5576        let tracker = RateLimitTracker::new(WeightPool::Spot);
5577
5578        // Set 10s cooldown then try to shorten with 2s — deadline should stay at 10s
5579        tracker.on_rate_limited(Some(Duration::from_secs(10)));
5580        tracker.on_rate_limited(Some(Duration::from_secs(2)));
5581
5582        // Advance past 2s — should still be blocked
5583        tokio::time::advance(Duration::from_secs(3)).await;
5584        assert!(
5585            tokio::time::timeout(
5586                Duration::from_millis(1),
5587                tracker.wait_if_blocked(RequestKind::Order)
5588            )
5589            .await
5590            .is_err(),
5591            "shorter on_rate_limited must not shorten existing cooldown"
5592        );
5593    }
5594
5595    // ---------------------------------------------------------------------------
5596    // L1: classify_ws_order_error with a context-wrapped error chain
5597    // ---------------------------------------------------------------------------
5598
5599    #[test]
5600    fn test_classify_ws_order_error_with_wrapped_error_chain() {
5601        // `classify_ws_order_error` uses `anyhow::Error::downcast_ref`, which searches
5602        // the *entire* error chain (not just the root). This test verifies that a
5603        // ResponseError wrapped in anyhow context layers is still detected correctly,
5604        // so SDK-internal context wrapping does not break the rejection check.
5605        let instrument = InstrumentNameExchange::new("BTCUSDT");
5606        let raw = anyhow::anyhow!(WebsocketError::ResponseError {
5607            code: -2010,
5608            message: "insufficient balance".into(),
5609            retry_after: None,
5610        });
5611        // Unwrapped root: must be detected
5612        assert!(
5613            classify_ws_order_error(&raw, &instrument).is_some(),
5614            "unwrapped ResponseError at root must be detected"
5615        );
5616        // Context-wrapped: anyhow::downcast_ref searches the full chain, so this is also detected
5617        let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
5618        assert!(
5619            classify_ws_order_error(&wrapped, &instrument).is_some(),
5620            "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
5621        );
5622    }
5623
5624    /// A spot REST response reporting `x-mbx-used-weight-1m` at 90% of the default limit pauses
5625    /// later queries but not orders; under it, nothing pauses.
5626    #[tokio::test]
5627    async fn rest_used_weight_near_the_limit_pauses_queries() {
5628        for (used, pauses) in [(5_399, false), (5_400, true)] {
5629            let server = wiremock::MockServer::start().await;
5630            wiremock::Mock::given(wiremock::matchers::method("GET"))
5631                .and(wiremock::matchers::path("/api/v3/openOrders"))
5632                .respond_with(
5633                    wiremock::ResponseTemplate::new(200)
5634                        .insert_header("x-mbx-used-weight-1m", used.to_string())
5635                        .set_body_json(serde_json::json!([])),
5636                )
5637                .mount(&server)
5638                .await;
5639            let rest = Arc::new(SpotRestApi::from_config(
5640                ConfigurationRestApi::builder()
5641                    .api_key("key")
5642                    .api_secret("secret")
5643                    .base_path(server.uri())
5644                    .build()
5645                    .unwrap(),
5646            ));
5647            let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5648
5649            fetch_all_open_orders(rest, Arc::clone(&tracker))
5650                .await
5651                .unwrap();
5652
5653            let query_waits = tokio::time::timeout(
5654                Duration::from_millis(50),
5655                tracker.wait_if_blocked(RequestKind::Query),
5656            )
5657            .await
5658            .is_err();
5659            assert_eq!(query_waits, pauses, "used weight {used}");
5660            tokio::time::timeout(
5661                Duration::from_millis(50),
5662                tracker.wait_if_blocked(RequestKind::Order),
5663            )
5664            .await
5665            .expect("orders never wait on the pause")
5666            .expect("no cooldown too long to sleep through");
5667        }
5668    }
5669
5670    /// A spot client whose WS-API order session and account stream connect to `ws_url`, and which
5671    /// reaches no Binance REST API: its pre-send rules are off, so an open reads no
5672    /// `exchangeInfo`, and its REST calls go to a closed local port, so a stream's reconnect reads
5673    /// its fills and balances from nowhere unless a test points `rest` at a mock.
5674    fn client_with_ws_api(ws_url: String) -> BinanceSpot {
5675        let mut client = <BinanceSpot as ExecutionClient>::new(
5676            BinanceSpotConfig::new("key".into(), "secret".into())
5677                .with_server_time_sync(false)
5678                .with_pre_send_rules(PreSendRules::Off),
5679        );
5680        client.rest = Arc::new(SpotRestApi::from_config(
5681            ConfigurationRestApi::builder()
5682                .api_key("key")
5683                .api_secret("secret")
5684                .base_path("http://127.0.0.1:9")
5685                .build()
5686                .unwrap(),
5687        ));
5688        client.ws_handle = SpotWsApi::from_config(
5689            ConfigurationWebsocketApi::builder()
5690                .api_key("key")
5691                .api_secret("secret")
5692                .ws_url(ws_url)
5693                .build()
5694                .unwrap(),
5695        );
5696        client
5697    }
5698
5699    /// A cancel by client order ID for `instrument`.
5700    fn cancel_request(
5701        instrument: &InstrumentNameExchange,
5702    ) -> OrderRequestCancel<ExchangeId, &InstrumentNameExchange> {
5703        crate::order::OrderEvent {
5704            key: OrderKey {
5705                exchange: ExchangeId::BinanceSpot,
5706                instrument,
5707                strategy: StrategyId::new("strategy"),
5708                cid: ClientOrderId::new("cid"),
5709            },
5710            state: crate::order::request::RequestCancel { id: None },
5711        }
5712    }
5713
5714    /// A local WebSocket server: every connection is handed to `serve`. Returns its `ws://` URL.
5715    async fn ws_server<F, Fut>(serve: F) -> String
5716    where
5717        F: Fn(tokio::net::TcpStream) -> Fut + Send + 'static,
5718        Fut: std::future::Future<Output = ()> + Send + 'static,
5719    {
5720        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5721        let url = format!("ws://{}", listener.local_addr().unwrap());
5722        tokio::spawn(async move {
5723            while let Ok((stream, _)) = listener.accept().await {
5724                tokio::spawn(serve(stream));
5725            }
5726        });
5727        url
5728    }
5729
5730    /// During a rate-limit cooldown a cancel with no WS-API session is not sent: it fails as a
5731    /// rate-limit rejection, without a handshake.
5732    #[tokio::test]
5733    async fn cancel_without_a_session_during_a_cooldown_is_rejected_unsent() {
5734        let client = <BinanceSpot as ExecutionClient>::new(
5735            BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
5736        );
5737        client
5738            .rate_limiter
5739            .on_rate_limited(Some(Duration::from_secs(60)));
5740        let instrument = InstrumentNameExchange::new("BTCUSDT");
5741        let response = client.cancel_order(cancel_request(&instrument)).await;
5742
5743        assert_rate_limited_for(&response.state, Duration::from_secs(60));
5744        assert!(
5745            client.ws_api.read().await.is_none(),
5746            "no session was opened"
5747        );
5748    }
5749
5750    /// Asserts `state` is a rate-limit rejection whose wait is at most `wait`, and no more than
5751    /// a few seconds under it (the time the test took).
5752    fn assert_rate_limited_for<T: std::fmt::Debug>(
5753        state: &Result<T, UnindexedOrderError>,
5754        wait: Duration,
5755    ) {
5756        let retry_after = match state {
5757            Err(UnindexedOrderError::Rejected(api @ ApiError::RateLimit { .. })) => {
5758                api.retry_after()
5759            }
5760            other => panic!("expected a rate-limit rejection, got {other:?}"),
5761        };
5762        assert!(
5763            retry_after.is_some_and(|got| got <= wait && got > wait - Duration::from_secs(5)),
5764            "expected a wait of about {wait:?}, got {retry_after:?}"
5765        );
5766    }
5767
5768    /// A local WS-API server answering every request with `answer`, given the request's `id`.
5769    /// Returns its URL and the count of requests it has answered.
5770    async fn ws_api_answering(
5771        answer: fn(&serde_json::Value) -> serde_json::Value,
5772    ) -> (String, Arc<std::sync::atomic::AtomicU32>) {
5773        use futures::{SinkExt as _, StreamExt as _};
5774        use tokio_tungstenite::tungstenite::Message;
5775
5776        let requests = Arc::new(std::sync::atomic::AtomicU32::new(0));
5777        let url = ws_server({
5778            let requests = Arc::clone(&requests);
5779            move |stream| {
5780                let requests = Arc::clone(&requests);
5781                async move {
5782                    let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5783                        return;
5784                    };
5785                    while let Some(Ok(message)) = ws.next().await {
5786                        let Message::Text(text) = message else {
5787                            continue;
5788                        };
5789                        requests.fetch_add(1, Ordering::SeqCst);
5790                        let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5791                        let response = answer(&request);
5792                        if ws.send(Message::text(response.to_string())).await.is_err() {
5793                            return;
5794                        }
5795                    }
5796                }
5797            }
5798        })
5799        .await;
5800        (url, requests)
5801    }
5802
5803    /// A WS-API answer accepting a cancel.
5804    fn cancel_accepted(request: &serde_json::Value) -> serde_json::Value {
5805        serde_json::json!({
5806            "id": request["id"],
5807            "status": 200,
5808            "result": {
5809                "symbol": "BTCUSDT",
5810                "origClientOrderId": "cid",
5811                "orderId": 7,
5812                "transactTime": 1_700_000_000_000_i64,
5813                "executedQty": "0",
5814            },
5815        })
5816    }
5817
5818    /// A cancel on an open WS-API session honours a cooldown too: one too long to sleep through
5819    /// rejects it unsent, with the time left as its wait.
5820    #[tokio::test]
5821    async fn cancel_on_an_open_session_during_a_long_cooldown_is_rejected_unsent() {
5822        let (url, requests) = ws_api_answering(cancel_accepted).await;
5823        let client = client_with_ws_api(url);
5824        let instrument = InstrumentNameExchange::new("BTCUSDT");
5825        let response = client.cancel_order(cancel_request(&instrument)).await;
5826        assert!(response.state.is_ok(), "{:?}", response.state);
5827
5828        client
5829            .rate_limiter
5830            .on_rate_limited(Some(Duration::from_secs(60)));
5831        let response = client.cancel_order(cancel_request(&instrument)).await;
5832
5833        assert_rate_limited_for(&response.state, Duration::from_secs(60));
5834        assert_eq!(
5835            requests.load(Ordering::SeqCst),
5836            1,
5837            "the second was not sent"
5838        );
5839        assert!(
5840            client.ws_api.read().await.is_some(),
5841            "the session stays open"
5842        );
5843    }
5844
5845    /// A WS-API ban (`-1003` with `retryAfter`, a Unix-millisecond time) rejects the cancel with
5846    /// the wait until then, and holds back later requests, REST and WS API alike, without
5847    /// sending them.
5848    #[tokio::test]
5849    async fn a_ws_api_ban_reports_its_wait_and_holds_back_later_requests() {
5850        let (url, requests) = ws_api_answering(banned_for_two_minutes).await;
5851        let client = client_with_ws_api(url);
5852        let instrument = InstrumentNameExchange::new("BTCUSDT");
5853
5854        let response = client.cancel_order(cancel_request(&instrument)).await;
5855        assert_rate_limited_for(&response.state, TWO_MINUTE_BAN);
5856        assert_cooldown_about(&client, TWO_MINUTE_BAN);
5857
5858        let response = client.cancel_order(cancel_request(&instrument)).await;
5859        assert_rate_limited_for(&response.state, TWO_MINUTE_BAN);
5860        assert_eq!(
5861            requests.load(Ordering::SeqCst),
5862            1,
5863            "the second was not sent"
5864        );
5865    }
5866
5867    /// The wait [`banned_for_two_minutes`] reports.
5868    const TWO_MINUTE_BAN: Duration = Duration::from_millis(120_000 + CLOCK_SKEW_SLACK_MS);
5869
5870    /// A WS-API answer refusing any request with a ban that lifts two minutes from now.
5871    fn banned_for_two_minutes(request: &serde_json::Value) -> serde_json::Value {
5872        let until = unix_ms() + 120_000;
5873        serde_json::json!({
5874            "id": request["id"],
5875            "status": 418,
5876            "error": {
5877                "code": -1003,
5878                "msg": format!("Way too much request weight used; IP banned until {until}."),
5879                "data": { "serverTime": unix_ms(), "retryAfter": until },
5880            },
5881        })
5882    }
5883
5884    /// Asserts `client`'s cooldown has at most `wait` left, and no more than a few seconds under
5885    /// it (the time the test took).
5886    fn assert_cooldown_about(client: &BinanceSpot, wait: Duration) {
5887        let left = client.rate_limiter.cooldown_remaining();
5888        assert!(
5889            left.is_some_and(|left| left <= wait && left > wait - Duration::from_secs(5)),
5890            "expected a cooldown of about {wait:?}, got {left:?}"
5891        );
5892    }
5893
5894    /// An order the WS API refuses with a ban fails with the ban's wait, and holds every later
5895    /// request back for it.
5896    #[tokio::test]
5897    async fn a_ws_api_ban_on_an_order_holds_back_later_requests() {
5898        let (url, requests) = ws_api_answering(banned_for_two_minutes).await;
5899        let client = client_with_ws_api(url);
5900        let instrument = InstrumentNameExchange::new("BTCUSDT");
5901        let request = crate::order::OrderEvent {
5902            key: OrderKey {
5903                exchange: ExchangeId::BinanceSpot,
5904                instrument: &instrument,
5905                strategy: StrategyId::new("strategy"),
5906                cid: ClientOrderId::new("cid"),
5907            },
5908            state: crate::order::request::RequestOpen::new(
5909                Side::Buy,
5910                Some(Decimal::new(50_000, 0)),
5911                Decimal::new(1, 3),
5912                OrderKind::Limit,
5913                TimeInForce::GoodUntilCancelled { post_only: false },
5914            ),
5915        };
5916
5917        let order = client.open_order(request).await;
5918
5919        let state = match order.state {
5920            OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(error)) => {
5921                Err::<(), _>(error)
5922            }
5923            other => panic!("expected the open to fail, got {other:?}"),
5924        };
5925        assert_rate_limited_for(&state, TWO_MINUTE_BAN);
5926        assert_cooldown_about(&client, TWO_MINUTE_BAN);
5927        assert_eq!(requests.load(Ordering::SeqCst), 1);
5928    }
5929
5930    /// A WS-API answer refusing any request's timestamp (`-1021`).
5931    fn refused_timestamp(request: &serde_json::Value) -> serde_json::Value {
5932        serde_json::json!({
5933            "id": request["id"],
5934            "status": 400,
5935            "error": {
5936                "code": -1021,
5937                "msg": "Timestamp for this request was 1000ms ahead of the server's time.",
5938            },
5939        })
5940    }
5941
5942    /// An order or cancel the WS API refuses with `-1021` reads Binance's server time again before
5943    /// its answer returns, so a retry is stamped on the new reading.
5944    #[tokio::test]
5945    async fn a_ws_api_refused_timestamp_reads_the_server_time_again() {
5946        let (url, _) = ws_api_answering(refused_timestamp).await;
5947        let mut client = client_with_ws_api(url);
5948        let reads = Arc::new(std::sync::atomic::AtomicU32::new(0));
5949        let read: crate::client::binance::server_time::ReadServerTime = {
5950            let reads = Arc::clone(&reads);
5951            Arc::new(move || {
5952                reads.fetch_add(1, Ordering::SeqCst);
5953                Box::pin(async { Ok(i64::try_from(unix_ms()).unwrap()) })
5954            })
5955        };
5956        client.rate_limiter = Arc::new(
5957            RateLimitTracker::new(WeightPool::Spot)
5958                .with_server_time(Some(ServerTime::new(SigningClock::default(), read))),
5959        );
5960        let instrument = InstrumentNameExchange::new("BTCUSDT");
5961        let open = crate::order::OrderEvent {
5962            key: OrderKey {
5963                exchange: ExchangeId::BinanceSpot,
5964                instrument: &instrument,
5965                strategy: StrategyId::new("strategy"),
5966                cid: ClientOrderId::new("cid"),
5967            },
5968            state: crate::order::request::RequestOpen::new(
5969                Side::Buy,
5970                Some(Decimal::new(50_000, 0)),
5971                Decimal::new(1, 3),
5972                OrderKind::Limit,
5973                TimeInForce::GoodUntilCancelled { post_only: false },
5974            ),
5975        };
5976
5977        let order = client.open_order(open).await;
5978        assert!(
5979            matches!(order.state, OrderState::Inactive(_)),
5980            "{:?}",
5981            order.state
5982        );
5983        // The first request's read, and the read after the refusal.
5984        assert_eq!(reads.load(Ordering::SeqCst), 2);
5985
5986        let cancel = client.cancel_order(cancel_request(&instrument)).await;
5987        assert!(cancel.state.is_err(), "{:?}", cancel.state);
5988        assert_eq!(reads.load(Ordering::SeqCst), 3);
5989    }
5990
5991    /// A WS-API success whose result has no `orderId`.
5992    fn answered_without_an_order_id(request: &serde_json::Value) -> serde_json::Value {
5993        serde_json::json!({
5994            "id": request["id"],
5995            "status": 200,
5996            "result": { "symbol": "BTCUSDT", "clientOrderId": "cid" },
5997        })
5998    }
5999
6000    /// A WS-API success whose result does not decode: its `orderId` is not a number.
6001    fn answered_undecodably(request: &serde_json::Value) -> serde_json::Value {
6002        serde_json::json!({
6003            "id": request["id"],
6004            "status": 200,
6005            "result": { "symbol": "BTCUSDT", "orderId": "not a number" },
6006        })
6007    }
6008
6009    /// An open the WS API answered with a success it cannot read was acted on, so its outcome is
6010    /// unknown: `Connectivity`, not a refusal.
6011    #[tokio::test]
6012    async fn an_open_answered_unreadably_is_an_unknown_outcome() {
6013        for answer in [answered_without_an_order_id, answered_undecodably] {
6014            let (url, _) = ws_api_answering(answer).await;
6015            let client = client_with_ws_api(url);
6016            let instrument = InstrumentNameExchange::new("BTCUSDT");
6017            let request = crate::order::OrderEvent {
6018                key: OrderKey {
6019                    exchange: ExchangeId::BinanceSpot,
6020                    instrument: &instrument,
6021                    strategy: StrategyId::new("strategy"),
6022                    cid: ClientOrderId::new("cid"),
6023                },
6024                state: crate::order::request::RequestOpen::new(
6025                    Side::Buy,
6026                    Some(Decimal::new(50_000, 0)),
6027                    Decimal::new(1, 3),
6028                    OrderKind::Limit,
6029                    TimeInForce::GoodUntilCancelled { post_only: false },
6030                ),
6031            };
6032
6033            let order = client.open_order(request).await;
6034
6035            assert!(
6036                matches!(
6037                    order.state,
6038                    OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6039                        OrderError::Connectivity(_)
6040                    ))
6041                ),
6042                "{:?}",
6043                order.state
6044            );
6045        }
6046    }
6047
6048    /// A cancel the WS API answered with a success it cannot read was acted on, so whether the
6049    /// order still works is unknown: `Connectivity`, not a refusal.
6050    #[tokio::test]
6051    async fn a_cancel_answered_unreadably_is_an_unknown_outcome() {
6052        for answer in [answered_without_an_order_id, answered_undecodably] {
6053            let (url, _) = ws_api_answering(answer).await;
6054            let client = client_with_ws_api(url);
6055            let instrument = InstrumentNameExchange::new("BTCUSDT");
6056
6057            let response = client.cancel_order(cancel_request(&instrument)).await;
6058
6059            assert!(
6060                matches!(response.state, Err(OrderError::Connectivity(_))),
6061                "{:?}",
6062                response.state
6063            );
6064        }
6065    }
6066
6067    /// A WS-API answer accepting a user-data subscription.
6068    fn subscribed(request: &serde_json::Value) -> serde_json::Value {
6069        serde_json::json!({
6070            "id": request["id"],
6071            "status": 200,
6072            "result": { "subscriptionId": 0 },
6073        })
6074    }
6075
6076    /// The error opening an account stream failed with, which must be a rate limit, and its wait.
6077    fn rate_limited_wait<T>(opened: Result<T, UnindexedClientError>) -> Option<Duration> {
6078        match opened {
6079            Err(UnindexedClientError::Api(api @ ApiError::RateLimit { .. })) => api.retry_after(),
6080            Err(other) => panic!("expected a rate limit, got {other:?}"),
6081            Ok(_) => panic!("expected the open to fail"),
6082        }
6083    }
6084
6085    /// Opening the account stream during a cooldown too long to sleep through fails with the time
6086    /// left, and sends nothing.
6087    #[tokio::test]
6088    async fn opening_the_account_stream_during_a_long_cooldown_fails_unsent() {
6089        let (url, requests) = ws_api_answering(subscribed).await;
6090        let client = client_with_ws_api(url);
6091        client
6092            .rate_limiter
6093            .on_rate_limited(Some(Duration::from_secs(60)));
6094
6095        let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
6096
6097        assert!(
6098            wait.is_some_and(
6099                |wait| wait <= Duration::from_secs(60) && wait > Duration::from_secs(55)
6100            ),
6101            "{wait:?}"
6102        );
6103        assert_eq!(requests.load(Ordering::SeqCst), 0);
6104    }
6105
6106    /// A user-data subscribe the WS API refuses with a ban fails with the ban's wait, and holds
6107    /// every later request back for it.
6108    #[tokio::test]
6109    async fn a_ban_on_the_account_stream_subscribe_starts_its_cooldown() {
6110        let (url, _) = ws_api_answering(banned_for_two_minutes).await;
6111        let client = client_with_ws_api(url);
6112
6113        let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
6114
6115        assert!(
6116            wait.is_some_and(
6117                |wait| wait <= TWO_MINUTE_BAN && wait > TWO_MINUTE_BAN - Duration::from_secs(5)
6118            ),
6119            "{wait:?}"
6120        );
6121        assert_cooldown_about(&client, TWO_MINUTE_BAN);
6122    }
6123
6124    /// An account-stream handshake Binance refuses with 418, an IP ban, starts a cooldown as long as
6125    /// Binance's shortest ban, and the open fails as a rate limit with that wait.
6126    #[tokio::test]
6127    async fn a_refused_account_stream_handshake_starts_a_cooldown() {
6128        use tokio_tungstenite::tungstenite::http;
6129
6130        let url = ws_server(|stream| async move {
6131            // tungstenite's handshake callback fixes the error type to a whole HTTP response.
6132            #[allow(clippy::result_large_err)]
6133            let refuse = |_: &http::Request<()>, _| {
6134                Err(http::Response::builder()
6135                    .status(http::StatusCode::IM_A_TEAPOT)
6136                    .body(None)
6137                    .unwrap())
6138            };
6139            let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
6140        })
6141        .await;
6142        let client = client_with_ws_api(url);
6143
6144        let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
6145
6146        // A 418 is an IP ban: Binance's shortest, as the SDK drops its `Retry-After`.
6147        assert!(
6148            wait.is_some_and(
6149                |wait| wait <= MIN_IP_BAN && wait > MIN_IP_BAN - Duration::from_secs(5)
6150            ),
6151            "{wait:?}"
6152        );
6153        assert_cooldown_about(&client, MIN_IP_BAN);
6154    }
6155
6156    /// A connection that drops while a long cooldown is active ends the stream at once with the
6157    /// time left, rather than reconnecting into the ban: no second subscribe is sent.
6158    #[tokio::test]
6159    async fn a_drop_during_a_long_cooldown_ends_the_account_stream_rate_limited() {
6160        use futures::{SinkExt as _, StreamExt as _};
6161        use tokio_tungstenite::tungstenite::Message;
6162
6163        let subscribes = Arc::new(std::sync::atomic::AtomicU32::new(0));
6164        let url = ws_server({
6165            let subscribes = Arc::clone(&subscribes);
6166            move |stream| {
6167                let subscribes = Arc::clone(&subscribes);
6168                async move {
6169                    let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
6170                        return;
6171                    };
6172                    // Answer the subscribe, then drop the connection once the test has started
6173                    // the cooldown.
6174                    while let Some(Ok(message)) = ws.next().await {
6175                        let Message::Text(text) = message else {
6176                            continue;
6177                        };
6178                        subscribes.fetch_add(1, Ordering::SeqCst);
6179                        let request: serde_json::Value = serde_json::from_str(&text).unwrap();
6180                        let answer = subscribed(&request).to_string();
6181                        if ws.send(Message::text(answer)).await.is_err() {
6182                            return;
6183                        }
6184                        tokio::time::sleep(Duration::from_secs(1)).await;
6185                        let _ = ws.close(None).await;
6186                        return;
6187                    }
6188                }
6189            }
6190        })
6191        .await;
6192        let client = client_with_ws_api(url);
6193        let mut stream = client.account_stream(&[], &[]).await.unwrap();
6194        client
6195            .rate_limiter
6196            .on_rate_limited(Some(Duration::from_secs(60)));
6197
6198        let event = tokio::time::timeout(Duration::from_secs(5), stream.next())
6199            .await
6200            .expect("the stream ends rate-limited within the timeout")
6201            .expect("it reports why before it ends");
6202
6203        match event.kind {
6204            AccountEventKind::StreamTerminated(StreamTerminationReason::RateLimited {
6205                retry_after,
6206            }) => assert!(
6207                retry_after <= Duration::from_secs(60) && retry_after > Duration::from_secs(55),
6208                "{retry_after:?}"
6209            ),
6210            other => panic!("expected a rate-limited termination, got {other:?}"),
6211        }
6212        assert!(stream.next().await.is_none(), "the stream has ended");
6213        assert_eq!(subscribes.load(Ordering::SeqCst), 1);
6214    }
6215
6216    /// A reconnect facing a cooldown short enough to wait through waits it out, rather than
6217    /// connecting into it once its backoff has passed.
6218    #[tokio::test]
6219    async fn a_reconnect_waits_out_a_short_cooldown() {
6220        use futures::{SinkExt as _, StreamExt as _};
6221        use tokio_tungstenite::tungstenite::Message;
6222
6223        let subscribed_at = Arc::new(parking_lot::Mutex::new(Vec::new()));
6224        let url = ws_server({
6225            let subscribed_at = Arc::clone(&subscribed_at);
6226            move |stream| {
6227                let subscribed_at = Arc::clone(&subscribed_at);
6228                async move {
6229                    let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
6230                        return;
6231                    };
6232                    while let Some(Ok(message)) = ws.next().await {
6233                        let Message::Text(text) = message else {
6234                            continue;
6235                        };
6236                        subscribed_at.lock().push(tokio::time::Instant::now());
6237                        let request: serde_json::Value = serde_json::from_str(&text).unwrap();
6238                        if ws
6239                            .send(Message::text(subscribed(&request).to_string()))
6240                            .await
6241                            .is_err()
6242                        {
6243                            return;
6244                        }
6245                        // The first connection drops shortly after, once the test has started
6246                        // the cooldown; the second stays.
6247                        if subscribed_at.lock().len() == 1 {
6248                            tokio::time::sleep(Duration::from_millis(500)).await;
6249                            let _ = ws.close(None).await;
6250                            return;
6251                        }
6252                    }
6253                }
6254            }
6255        })
6256        .await;
6257        let client = client_with_ws_api(url);
6258        let _stream = client.account_stream(&[], &[]).await.unwrap();
6259        let cooldown = Duration::from_secs(3);
6260        // Before the cooldown starts, so the bound cannot be later than its end.
6261        let cooled = tokio::time::Instant::now() + cooldown;
6262        client.rate_limiter.on_rate_limited(Some(cooldown));
6263
6264        let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
6265        while subscribed_at.lock().len() < 2 {
6266            assert!(tokio::time::Instant::now() < deadline, "no reconnect");
6267            tokio::time::sleep(Duration::from_millis(20)).await;
6268        }
6269
6270        // The backoff's first wait is a second: the cooldown, not the backoff, set the time.
6271        assert!(subscribed_at.lock()[1] >= cooled);
6272    }
6273
6274    /// The stream clears its reconnect failures only on a heartbeat tick that saw a frame or
6275    /// ping. A connection Binance accepts and drops at once is a failure like any other, and so is
6276    /// one that stays silent past the first tick, so a venue doing either to every connection
6277    /// exhausts the budget.
6278    #[tokio::test(start_paused = true)]
6279    async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
6280        async fn run(pinging: bool) {
6281            let lifetime = if pinging {
6282                test_ws_api::dropped_then_pinging
6283            } else {
6284                test_ws_api::dropped_then_silent
6285            };
6286            let (url, requests) = test_ws_api::serve(subscribed, lifetime).await;
6287            let client = client_with_ws_api(url);
6288            let _stream = client.account_stream(&[], &[]).await.unwrap();
6289            if pinging {
6290                test_ws_api::assert_reset_once_pinged(&requests, "spot").await;
6291            } else {
6292                test_ws_api::assert_no_reset_when_silent(&requests, "spot").await;
6293            }
6294        }
6295
6296        tokio::join!(run(true), run(false));
6297    }
6298
6299    /// After its connection drops and the stream reconnects by itself, it reads the account's
6300    /// balances and delivers each one it was opened for as a balance snapshot.
6301    #[tokio::test]
6302    async fn a_reconnect_restates_the_balances() {
6303        fn dropped_once(n: usize) -> test_ws_api::Lifetime {
6304            match n {
6305                0 => test_ws_api::Lifetime::Silent(Duration::from_millis(100)),
6306                _ => test_ws_api::Lifetime::Open,
6307            }
6308        }
6309        let venue = wiremock::MockServer::start().await;
6310        wiremock::Mock::given(wiremock::matchers::method("GET"))
6311            .and(wiremock::matchers::path("/api/v3/account"))
6312            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6313                serde_json::json!({ "balances": [
6314                    { "asset": "BTC", "free": "1", "locked": "0" },
6315                    { "asset": "ETH", "free": "2", "locked": "0" },
6316                ] }),
6317            ))
6318            .mount(&venue)
6319            .await;
6320        let (url, _) = test_ws_api::serve(subscribed, dropped_once).await;
6321        let mut client = client_with_ws_api(url);
6322        client.rest = rest_at(&venue);
6323        let btc = AssetNameExchange::new("BTC");
6324        let mut stream = client
6325            .account_stream(std::slice::from_ref(&btc), &[])
6326            .await
6327            .unwrap();
6328
6329        let restated = tokio::time::timeout(Duration::from_secs(10), async {
6330            loop {
6331                let event = futures::StreamExt::next(&mut stream).await.unwrap();
6332                if let AccountEventKind::BalanceSnapshot(balance) = event.kind {
6333                    break balance.0;
6334                }
6335            }
6336        })
6337        .await
6338        .expect("the balances are restated after the reconnect");
6339        assert_eq!(restated.asset, btc);
6340        assert_eq!(restated.balance, Balance::new(Decimal::ONE, Decimal::ONE));
6341    }
6342
6343    /// The latest balance event the stream delivered sets the floor of the restatement after the
6344    /// next reconnect. Here the venue's clock reads 60 s ahead of this host's: the read waits until
6345    /// the venue's clock is the REST lag past that event, and is stamped at its time, not this
6346    /// host's time less the lag, which the engine would judge older than the event and drop.
6347    #[tokio::test]
6348    async fn a_restatement_is_stamped_no_earlier_than_the_last_balance_delivered() {
6349        use crate::client::binance::shared::REST_BALANCE_LAG;
6350        fn ahead_ms() -> i64 {
6351            (Utc::now() + chrono::Duration::seconds(60)).timestamp_millis()
6352        }
6353        fn position() -> String {
6354            let time = ahead_ms();
6355            serde_json::json!({
6356                "subscriptionId": 0,
6357                "event": {
6358                    "e": "outboundAccountPosition", "E": time, "u": time,
6359                    "B": [{ "a": "BTC", "f": "1", "l": "0" }],
6360                },
6361            })
6362            .to_string()
6363        }
6364        fn pushed_then_open(n: usize) -> test_ws_api::Lifetime {
6365            match n {
6366                0 => test_ws_api::Lifetime::Pushing(position, Duration::from_millis(300)),
6367                _ => test_ws_api::Lifetime::Open,
6368            }
6369        }
6370        let venue = wiremock::MockServer::start().await;
6371        wiremock::Mock::given(wiremock::matchers::method("GET"))
6372            .and(wiremock::matchers::path("/api/v3/account"))
6373            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6374                serde_json::json!({ "balances": [{ "asset": "BTC", "free": "1", "locked": "0" }] }),
6375            ))
6376            .mount(&venue)
6377            .await;
6378        let (url, _) = test_ws_api::serve(subscribed, pushed_then_open).await;
6379        let mut client = client_with_ws_api(url);
6380        client.rest = rest_at(&venue);
6381        let mut stream = client.account_stream(&[], &[]).await.unwrap();
6382
6383        let (pushed, restated) = tokio::time::timeout(Duration::from_secs(15), async {
6384            let mut pushed = None;
6385            loop {
6386                let event = futures::StreamExt::next(&mut stream).await.unwrap();
6387                match event.kind {
6388                    AccountEventKind::BalanceStreamUpdate(update) => {
6389                        pushed = Some((update.0.time_exchange, tokio::time::Instant::now()));
6390                    }
6391                    AccountEventKind::BalanceSnapshot(balance) => {
6392                        break (
6393                            pushed.expect("the pushed balance comes first"),
6394                            (balance.0.time_exchange, tokio::time::Instant::now()),
6395                        );
6396                    }
6397                    _ => {}
6398                }
6399            }
6400        })
6401        .await
6402        .expect("the balances are restated after the reconnect");
6403
6404        assert_eq!(restated.0, pushed.0);
6405        // This test receives the balance event a little after the stream does, which is when the
6406        // wait starts. A second's allowance for that still tells the 5 s wait from none: without
6407        // it, the read would follow the reconnect, 300 ms after the event, at once.
6408        let allowance = Duration::from_secs(1);
6409        assert!(
6410            restated.1 + allowance >= pushed.1 + REST_BALANCE_LAG.to_std().unwrap(),
6411            "restated {:?} after the balance event, before the REST lag",
6412            restated.1 - pushed.1
6413        );
6414    }
6415
6416    /// A cancel that waited on another's handshake, which Binance refused, is not sent and opens
6417    /// no second session: it reports the cooldown the refusal started.
6418    #[tokio::test]
6419    async fn a_cancel_queued_behind_a_refused_handshake_reports_the_cooldown() {
6420        use tokio_tungstenite::tungstenite::http;
6421
6422        let handshakes = Arc::new(std::sync::atomic::AtomicU32::new(0));
6423        let url = ws_server({
6424            let handshakes = Arc::clone(&handshakes);
6425            move |stream| {
6426                let handshakes = Arc::clone(&handshakes);
6427                async move {
6428                    handshakes.fetch_add(1, Ordering::SeqCst);
6429                    // Hold the refusal back, so the second cancel queues behind the first.
6430                    tokio::time::sleep(Duration::from_millis(200)).await;
6431                    // tungstenite's handshake callback fixes the error type to a whole HTTP
6432                    // response.
6433                    #[allow(clippy::result_large_err)]
6434                    let refuse = |_: &http::Request<()>, _| {
6435                        Err(http::Response::builder()
6436                            .status(http::StatusCode::TOO_MANY_REQUESTS)
6437                            .body(None)
6438                            .unwrap())
6439                    };
6440                    let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
6441                }
6442            }
6443        })
6444        .await;
6445        let client = client_with_ws_api(url);
6446        let instrument = InstrumentNameExchange::new("BTCUSDT");
6447
6448        let (first, second) = tokio::join!(
6449            client.cancel_order(cancel_request(&instrument)),
6450            client.cancel_order(cancel_request(&instrument)),
6451        );
6452
6453        assert_eq!(handshakes.load(Ordering::SeqCst), 1);
6454        let waits: Vec<_> = [&first.state, &second.state]
6455            .into_iter()
6456            .map(|state| match state {
6457                Err(UnindexedOrderError::Rejected(api @ ApiError::RateLimit { .. })) => {
6458                    api.retry_after()
6459                }
6460                other => panic!("expected a rate-limit rejection, got {other:?}"),
6461            })
6462            .collect();
6463        // The refused handshake carries no wait; the queued cancel reports the cooldown left.
6464        let default = Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS);
6465        assert!(
6466            waits.contains(&None)
6467                && waits
6468                    .iter()
6469                    .flatten()
6470                    .any(|wait| { *wait <= default && *wait > default - Duration::from_secs(5) }),
6471            "{waits:?}"
6472        );
6473    }
6474
6475    /// A spot REST client, and its rate limiter, whose requests go to `server`.
6476    fn rest_against(server: &wiremock::MockServer) -> (Arc<RestApi>, Arc<RateLimitTracker>) {
6477        let rest = Arc::new(SpotRestApi::from_config(
6478            ConfigurationRestApi::builder()
6479                .api_key("key")
6480                .api_secret("secret")
6481                .base_path(server.uri())
6482                .build()
6483                .unwrap(),
6484        ));
6485        (rest, Arc::new(RateLimitTracker::new(WeightPool::Spot)))
6486    }
6487
6488    /// Mounts an `/api/v3/openOrders` refusal with `status`, and `Retry-After: <retry_after>`
6489    /// when given, answering at most `times` requests.
6490    async fn mount_refusal(
6491        server: &wiremock::MockServer,
6492        status: u16,
6493        retry_after: Option<&str>,
6494        times: u64,
6495    ) {
6496        let mut refusal = wiremock::ResponseTemplate::new(status).set_body_json(
6497            serde_json::json!({ "code": -1003, "msg": "Too much request weight used." }),
6498        );
6499        if let Some(retry_after) = retry_after {
6500            refusal = refusal.insert_header("retry-after", retry_after);
6501        }
6502        wiremock::Mock::given(wiremock::matchers::method("GET"))
6503            .and(wiremock::matchers::path("/api/v3/openOrders"))
6504            .respond_with(refusal)
6505            .up_to_n_times(times)
6506            .mount(server)
6507            .await;
6508    }
6509
6510    /// The wait a query's rate-limit refusal reports. Panics on any other result.
6511    fn query_retry_after<T: std::fmt::Debug>(
6512        result: &Result<T, UnindexedClientError>,
6513    ) -> Option<Duration> {
6514        match result {
6515            Err(UnindexedClientError::Api(api @ ApiError::RateLimit { .. })) => api.retry_after(),
6516            other => panic!("expected a rate-limit refusal, got {other:?}"),
6517        }
6518    }
6519
6520    /// A REST 418 is never retried in place. Its `Retry-After` is the wait reported and the
6521    /// cooldown set, and the next request, facing that cooldown, is not sent.
6522    #[tokio::test]
6523    async fn a_rest_ban_is_not_retried_and_holds_back_later_requests() {
6524        let server = wiremock::MockServer::start().await;
6525        mount_refusal(&server, 418, Some("120"), u64::MAX).await;
6526        let (rest, tracker) = rest_against(&server);
6527
6528        let result = fetch_all_open_orders(Arc::clone(&rest), Arc::clone(&tracker)).await;
6529        assert_eq!(query_retry_after(&result), Some(Duration::from_secs(120)));
6530        assert!(
6531            tracker
6532                .cooldown_remaining()
6533                .is_some_and(|left| left > Duration::from_secs(115)),
6534            "{:?}",
6535            tracker.cooldown_remaining()
6536        );
6537
6538        let result = fetch_all_open_orders(rest, tracker).await;
6539        assert!(
6540            query_retry_after(&result).is_some_and(
6541                |left| left > Duration::from_secs(115) && left <= Duration::from_secs(120)
6542            ),
6543            "{result:?}"
6544        );
6545        assert_eq!(server.received_requests().await.unwrap().len(), 1);
6546    }
6547
6548    /// A 418 without `Retry-After` is not retried either; it reports no wait, and holds requests
6549    /// back for the default cooldown.
6550    #[tokio::test]
6551    async fn a_rest_ban_without_retry_after_is_not_retried() {
6552        let server = wiremock::MockServer::start().await;
6553        mount_refusal(&server, 418, None, u64::MAX).await;
6554        let (rest, tracker) = rest_against(&server);
6555
6556        let result = fetch_all_open_orders(rest, Arc::clone(&tracker)).await;
6557        assert_eq!(query_retry_after(&result), None);
6558        assert_eq!(server.received_requests().await.unwrap().len(), 1);
6559        assert!(
6560            tracker
6561                .cooldown_remaining()
6562                .is_some_and(|left| left > Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS - 5)),
6563            "{:?}",
6564            tracker.cooldown_remaining()
6565        );
6566    }
6567
6568    /// A 429 asking for longer than a request sleeps through is not retried in place.
6569    #[tokio::test]
6570    async fn a_rest_throttle_with_a_long_retry_after_is_not_retried() {
6571        let server = wiremock::MockServer::start().await;
6572        mount_refusal(&server, 429, Some("30"), u64::MAX).await;
6573        let (rest, tracker) = rest_against(&server);
6574
6575        let result = fetch_all_open_orders(rest, tracker).await;
6576        assert_eq!(query_retry_after(&result), Some(Duration::from_secs(30)));
6577        assert_eq!(server.received_requests().await.unwrap().len(), 1);
6578    }
6579
6580    /// A 429 asking for a short wait is retried in place, no sooner than that wait, though it
6581    /// is longer than the first backoff.
6582    #[tokio::test]
6583    async fn a_rest_throttle_with_a_short_retry_after_is_retried_after_it() {
6584        let server = wiremock::MockServer::start().await;
6585        mount_refusal(&server, 429, Some("2"), 1).await;
6586        wiremock::Mock::given(wiremock::matchers::method("GET"))
6587            .and(wiremock::matchers::path("/api/v3/openOrders"))
6588            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6589            .mount(&server)
6590            .await;
6591        let (rest, tracker) = rest_against(&server);
6592
6593        let started = std::time::Instant::now();
6594        let orders = fetch_all_open_orders(rest, tracker).await.unwrap();
6595
6596        assert!(orders.is_empty());
6597        assert_eq!(server.received_requests().await.unwrap().len(), 2);
6598        assert!(
6599            started.elapsed() >= Duration::from_secs(2),
6600            "{:?}",
6601            started.elapsed()
6602        );
6603    }
6604
6605    /// A WS-API handshake Binance refuses with 429 starts a cooldown, and the cancel that needed
6606    /// the session fails as a rate-limit rejection.
6607    #[tokio::test]
6608    async fn a_handshake_refused_with_429_starts_a_cooldown() {
6609        use tokio_tungstenite::tungstenite::http;
6610
6611        let url = ws_server(|stream| async move {
6612            // tungstenite's handshake callback fixes the error type to a whole HTTP response.
6613            #[allow(clippy::result_large_err)]
6614            let refuse = |_: &http::Request<()>, _| {
6615                Err(http::Response::builder()
6616                    .status(http::StatusCode::TOO_MANY_REQUESTS)
6617                    .body(None)
6618                    .unwrap())
6619            };
6620            let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
6621        })
6622        .await;
6623        let client = client_with_ws_api(url);
6624        let instrument = InstrumentNameExchange::new("BTCUSDT");
6625
6626        let response = client.cancel_order(cancel_request(&instrument)).await;
6627
6628        assert!(
6629            matches!(
6630                response.state,
6631                Err(UnindexedOrderError::Rejected(ApiError::RateLimit { .. }))
6632            ),
6633            "{:?}",
6634            response.state
6635        );
6636        assert!(client.rate_limiter.is_blocked());
6637    }
6638
6639    /// A WS-API handshake Binance refuses with 418, an IP ban, holds requests back for Binance's
6640    /// shortest ban, and the cancel that needed the session fails with that wait.
6641    #[tokio::test]
6642    async fn a_handshake_refused_with_418_starts_the_shortest_ban() {
6643        use tokio_tungstenite::tungstenite::http;
6644
6645        let url = ws_server(|stream| async move {
6646            // tungstenite's handshake callback fixes the error type to a whole HTTP response.
6647            #[allow(clippy::result_large_err)]
6648            let refuse = |_: &http::Request<()>, _| {
6649                Err(http::Response::builder()
6650                    .status(http::StatusCode::IM_A_TEAPOT)
6651                    .body(None)
6652                    .unwrap())
6653            };
6654            let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
6655        })
6656        .await;
6657        let client = client_with_ws_api(url);
6658        let instrument = InstrumentNameExchange::new("BTCUSDT");
6659
6660        let response = client.cancel_order(cancel_request(&instrument)).await;
6661
6662        assert_rate_limited_for(&response.state, MIN_IP_BAN);
6663        assert_cooldown_about(&client, MIN_IP_BAN);
6664    }
6665
6666    /// A WS-API cancel response carries the spot pool's weight limit and usage: the limit
6667    /// replaces the default, and usage at 90% of it pauses queries but not orders.
6668    #[tokio::test]
6669    async fn a_ws_api_response_near_the_weight_limit_pauses_queries() {
6670        use futures::{SinkExt as _, StreamExt as _};
6671        use tokio_tungstenite::tungstenite::Message;
6672
6673        let url = ws_server(|stream| async move {
6674            let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
6675                return;
6676            };
6677            while let Some(Ok(message)) = ws.next().await {
6678                let Message::Text(text) = message else {
6679                    continue;
6680                };
6681                let request: serde_json::Value = serde_json::from_str(&text).unwrap();
6682                let response = serde_json::json!({
6683                    "id": request["id"],
6684                    "status": 200,
6685                    "result": {
6686                        "symbol": "BTCUSDT",
6687                        "origClientOrderId": "cid",
6688                        "orderId": 7,
6689                        "transactTime": 1_700_000_000_000_i64,
6690                        "executedQty": "0",
6691                    },
6692                    "rateLimits": [{
6693                        "rateLimitType": "REQUEST_WEIGHT",
6694                        "interval": "MINUTE",
6695                        "intervalNum": 1,
6696                        "limit": 1_000,
6697                        "count": 950,
6698                    }],
6699                });
6700                if ws.send(Message::text(response.to_string())).await.is_err() {
6701                    return;
6702                }
6703            }
6704        })
6705        .await;
6706        let client = client_with_ws_api(url);
6707        let instrument = InstrumentNameExchange::new("BTCUSDT");
6708
6709        let response = client.cancel_order(cancel_request(&instrument)).await;
6710
6711        assert!(response.state.is_ok(), "{:?}", response.state);
6712        assert_eq!(client.rate_limiter.weight_limit(), 1_000);
6713        assert!(
6714            tokio::time::timeout(
6715                Duration::from_millis(50),
6716                client.rate_limiter.wait_if_blocked(RequestKind::Query),
6717            )
6718            .await
6719            .is_err(),
6720            "queries pause"
6721        );
6722        tokio::time::timeout(
6723            Duration::from_millis(50),
6724            client.rate_limiter.wait_if_blocked(RequestKind::Order),
6725        )
6726        .await
6727        .expect("orders never wait on the pause")
6728        .expect("no cooldown too long to sleep through");
6729    }
6730
6731    /// A local WebSocket server that answers every request with `result`, and reports on the
6732    /// returned channel each connection the client closes with a Close frame.
6733    async fn ws_server_reporting_close(
6734        result: serde_json::Value,
6735    ) -> (String, mpsc::UnboundedReceiver<()>) {
6736        use futures::{SinkExt as _, StreamExt as _};
6737        use tokio_tungstenite::tungstenite::Message;
6738
6739        let (closed_tx, closed_rx) = mpsc::unbounded_channel();
6740        let url = ws_server(move |stream| {
6741            let result = result.clone();
6742            let closed_tx = closed_tx.clone();
6743            async move {
6744                let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
6745                    return;
6746                };
6747                while let Some(Ok(message)) = ws.next().await {
6748                    match message {
6749                        Message::Text(text) => {
6750                            let request: serde_json::Value = serde_json::from_str(&text).unwrap();
6751                            let response = serde_json::json!({
6752                                "id": request["id"],
6753                                "status": 200,
6754                                "result": result,
6755                            });
6756                            if ws.send(Message::text(response.to_string())).await.is_err() {
6757                                return;
6758                            }
6759                        }
6760                        Message::Close(_) => {
6761                            let _ = closed_tx.send(());
6762                            return;
6763                        }
6764                        _ => {}
6765                    }
6766                }
6767            }
6768        })
6769        .await;
6770        (url, closed_rx)
6771    }
6772
6773    /// Dropping the account stream closes its WebSocket connection. Dropped at once, the manager
6774    /// has not started and holds the connection as its argument; dropped later, it holds it in its
6775    /// loop, and its event callback runs. The stream's state is released either way: the callback
6776    /// holds the client's known-live orders, and is unsubscribed when the stream drops, rather than
6777    /// whenever binance-sdk frees the connection.
6778    #[tokio::test]
6779    async fn dropping_the_account_stream_closes_its_connection() {
6780        for running in [Duration::ZERO, Duration::from_millis(100)] {
6781            let (url, mut closed) =
6782                ws_server_reporting_close(serde_json::json!({ "subscriptionId": 0 })).await;
6783            let client = client_with_ws_api(url);
6784            let holders = client.known_live.holders();
6785
6786            let stream = client.account_stream(&[], &[]).await.unwrap();
6787            tokio::time::sleep(running).await;
6788            drop(stream);
6789
6790            tokio::time::timeout(Duration::from_secs(5), closed.recv())
6791                .await
6792                .expect("the dropped stream closes its connection")
6793                .unwrap();
6794            // An aborted task drops its state on its next turn.
6795            for _ in 0..10 {
6796                tokio::task::yield_now().await;
6797            }
6798            assert_eq!(
6799                client.known_live.holders(),
6800                holders,
6801                "the stream's state is released"
6802            );
6803        }
6804    }
6805
6806    /// The order session is shared by every clone of a client, and closed when the last one is
6807    /// dropped.
6808    #[tokio::test]
6809    async fn dropping_the_last_client_closes_its_order_session() {
6810        let (url, mut closed) = ws_server_reporting_close(serde_json::json!({
6811            "symbol": "BTCUSDT",
6812            "origClientOrderId": "cid",
6813            "orderId": 7,
6814            "transactTime": 1_700_000_000_000_i64,
6815            "executedQty": "0",
6816        }))
6817        .await;
6818        let client = client_with_ws_api(url);
6819        let clone = client.clone();
6820        let instrument = InstrumentNameExchange::new("BTCUSDT");
6821        let response = client.cancel_order(cancel_request(&instrument)).await;
6822        assert!(response.state.is_ok(), "{:?}", response.state);
6823
6824        drop(client);
6825        assert!(
6826            tokio::time::timeout(Duration::from_millis(200), closed.recv())
6827                .await
6828                .is_err(),
6829            "a clone still holds the session"
6830        );
6831
6832        drop(clone);
6833        tokio::time::timeout(Duration::from_secs(5), closed.recv())
6834            .await
6835            .expect("the last client's drop closes the session")
6836            .unwrap();
6837    }
6838
6839    /// Fill recovery does not wait for the pause near the weight limit, which could outlast its
6840    /// budget and lose the fills: with queries paused, it still reads the trades at once.
6841    #[tokio::test]
6842    async fn fill_recovery_does_not_wait_for_the_weight_pause() {
6843        let server = wiremock::MockServer::start().await;
6844        wiremock::Mock::given(wiremock::matchers::method("GET"))
6845            .and(wiremock::matchers::path("/api/v3/myTrades"))
6846            // One trade, so recovery also looks its order up: both reads run under the pause.
6847            .respond_with(
6848                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
6849                    "symbol": "BTCUSDT",
6850                    "id": 1,
6851                    "orderId": 7,
6852                    "price": "100",
6853                    "qty": "1",
6854                    "commission": "0",
6855                    "commissionAsset": "USDT",
6856                    "time": Utc::now().timestamp_millis(),
6857                    "isBuyer": true,
6858                    "isMaker": false,
6859                }])),
6860            )
6861            .expect(2)
6862            .mount(&server)
6863            .await;
6864        let rest = Arc::new(SpotRestApi::from_config(
6865            ConfigurationRestApi::builder()
6866                .api_key("key")
6867                .api_secret("secret")
6868                .base_path(server.uri())
6869                .build()
6870                .unwrap(),
6871        ));
6872        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6873        tracker.throttle(Duration::from_secs(60));
6874        let (tx, _rx) = mpsc::unbounded_channel();
6875        let mut unrecovered = UnrecoveredFills::default();
6876        unrecovered.open(
6877            &[InstrumentNameExchange::new("BTCUSDT")],
6878            Utc::now() - chrono::Duration::minutes(1),
6879            Utc::now(),
6880        );
6881
6882        // Far under the 60 s pause set above, and ample for two local round trips.
6883        tokio::time::timeout(
6884            Duration::from_millis(800),
6885            recover_fills(
6886                &rest,
6887                &tracker,
6888                &mut unrecovered,
6889                &tx,
6890                &new_dedup_cache(),
6891                &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6892            ),
6893        )
6894        .await
6895        .expect("recovery does not wait for the pause");
6896    }
6897
6898    /// A gap whose every read fails is given up, and reported on the stream with the last read's error.
6899    #[tokio::test]
6900    async fn a_gap_whose_every_read_fails_is_reported_when_given_up() {
6901        let disconnect = Utc::now() - chrono::Duration::minutes(10);
6902        let reconnect = Utc::now() - chrono::Duration::minutes(5);
6903        let server = wiremock::MockServer::start().await;
6904        wiremock::Mock::given(wiremock::matchers::method("GET"))
6905            .and(wiremock::matchers::path("/api/v3/myTrades"))
6906            .respond_with(
6907                wiremock::ResponseTemplate::new(400)
6908                    .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6909            )
6910            .mount(&server)
6911            .await;
6912        let rest = Arc::new(SpotRestApi::from_config(
6913            ConfigurationRestApi::builder()
6914                .api_key("key")
6915                .api_secret("secret")
6916                .base_path(server.uri())
6917                .build()
6918                .expect("valid config"),
6919        ));
6920        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6921        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6922        let dedup = new_dedup_cache();
6923        let btc = InstrumentNameExchange::new("BTCUSDT");
6924        let mut unrecovered = UnrecoveredFills::default();
6925        unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
6926
6927        for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
6928            recover_fills(
6929                &rest,
6930                &tracker,
6931                &mut unrecovered,
6932                &tx,
6933                &dedup,
6934                &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6935            )
6936            .await;
6937            unrecovered.make_due();
6938        }
6939
6940        assert!(unrecovered.is_empty(), "the gap is given up");
6941        let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6942        let [event] = forwarded.as_slice() else {
6943            panic!("only the give-up is sent: {forwarded:?}");
6944        };
6945        let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
6946            panic!("expected FillRecoveryGaveUp, got {event:?}");
6947        };
6948        assert_eq!(event.exchange, ExchangeId::BinanceSpot);
6949        assert_eq!(
6950            gave_up.scope,
6951            crate::FillRecoveryScope::Instruments(vec![btc])
6952        );
6953        assert_eq!(
6954            gave_up.start.timestamp_millis(),
6955            disconnect.timestamp_millis()
6956        );
6957        assert_eq!(
6958            gave_up.attempts,
6959            crate::client::order_recovery::MAX_GAP_RETRIES + 1
6960        );
6961        assert!(
6962            matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
6963            "the last read's error: {:?}",
6964            gave_up.reason
6965        );
6966    }
6967
6968    /// A recovery that fails keeps its gap, closed at its own start; the retry reads exactly that
6969    /// span, forwards only the fill inside it (a later one arrived live), and clears it.
6970    #[tokio::test]
6971    async fn a_failed_recovery_keeps_its_closed_gap_for_the_retry() {
6972        let disconnect = Utc::now() - chrono::Duration::minutes(10);
6973        let reconnect = Utc::now() - chrono::Duration::minutes(5);
6974        let trade = |id: i64, time: DateTime<Utc>| {
6975            serde_json::json!({
6976                "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
6977                "commission": "0", "commissionAsset": "USDT", "time": time.timestamp_millis(),
6978                "isBuyer": true, "isMaker": false, "isBestMatch": true,
6979            })
6980        };
6981        let server = wiremock::MockServer::start().await;
6982        wiremock::Mock::given(wiremock::matchers::method("GET"))
6983            .and(wiremock::matchers::path("/api/v3/myTrades"))
6984            .respond_with(
6985                wiremock::ResponseTemplate::new(400)
6986                    .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6987            )
6988            .up_to_n_times(1)
6989            .with_priority(1)
6990            .mount(&server)
6991            .await;
6992        wiremock::Mock::given(wiremock::matchers::method("GET"))
6993            .and(wiremock::matchers::path("/api/v3/myTrades"))
6994            .respond_with(
6995                wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
6996                    trade(1, disconnect + chrono::Duration::minutes(1)),
6997                    trade(2, Utc::now() - chrono::Duration::minutes(1)),
6998                ])),
6999            )
7000            .mount(&server)
7001            .await;
7002        let rest = Arc::new(SpotRestApi::from_config(
7003            ConfigurationRestApi::builder()
7004                .api_key("key")
7005                .api_secret("secret")
7006                .base_path(server.uri())
7007                .build()
7008                .unwrap(),
7009        ));
7010        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
7011        let (tx, mut rx) = mpsc::unbounded_channel();
7012        let dedup = new_dedup_cache();
7013        let mut unrecovered = UnrecoveredFills::default();
7014        unrecovered.open(
7015            &[InstrumentNameExchange::new("BTCUSDT")],
7016            disconnect,
7017            reconnect,
7018        );
7019
7020        recover_fills(
7021            &rest,
7022            &tracker,
7023            &mut unrecovered,
7024            &tx,
7025            &dedup,
7026            &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7027        )
7028        .await;
7029        assert!(!unrecovered.is_empty(), "the failed gap is kept");
7030        assert!(rx.try_recv().is_err(), "nothing forwarded");
7031        assert!(
7032            unrecovered.due(tokio::time::Instant::now()).is_empty(),
7033            "and waits for its retry"
7034        );
7035
7036        unrecovered.make_due();
7037        recover_fills(
7038            &rest,
7039            &tracker,
7040            &mut unrecovered,
7041            &tx,
7042            &dedup,
7043            &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7044        )
7045        .await;
7046        assert!(unrecovered.is_empty(), "the retry recovered it");
7047        let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7048        assert_eq!(
7049            forwarded.len(),
7050            1,
7051            "only the fill inside the gap: {forwarded:?}"
7052        );
7053
7054        let start_times: Vec<String> = server
7055            .received_requests()
7056            .await
7057            .unwrap()
7058            .iter()
7059            .filter_map(|request| {
7060                request
7061                    .url
7062                    .query_pairs()
7063                    .find(|(key, _)| key == "startTime")
7064                    .map(|(_, value)| value.into_owned())
7065            })
7066            .collect();
7067        let start = disconnect.timestamp_millis().to_string();
7068        assert_eq!(start_times, [start.clone(), start]);
7069    }
7070
7071    /// Recovery reads each recovered fill's order once for the client order id `myTrades` lacks.
7072    /// An order Binance no longer knows leaves its fill without one, and the fill still goes out.
7073    #[tokio::test]
7074    async fn recovered_fills_carry_their_orders_client_order_ids() {
7075        let disconnect = Utc::now() - chrono::Duration::minutes(10);
7076        let reconnect = Utc::now() - chrono::Duration::minutes(5);
7077        let trade = |id: i64| {
7078            serde_json::json!({
7079                "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
7080                "commission": "0", "commissionAsset": "USDT",
7081                "time": (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
7082                "isBuyer": true, "isMaker": false, "isBestMatch": true,
7083            })
7084        };
7085        let server = wiremock::MockServer::start().await;
7086        // Each order's own walk returns its one execution; the span read returns both.
7087        for id in [1, 2] {
7088            wiremock::Mock::given(wiremock::matchers::method("GET"))
7089                .and(wiremock::matchers::path("/api/v3/myTrades"))
7090                .and(wiremock::matchers::query_param("orderId", id.to_string()))
7091                .respond_with(
7092                    wiremock::ResponseTemplate::new(200)
7093                        .set_body_json(serde_json::json!([trade(id)])),
7094                )
7095                .with_priority(1)
7096                .mount(&server)
7097                .await;
7098        }
7099        wiremock::Mock::given(wiremock::matchers::method("GET"))
7100            .and(wiremock::matchers::path("/api/v3/myTrades"))
7101            .respond_with(
7102                wiremock::ResponseTemplate::new(200)
7103                    .set_body_json(serde_json::json!([trade(1), trade(2)])),
7104            )
7105            .mount(&server)
7106            .await;
7107        wiremock::Mock::given(wiremock::matchers::method("GET"))
7108            .and(wiremock::matchers::path("/api/v3/order"))
7109            .and(wiremock::matchers::query_param("orderId", "1"))
7110            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7111                serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "sweep-1"}),
7112            ))
7113            .mount(&server)
7114            .await;
7115        wiremock::Mock::given(wiremock::matchers::method("GET"))
7116            .and(wiremock::matchers::path("/api/v3/order"))
7117            .and(wiremock::matchers::query_param("orderId", "2"))
7118            .respond_with(
7119                wiremock::ResponseTemplate::new(400).set_body_json(
7120                    serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
7121                ),
7122            )
7123            .mount(&server)
7124            .await;
7125        let rest = Arc::new(SpotRestApi::from_config(
7126            ConfigurationRestApi::builder()
7127                .api_key("key")
7128                .api_secret("secret")
7129                .base_path(server.uri())
7130                .build()
7131                .unwrap(),
7132        ));
7133        let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
7134        let (tx, mut rx) = mpsc::unbounded_channel();
7135        let mut unrecovered = UnrecoveredFills::default();
7136        unrecovered.open(
7137            &[InstrumentNameExchange::new("BTCUSDT")],
7138            disconnect,
7139            reconnect,
7140        );
7141
7142        recover_fills(
7143            &rest,
7144            &tracker,
7145            &mut unrecovered,
7146            &tx,
7147            &new_dedup_cache(),
7148            &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7149        )
7150        .await;
7151
7152        let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
7153            .filter_map(|event| match event.kind {
7154                AccountEventKind::Trade(trade) => Some((
7155                    trade.id.0.to_string(),
7156                    trade.client_order_id,
7157                    trade.order_filled_quantity,
7158                )),
7159                _ => None,
7160            })
7161            .collect();
7162        cids.sort();
7163        // The cumulatives from the walks, read first, are untouched by the reads after them.
7164        assert_eq!(
7165            cids,
7166            [
7167                (
7168                    "1".to_string(),
7169                    Some(ClientOrderId::new("sweep-1")),
7170                    Some(Decimal::ONE)
7171                ),
7172                ("2".to_string(), None, Some(Decimal::ONE)),
7173            ]
7174        );
7175        let order_reads = server
7176            .received_requests()
7177            .await
7178            .unwrap()
7179            .iter()
7180            .filter(|request| request.url.path() == "/api/v3/order")
7181            .count();
7182        assert_eq!(order_reads, 2, "one read per order");
7183    }
7184
7185    /// A span walk reads on by id through a full page inside the span, and stops at the page that
7186    /// reaches past its end, without the executions after it.
7187    #[tokio::test]
7188    async fn a_span_walk_pages_by_id_and_stops_past_the_end() {
7189        let start = Utc::now().timestamp_millis() - 600_000;
7190        let end = start + 10_000;
7191        let trade = |id: i64, time: i64| {
7192            serde_json::json!({
7193                "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
7194                "commission": "0", "commissionAsset": "USDT", "time": time,
7195                "isBuyer": true, "isMaker": false, "isBestMatch": true,
7196            })
7197        };
7198        let server = wiremock::MockServer::start().await;
7199        wiremock::Mock::given(wiremock::matchers::method("GET"))
7200            .and(wiremock::matchers::path("/api/v3/myTrades"))
7201            .and(wiremock::matchers::query_param("fromId", "1001"))
7202            // A full page that reaches past the end: only the end, not a short page, stops the walk.
7203            .respond_with(
7204                wiremock::ResponseTemplate::new(200).set_body_json(
7205                    (1_001..=2_000)
7206                        .map(|id| trade(id, if id == 1_001 { end } else { end + 1 }))
7207                        .collect::<Vec<_>>(),
7208                ),
7209            )
7210            .with_priority(1)
7211            .mount(&server)
7212            .await;
7213        let full_page: Vec<_> = (1..=1_000).map(|id| trade(id, start + id)).collect();
7214        wiremock::Mock::given(wiremock::matchers::method("GET"))
7215            .and(wiremock::matchers::path("/api/v3/myTrades"))
7216            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(full_page))
7217            .mount(&server)
7218            .await;
7219        let rest = Arc::new(SpotRestApi::from_config(
7220            ConfigurationRestApi::builder()
7221                .api_key("key")
7222                .api_secret("secret")
7223                .base_path(server.uri())
7224                .build()
7225                .unwrap(),
7226        ));
7227
7228        let read = paginate_my_trades(
7229            &rest,
7230            &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7231            &InstrumentNameExchange::new("BTCUSDT"),
7232            MyTradesFrom::Span { start, end },
7233            RequestKind::Query,
7234        )
7235        .await
7236        .unwrap();
7237
7238        let ids: Vec<_> = read.iter().filter_map(|t| t.id).collect();
7239        assert_eq!(ids, (1..=1_001).collect::<Vec<_>>());
7240        assert_eq!(server.received_requests().await.unwrap().len(), 2);
7241    }
7242
7243    /// `fetch_trades` reads its span's milliseconds and returns only the trades inside the span:
7244    /// not one earlier in `start`'s millisecond, nor one after `end`. An empty span reads nothing.
7245    #[tokio::test]
7246    async fn fetch_trades_returns_only_the_span() {
7247        // Half a millisecond in, so a trade at the start of its millisecond is before it.
7248        let start = DateTime::from_timestamp_millis(
7249            (Utc::now() - chrono::Duration::minutes(10)).timestamp_millis(),
7250        )
7251        .unwrap()
7252            + chrono::Duration::microseconds(500);
7253        let end = start + chrono::Duration::seconds(10);
7254        let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
7255        let trade = |id: i64, time: i64| {
7256            serde_json::json!({
7257                "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
7258                "commission": "0", "commissionAsset": "USDT", "time": time,
7259                "isBuyer": true, "isMaker": false, "isBestMatch": true,
7260            })
7261        };
7262        let server = wiremock::MockServer::start().await;
7263        wiremock::Mock::given(wiremock::matchers::method("GET"))
7264            .and(wiremock::matchers::path("/api/v3/myTrades"))
7265            .and(wiremock::matchers::query_param(
7266                "startTime",
7267                start_ms.to_string(),
7268            ))
7269            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(vec![
7270                trade(1, start_ms),
7271                trade(2, start_ms + 1),
7272                trade(3, end_ms),
7273                trade(4, end_ms + 1),
7274            ]))
7275            .mount(&server)
7276            .await;
7277        let mut client = <BinanceSpot as ExecutionClient>::new(
7278            BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7279        );
7280        client.rest = Arc::new(SpotRestApi::from_config(
7281            ConfigurationRestApi::builder()
7282                .api_key("key")
7283                .api_secret("secret")
7284                .base_path(server.uri())
7285                .build()
7286                .unwrap(),
7287        ));
7288        let btc = [InstrumentNameExchange::new("BTCUSDT")];
7289
7290        let read = client.fetch_trades(start, end, &btc).await.unwrap();
7291        let ids: Vec<_> = read.trades.iter().map(|t| t.id.0.as_str()).collect();
7292        assert_eq!(ids, ["2", "3"]);
7293        assert_eq!(read.resume, None, "spot reads a span whole");
7294
7295        let empty = client.fetch_trades(end, start, &btc).await.unwrap();
7296        assert!(empty.trades.is_empty() && empty.resume.is_none());
7297        assert_eq!(server.received_requests().await.unwrap().len(), 1);
7298    }
7299
7300    /// A gap whose every read times out is given up, and reported with the recovery's time budget.
7301    #[tokio::test]
7302    async fn a_gap_whose_every_read_times_out_is_reported_when_given_up() {
7303        let server = wiremock::MockServer::start().await;
7304        wiremock::Mock::given(wiremock::matchers::method("GET"))
7305            .and(wiremock::matchers::path("/api/v3/myTrades"))
7306            .respond_with(
7307                wiremock::ResponseTemplate::new(200)
7308                    .set_body_json(serde_json::json!([]))
7309                    .set_delay(Duration::from_secs(3_600)),
7310            )
7311            .mount(&server)
7312            .await;
7313        let rest = Arc::new(SpotRestApi::from_config(
7314            ConfigurationRestApi::builder()
7315                .api_key("key")
7316                .api_secret("secret")
7317                .base_path(server.uri())
7318                .timeout(3_600_000_u64)
7319                .retries(0_u32)
7320                .build()
7321                .expect("valid config"),
7322        ));
7323        let btc = InstrumentNameExchange::new("BTCUSDT");
7324        let mut unrecovered = UnrecoveredFills::default();
7325        unrecovered.open(
7326            std::slice::from_ref(&btc),
7327            Utc::now() - chrono::Duration::minutes(10),
7328            Utc::now(),
7329        );
7330        let (tx, mut rx) = mpsc::unbounded_channel();
7331        // Paused: once the read is waiting, the clock jumps to the recovery timeout.
7332        tokio::time::pause();
7333
7334        for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
7335            recover_fills(
7336                &rest,
7337                &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7338                &mut unrecovered,
7339                &tx,
7340                &new_dedup_cache(),
7341                &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7342            )
7343            .await;
7344            unrecovered.make_due();
7345        }
7346
7347        assert!(unrecovered.is_empty(), "the gap is given up");
7348        let Ok(event) = rx.try_recv() else {
7349            panic!("the given-up gap is reported");
7350        };
7351        let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
7352            panic!("expected FillRecoveryGaveUp, got {event:?}");
7353        };
7354        assert_eq!(
7355            gave_up.scope,
7356            crate::FillRecoveryScope::Instruments(vec![btc])
7357        );
7358        assert_eq!(
7359            gave_up.reason,
7360            FillRecoveryFailure::TimedOut {
7361                timeout_secs: FILL_RECOVERY_TIMEOUT_SECS
7362            }
7363        );
7364        assert!(rx.try_recv().is_err(), "reported once");
7365    }
7366
7367    /// When recovery times out, a gap whose read started has failed and waits for its retry, but
7368    /// one never started (eight are read at a time) is not charged and stays due.
7369    #[tokio::test]
7370    async fn a_timed_out_recovery_charges_only_the_gaps_it_started() {
7371        let server = wiremock::MockServer::start().await;
7372        wiremock::Mock::given(wiremock::matchers::method("GET"))
7373            .and(wiremock::matchers::path("/api/v3/myTrades"))
7374            .respond_with(
7375                wiremock::ResponseTemplate::new(200)
7376                    .set_body_json(serde_json::json!([]))
7377                    .set_delay(Duration::from_secs(3_600)),
7378            )
7379            .mount(&server)
7380            .await;
7381        let rest = Arc::new(SpotRestApi::from_config(
7382            ConfigurationRestApi::builder()
7383                .api_key("key")
7384                .api_secret("secret")
7385                .base_path(server.uri())
7386                .timeout(3_600_000_u64)
7387                .retries(0_u32)
7388                .build()
7389                .unwrap(),
7390        ));
7391        let instruments: Vec<_> = (0..9)
7392            .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
7393            .collect();
7394        let mut unrecovered = UnrecoveredFills::default();
7395        unrecovered.open(
7396            &instruments,
7397            Utc::now() - chrono::Duration::minutes(10),
7398            Utc::now(),
7399        );
7400        let (tx, _rx) = mpsc::unbounded_channel();
7401        // Paused: once every read is waiting, the clock jumps to the recovery timeout.
7402        tokio::time::pause();
7403
7404        recover_fills(
7405            &rest,
7406            &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7407            &mut unrecovered,
7408            &tx,
7409            &new_dedup_cache(),
7410            &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7411        )
7412        .await;
7413
7414        let now = tokio::time::Instant::now();
7415        assert_eq!(
7416            unrecovered.due(now).len(),
7417            1,
7418            "the gap never started stays due"
7419        );
7420        assert_eq!(
7421            unrecovered
7422                .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
7423                .len(),
7424            9,
7425            "the eight started wait for their first retry"
7426        );
7427    }
7428
7429    // -----------------------------------------------------------------------
7430    // How orders ended: convert_ended_order, fetch_ended_orders, recover_ended_orders
7431    // -----------------------------------------------------------------------
7432
7433    /// An order row for client order id `cid` in `status`: 1 of 2 filled, for 105 quote.
7434    fn order_row(cid: &str, status: &str) -> serde_json::Value {
7435        serde_json::json!({
7436            "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
7437            "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
7438            "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "time": 1_700_000_000_000_i64,
7439            "updateTime": 1_700_000_060_000_i64,
7440        })
7441    }
7442
7443    fn get_order_row(row: serde_json::Value) -> binance_sdk::spot::rest_api::GetOrderResponse {
7444        serde_json::from_value(row).unwrap()
7445    }
7446
7447    fn spot_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
7448        OrderKey::new(
7449            ExchangeId::BinanceSpot,
7450            InstrumentNameExchange::new(instrument),
7451            StrategyId::new("strategy"),
7452            ClientOrderId::new(cid),
7453        )
7454    }
7455
7456    fn ended_state(
7457        key: &UnindexedOrderKey,
7458        row: serde_json::Value,
7459    ) -> Option<crate::order::state::UnindexedInactiveOrderState> {
7460        let order = convert_ended_order(&get_order_row(row), ExchangeId::BinanceSpot, key, None)?;
7461        assert_eq!(&order.key, key, "reported under the key asked");
7462        assert_eq!(order.quantity, Decimal::TWO);
7463        Some(order.state)
7464    }
7465
7466    /// An ended order row that does not say what filled leaves the fill unknown, not zero; a
7467    /// filled one filled its whole quantity regardless.
7468    #[test]
7469    fn an_ended_order_row_without_a_fill_reports_it_unknown() {
7470        use crate::order::state::{Expired, Filled, InactiveOrderState};
7471        let key = spot_key("BTCUSDT", "a");
7472        let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
7473        let id = OrderId::new("7");
7474        let row = |status: &str, executed_qty: Option<&str>| {
7475            let mut row = order_row("a", status);
7476            row["executedQty"] = executed_qty.map_or(serde_json::Value::Null, Into::into);
7477            row
7478        };
7479
7480        for executed_qty in [None, Some("not a number")] {
7481            assert_eq!(
7482                ended_state(&key, row("CANCELED", executed_qty)),
7483                Some(InactiveOrderState::Cancelled(Cancelled::new(
7484                    id.clone(),
7485                    ended,
7486                    None
7487                ))),
7488                "{executed_qty:?}"
7489            );
7490            assert_eq!(
7491                ended_state(&key, row("EXPIRED", executed_qty)),
7492                Some(InactiveOrderState::Expired(Expired::new(
7493                    id.clone(),
7494                    ended,
7495                    None
7496                ))),
7497                "{executed_qty:?}"
7498            );
7499            assert_eq!(
7500                ended_state(&key, row("FILLED", executed_qty)),
7501                Some(InactiveOrderState::FullyFilled(Filled::new(
7502                    id.clone(),
7503                    ended,
7504                    Decimal::TWO,
7505                    Some(Decimal::new(525, 1)),
7506                ))),
7507                "the whole quantity, at the quote over it: {executed_qty:?}"
7508            );
7509        }
7510    }
7511
7512    #[test]
7513    fn an_ended_order_row_says_how_the_order_ended() {
7514        use crate::order::state::{Expired, Filled, InactiveOrderState};
7515        let key = spot_key("BTCUSDT", "a");
7516        let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
7517        let id = OrderId::new("7");
7518
7519        let mut filled = order_row("a", "FILLED");
7520        filled["executedQty"] = "2".into();
7521        filled["cummulativeQuoteQty"] = "210".into();
7522        assert_eq!(
7523            ended_state(&key, filled),
7524            Some(InactiveOrderState::FullyFilled(Filled::new(
7525                id.clone(),
7526                ended,
7527                Decimal::TWO,
7528                Some(Decimal::from(105)),
7529            )))
7530        );
7531        assert_eq!(
7532            ended_state(&key, order_row("a", "CANCELED")),
7533            Some(InactiveOrderState::Cancelled(Cancelled::new(
7534                id.clone(),
7535                ended,
7536                Some(Decimal::ONE)
7537            )))
7538        );
7539        for status in ["EXPIRED", "EXPIRED_IN_MATCH"] {
7540            assert_eq!(
7541                ended_state(&key, order_row("a", status)),
7542                Some(InactiveOrderState::Expired(Expired::new(
7543                    id.clone(),
7544                    ended,
7545                    Some(Decimal::ONE)
7546                ))),
7547                "{status}"
7548            );
7549        }
7550        assert!(matches!(
7551            ended_state(&key, order_row("a", "REJECTED")),
7552            Some(InactiveOrderState::OpenFailed(OrderError::Rejected(
7553                ApiError::OrderRejected(_)
7554            )))
7555        ));
7556    }
7557
7558    #[test]
7559    fn a_live_or_unreadable_order_row_is_not_ended() {
7560        let key = spot_key("BTCUSDT", "a");
7561        for status in [
7562            "NEW",
7563            "PARTIALLY_FILLED",
7564            "PENDING_NEW",
7565            "PENDING_CANCEL",
7566            "SOMETHING_NEW",
7567        ] {
7568            assert_eq!(ended_state(&key, order_row("a", status)), None, "{status}");
7569        }
7570        let mut no_status = order_row("a", "FILLED");
7571        no_status["status"] = serde_json::Value::Null;
7572        assert_eq!(ended_state(&key, no_status), None);
7573        let mut no_side = order_row("a", "FILLED");
7574        no_side["side"] = serde_json::Value::Null;
7575        assert_eq!(ended_state(&key, no_side), None);
7576    }
7577
7578    #[test]
7579    fn an_unknown_order_or_symbol_is_recognised_by_its_code() {
7580        use binance_sdk::common::errors::ConnectorError;
7581        let bad = |code| {
7582            anyhow::Error::new(ConnectorError::BadRequestError {
7583                msg: "no".into(),
7584                code: Some(code),
7585            })
7586        };
7587        assert!(is_unknown_order(&bad(-2013)));
7588        assert!(is_unknown_order(&bad(-1121)));
7589        assert!(!is_unknown_order(&bad(-1100)));
7590        assert!(is_unknown_order(&anyhow::Error::new(
7591            ConnectorError::NotFoundError {
7592                msg: "no".into(),
7593                code: Some(-2013),
7594            }
7595        )));
7596        assert!(!is_unknown_order(&anyhow::anyhow!("-2013 in text only")));
7597    }
7598
7599    /// `new` keeps the client's signing clock read from Binance's server time, unless
7600    /// `server_time_sync` is off; `host_clock_lead` reports the reads.
7601    #[tokio::test(start_paused = true)]
7602    async fn the_host_clock_lead_is_the_clients_server_time_reads() {
7603        let config = BinanceSpotConfig::testnet("key".into(), "secret".into());
7604        assert!(config.server_time_sync);
7605        assert!(
7606            BinanceSpot::new(config.clone())
7607                .rate_limiter
7608                .signing_clock()
7609                .is_some()
7610        );
7611        let mut client = BinanceSpot::new(config.with_server_time_sync(false));
7612        assert!(client.rate_limiter.signing_clock().is_none());
7613        assert_eq!(client.host_clock_lead(), None);
7614
7615        let host_ms = Utc::now().timestamp_millis();
7616        let read: crate::client::binance::server_time::ReadServerTime =
7617            Arc::new(move || Box::pin(async move { Ok(host_ms - 2_000) }));
7618        client.rate_limiter = Arc::new(
7619            RateLimitTracker::new(WeightPool::Spot)
7620                .with_server_time(Some(ServerTime::new(SigningClock::default(), read))),
7621        );
7622        client
7623            .rate_limiter
7624            .wait_if_blocked(RequestKind::Query)
7625            .await
7626            .unwrap();
7627
7628        let lead = client.host_clock_lead().unwrap();
7629        assert!(
7630            lead >= TimeDelta::seconds(2) && lead < TimeDelta::seconds(3),
7631            "{lead}"
7632        );
7633    }
7634
7635    fn rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
7636        Arc::new(SpotRestApi::from_config(
7637            ConfigurationRestApi::builder()
7638                .api_key("key")
7639                .api_secret("secret")
7640                .base_path(server.uri())
7641                .build()
7642                .unwrap(),
7643        ))
7644    }
7645
7646    /// Answer `GET /api/v3/order` for client order id `cid` with `response`.
7647    async fn mount_order(
7648        server: &wiremock::MockServer,
7649        cid: &str,
7650        response: wiremock::ResponseTemplate,
7651    ) {
7652        wiremock::Mock::given(wiremock::matchers::method("GET"))
7653            .and(wiremock::matchers::path("/api/v3/order"))
7654            .and(wiremock::matchers::query_param("origClientOrderId", cid))
7655            .respond_with(response)
7656            .mount(server)
7657            .await;
7658    }
7659
7660    fn venue_error(code: i64) -> wiremock::ResponseTemplate {
7661        wiremock::ResponseTemplate::new(400)
7662            .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
7663    }
7664
7665    fn row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
7666        wiremock::ResponseTemplate::new(200).set_body_json(order_row(cid, status))
7667    }
7668
7669    /// The client order ids asked about with `GET /api/v3/order`.
7670    async fn looked_up(server: &wiremock::MockServer) -> Vec<String> {
7671        server
7672            .received_requests()
7673            .await
7674            .unwrap()
7675            .iter()
7676            .filter(|request| request.url.path() == "/api/v3/order")
7677            .filter_map(|request| {
7678                request
7679                    .url
7680                    .query_pairs()
7681                    .find(|(key, _)| key == "origClientOrderId")
7682                    .map(|(_, value)| value.into_owned())
7683            })
7684            .collect()
7685    }
7686
7687    /// A balance read is stamped [`REST_BALANCE_LAG`] before the request that read it was sent,
7688    /// never when the answer arrives: Binance's REST view can trail its stream, and a stream
7689    /// event the read does not yet show must stay more recent than it. A read refused for a rate
7690    /// limit and retried is stamped by the retry, the request that read the balances.
7691    #[tokio::test]
7692    async fn a_balance_read_is_stamped_the_rest_lag_before_its_request_was_sent() {
7693        use crate::client::binance::shared::REST_BALANCE_LAG;
7694        const ANSWER_DELAY: Duration = Duration::from_millis(200);
7695        let server = wiremock::MockServer::start().await;
7696        wiremock::Mock::given(wiremock::matchers::method("GET"))
7697            .and(wiremock::matchers::path("/api/v3/account"))
7698            .respond_with(
7699                wiremock::ResponseTemplate::new(429)
7700                    .insert_header("retry-after", "1")
7701                    .set_body_json(serde_json::json!({ "code": -1003, "msg": "Too much weight." })),
7702            )
7703            .up_to_n_times(1)
7704            .mount(&server)
7705            .await;
7706        wiremock::Mock::given(wiremock::matchers::method("GET"))
7707            .and(wiremock::matchers::path("/api/v3/account"))
7708            .respond_with(
7709                wiremock::ResponseTemplate::new(200)
7710                    .set_body_json(serde_json::json!({
7711                        "balances": [{ "asset": "BTC", "free": "1", "locked": "0" }],
7712                    }))
7713                    .set_delay(ANSWER_DELAY),
7714            )
7715            .mount(&server)
7716            .await;
7717        let mut client = <BinanceSpot as ExecutionClient>::new(
7718            BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7719        );
7720        client.rest = rest_at(&server);
7721
7722        let before = Utc::now();
7723        let fetched = client.fetch_balances(&[]).await.unwrap();
7724        let retried = Utc::now();
7725        let snapshot = client.account_snapshot(&[], &[]).await.unwrap();
7726        let after = Utc::now();
7727
7728        let within = |time, from, to| {
7729            assert!(
7730                from <= time + REST_BALANCE_LAG && time + REST_BALANCE_LAG + ANSWER_DELAY <= to,
7731                "{time} + the lag not within {from} ..= {to} - {ANSWER_DELAY:?}"
7732            );
7733        };
7734        assert_eq!((fetched.len(), snapshot.balances.len()), (1, 1));
7735        // Sent no sooner than the second after the refusal.
7736        within(
7737            fetched[0].time_exchange,
7738            before + Duration::from_secs(1),
7739            retried,
7740        );
7741        within(snapshot.balances[0].time_exchange, retried, after);
7742    }
7743
7744    /// With this host's clock ahead of Binance's, a balance read is stamped the REST lag before
7745    /// Binance's time when its request was sent, as the client's server-time reads bound it, not
7746    /// before this host's time: a stamp ahead of Binance's would have the engine drop the stream's
7747    /// balance events until Binance's clock caught up with it. So is a restatement read.
7748    #[tokio::test]
7749    async fn a_balance_read_is_stamped_on_binances_clock_when_the_host_clock_leads() {
7750        use crate::client::binance::shared::REST_BALANCE_LAG;
7751        const LEAD: TimeDelta = TimeDelta::seconds(60);
7752        let server = wiremock::MockServer::start().await;
7753        wiremock::Mock::given(wiremock::matchers::method("GET"))
7754            .and(wiremock::matchers::path("/api/v3/account"))
7755            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7756                serde_json::json!({ "balances": [{ "asset": "BTC", "free": "1", "locked": "0" }] }),
7757            ))
7758            .mount(&server)
7759            .await;
7760        let mut client = <BinanceSpot as ExecutionClient>::new(
7761            BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7762        );
7763        client.rest = rest_at(&server);
7764        client.rate_limiter = Arc::new(
7765            RateLimitTracker::new(WeightPool::Spot)
7766                .with_server_time(Some(ServerTime::trailing_host(LEAD))),
7767        );
7768        let clock = StreamClock::shared(client.rate_limiter.signing_clock());
7769
7770        let before = Utc::now();
7771        let fetched = client.fetch_balances(&[]).await.unwrap();
7772        let snapshot = client.account_snapshot(&[], &[]).await.unwrap();
7773        let restated = read_spot_restatement(&client.rest, &client.rate_limiter, &[], &clock, None)
7774            .await
7775            .unwrap();
7776        let after = Utc::now();
7777
7778        let [restated] = restated.as_slice() else {
7779            panic!("one balance: {restated:?}");
7780        };
7781        let AccountEventKind::BalanceSnapshot(restated) = &restated.kind else {
7782            panic!("a balance snapshot: {restated:?}");
7783        };
7784        assert_eq!((fetched.len(), snapshot.balances.len()), (1, 1));
7785        for time in [
7786            fetched[0].time_exchange,
7787            snapshot.balances[0].time_exchange,
7788            restated.0.time_exchange,
7789        ] {
7790            // Binance's time when the request was sent, by this host's clock. Signed on a server
7791            // time read, it can be up to `TRAILING_HOST_BEHIND` before `before`.
7792            let sent = time + REST_BALANCE_LAG + LEAD;
7793            assert!(
7794                before - ServerTime::TRAILING_HOST_BEHIND <= sent && sent <= after,
7795                "{time} + the lag and the lead not within {before} ..= {after}"
7796            );
7797        }
7798    }
7799
7800    /// A restatement read gets the account and returns each requested balance as a balance
7801    /// snapshot, stamped the REST lag before the read was sent; a read the venue fails is an error.
7802    #[tokio::test]
7803    async fn a_restatement_read_returns_the_requested_balances_as_snapshots() {
7804        use crate::client::binance::shared::REST_BALANCE_LAG;
7805        let server = wiremock::MockServer::start().await;
7806        wiremock::Mock::given(wiremock::matchers::method("GET"))
7807            .and(wiremock::matchers::path("/api/v3/account"))
7808            .respond_with(wiremock::ResponseTemplate::new(500))
7809            .up_to_n_times(1)
7810            .mount(&server)
7811            .await;
7812        wiremock::Mock::given(wiremock::matchers::method("GET"))
7813            .and(wiremock::matchers::path("/api/v3/account"))
7814            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7815                serde_json::json!({ "balances": [
7816                    { "asset": "BTC", "free": "1", "locked": "0.5" },
7817                    { "asset": "ETH", "free": "2", "locked": "0" },
7818                ] }),
7819            ))
7820            .mount(&server)
7821            .await;
7822        let (rest, limiter) = (
7823            rest_at(&server),
7824            Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7825        );
7826        let clock = SharedStreamClock::default();
7827        let btc = [AssetNameExchange::new("BTC")];
7828
7829        let failed = read_spot_restatement(&rest, &limiter, &btc, &clock, None).await;
7830        assert!(failed.is_err(), "{failed:?}");
7831
7832        let before = Utc::now();
7833        let sent = read_spot_restatement(&rest, &limiter, &btc, &clock, None)
7834            .await
7835            .unwrap();
7836        let after = Utc::now();
7837
7838        let [event] = sent.as_slice() else {
7839            panic!("one balance: {sent:?}");
7840        };
7841        assert_eq!(event.exchange, ExchangeId::BinanceSpot);
7842        let AccountEventKind::BalanceSnapshot(balance) = &event.kind else {
7843            panic!("a balance snapshot: {event:?}");
7844        };
7845        assert_eq!(balance.0.asset, btc[0]);
7846        assert_eq!(
7847            balance.0.balance,
7848            Balance::new(Decimal::new(15, 1), Decimal::ONE)
7849        );
7850        let read = balance.0.time_exchange + REST_BALANCE_LAG;
7851        assert!(
7852            before <= read && read <= after,
7853            "{read} not within {before} ..= {after}"
7854        );
7855    }
7856
7857    #[tokio::test]
7858    async fn fetch_ended_orders_reports_each_ended_order_under_its_key_and_omits_the_rest() {
7859        let server = wiremock::MockServer::start().await;
7860        mount_order(&server, "filled", row_response("filled", "FILLED")).await;
7861        mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
7862        mount_order(&server, "live", row_response("live", "PARTIALLY_FILLED")).await;
7863        mount_order(&server, "unknown", venue_error(-2013)).await;
7864        mount_order(&server, "no-symbol", venue_error(-1121)).await;
7865        let mut client = <BinanceSpot as ExecutionClient>::new(
7866            BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7867        );
7868        client.rest = rest_at(&server);
7869        let keys = ["filled", "cancelled", "live", "unknown"].map(|cid| spot_key("BTCUSDT", cid));
7870
7871        let mut ended = client
7872            .fetch_ended_orders(&[keys.to_vec(), vec![spot_key("NOSUCH", "no-symbol")]].concat())
7873            .await
7874            .unwrap();
7875
7876        ended.sort_by(|a, b| a.key.cid.cmp(&b.key.cid));
7877        let reported: Vec<_> = ended.iter().map(|order| &order.key).collect();
7878        assert_eq!(
7879            reported,
7880            [&keys[1], &keys[0]],
7881            "the strategy asked survives"
7882        );
7883        assert!(matches!(
7884            ended[0].state,
7885            crate::order::state::InactiveOrderState::Cancelled(_)
7886        ));
7887        assert!(matches!(
7888            ended[1].state,
7889            crate::order::state::InactiveOrderState::FullyFilled(_)
7890        ));
7891        assert!(client.fetch_ended_orders(&[]).await.unwrap().is_empty());
7892    }
7893
7894    #[tokio::test]
7895    async fn fetch_ended_orders_fails_when_any_lookup_fails() {
7896        let server = wiremock::MockServer::start().await;
7897        mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
7898        mount_order(&server, "refused", venue_error(-1100)).await;
7899        let mut client = <BinanceSpot as ExecutionClient>::new(
7900            BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7901        );
7902        client.rest = rest_at(&server);
7903
7904        let result = client
7905            .fetch_ended_orders(&[
7906                spot_key("BTCUSDT", "cancelled"),
7907                spot_key("BTCUSDT", "refused"),
7908            ])
7909            .await;
7910
7911        assert!(
7912            matches!(
7913                result,
7914                Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
7915            ),
7916            "{result:?}"
7917        );
7918    }
7919
7920    /// A venue whose BTCUSDT listing shows only `live`, and whose order lookups answer `gone` as
7921    /// cancelled after `delay` and `unknown` as unknown.
7922    async fn venue_after_a_disconnect(delay: Duration) -> wiremock::MockServer {
7923        let server = wiremock::MockServer::start().await;
7924        wiremock::Mock::given(wiremock::matchers::method("GET"))
7925            .and(wiremock::matchers::path("/api/v3/openOrders"))
7926            .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
7927            .respond_with(
7928                wiremock::ResponseTemplate::new(200)
7929                    .set_body_json(serde_json::json!([order_row("live", "NEW")])),
7930            )
7931            .mount(&server)
7932            .await;
7933        mount_order(
7934            &server,
7935            "gone",
7936            row_response("gone", "CANCELED").set_delay(delay),
7937        )
7938        .await;
7939        mount_order(&server, "unknown", venue_error(-2013)).await;
7940        server
7941    }
7942
7943    /// `live`, `gone` and `unknown`, all held as live on BTCUSDT.
7944    fn held_orders() -> SharedKnownLiveOrders {
7945        let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
7946        for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
7947            known.lock().live(
7948                &spot_key("BTCUSDT", cid),
7949                Decimal::TWO,
7950                OrderKind::Limit,
7951                &Open::new(
7952                    VenueOrderId::Assigned(OrderId::new(n.to_string())),
7953                    Utc::now(),
7954                    Decimal::ZERO,
7955                ),
7956            );
7957        }
7958        known
7959    }
7960
7961    fn btcusdt_unchecked() -> UncheckedOrders {
7962        let mut unchecked = UncheckedOrders::default();
7963        unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
7964        unchecked
7965    }
7966
7967    #[tokio::test]
7968    async fn a_reconnect_reports_how_an_unlisted_order_ended_and_forgets_an_unknown_one() {
7969        let server = venue_after_a_disconnect(Duration::ZERO).await;
7970        let known = held_orders();
7971        let mut unchecked = btcusdt_unchecked();
7972        let (tx, mut rx) = mpsc::unbounded_channel();
7973
7974        recover_spot_ended_orders(
7975            &rest_at(&server),
7976            &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7977            &known,
7978            &mut unchecked,
7979            &UnrecoveredFills::default(),
7980            &tx,
7981        )
7982        .await;
7983
7984        let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7985        let [event] = sent.as_slice() else {
7986            panic!("one order ended: {sent:?}");
7987        };
7988        let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
7989            panic!("an order snapshot: {event:?}");
7990        };
7991        assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
7992        assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
7993        assert!(matches!(
7994            snapshot.0.state,
7995            OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
7996        ));
7997        let mut asked = looked_up(&server).await;
7998        asked.sort();
7999        assert_eq!(
8000            asked,
8001            ["gone", "unknown"],
8002            "a listed order is not looked up"
8003        );
8004        let known = known.lock();
8005        assert!(known.contains(&ClientOrderId::new("live")));
8006        assert!(!known.contains(&ClientOrderId::new("gone")));
8007        assert!(
8008            !known.contains(&ClientOrderId::new("unknown")),
8009            "asking again cannot help"
8010        );
8011        assert!(unchecked.is_empty());
8012    }
8013
8014    #[tokio::test]
8015    async fn an_order_the_stream_reports_ending_during_the_check_is_not_reported_again() {
8016        let server = venue_after_a_disconnect(Duration::from_millis(300)).await;
8017        let known = held_orders();
8018        let mut unchecked = btcusdt_unchecked();
8019        let (tx, mut rx) = mpsc::unbounded_channel();
8020        let (rest, tracker) = (
8021            rest_at(&server),
8022            Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8023        );
8024        let unrecovered = UnrecoveredFills::default();
8025
8026        tokio::join!(
8027            recover_spot_ended_orders(&rest, &tracker, &known, &mut unchecked, &unrecovered, &tx,),
8028            async {
8029                tokio::time::sleep(Duration::from_millis(100)).await;
8030                known.lock().ended(&ClientOrderId::new("gone"));
8031            }
8032        );
8033
8034        assert!(rx.try_recv().is_err(), "the stream already reported it");
8035        assert!(unchecked.is_empty());
8036    }
8037
8038    #[tokio::test]
8039    async fn an_instrument_with_unrecovered_fills_is_not_checked_yet() {
8040        let server = venue_after_a_disconnect(Duration::ZERO).await;
8041        let known = held_orders();
8042        let mut unchecked = btcusdt_unchecked();
8043        let mut unrecovered = UnrecoveredFills::default();
8044        unrecovered.open(
8045            &[InstrumentNameExchange::new("BTCUSDT")],
8046            Utc::now() - chrono::Duration::minutes(1),
8047            Utc::now(),
8048        );
8049        let (tx, mut rx) = mpsc::unbounded_channel();
8050
8051        recover_spot_ended_orders(
8052            &rest_at(&server),
8053            &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8054            &known,
8055            &mut unchecked,
8056            &unrecovered,
8057            &tx,
8058        )
8059        .await;
8060
8061        assert!(rx.try_recv().is_err());
8062        assert!(server.received_requests().await.unwrap().is_empty());
8063        assert!(!unchecked.is_empty(), "it waits for its fills");
8064        assert_eq!(unchecked.next_due(&unrecovered), None, "woken by the gap");
8065    }
8066
8067    #[tokio::test]
8068    async fn a_failed_check_sends_nothing_and_waits_for_its_retry() {
8069        let server = wiremock::MockServer::start().await;
8070        wiremock::Mock::given(wiremock::matchers::method("GET"))
8071            .and(wiremock::matchers::path("/api/v3/openOrders"))
8072            .respond_with(venue_error(-1100))
8073            .mount(&server)
8074            .await;
8075        let server_rest = rest_at(&server);
8076        let known = held_orders();
8077        let mut unchecked = btcusdt_unchecked();
8078        let (tx, mut rx) = mpsc::unbounded_channel();
8079
8080        recover_spot_ended_orders(
8081            &server_rest,
8082            &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8083            &known,
8084            &mut unchecked,
8085            &UnrecoveredFills::default(),
8086            &tx,
8087        )
8088        .await;
8089
8090        assert!(rx.try_recv().is_err());
8091        assert!(!unchecked.is_empty());
8092        assert!(
8093            unchecked
8094                .next_due(&UnrecoveredFills::default())
8095                .is_some_and(|due| due > tokio::time::Instant::now()),
8096            "retried after a backoff"
8097        );
8098        assert!(
8099            known.lock().contains(&ClientOrderId::new("gone")),
8100            "still held"
8101        );
8102    }
8103
8104    #[tokio::test]
8105    async fn an_unlisted_order_the_lookup_finds_live_stays_held() {
8106        let server = wiremock::MockServer::start().await;
8107        wiremock::Mock::given(wiremock::matchers::method("GET"))
8108            .and(wiremock::matchers::path("/api/v3/openOrders"))
8109            .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8110            .mount(&server)
8111            .await;
8112        for (cid, status) in [
8113            ("live", "NEW"),
8114            ("gone", "PENDING_CANCEL"),
8115            ("unknown", "WHAT"),
8116        ] {
8117            mount_order(&server, cid, row_response(cid, status)).await;
8118        }
8119        let known = held_orders();
8120        let mut unchecked = btcusdt_unchecked();
8121        let (tx, mut rx) = mpsc::unbounded_channel();
8122
8123        recover_spot_ended_orders(
8124            &rest_at(&server),
8125            &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8126            &known,
8127            &mut unchecked,
8128            &UnrecoveredFills::default(),
8129            &tx,
8130        )
8131        .await;
8132
8133        assert!(rx.try_recv().is_err(), "none has ended");
8134        let known = known.lock();
8135        for cid in ["live", "gone", "unknown"] {
8136            assert!(
8137                known.contains(&ClientOrderId::new(cid)),
8138                "{cid}: only an order Binance does not know is dropped"
8139            );
8140        }
8141        assert!(unchecked.is_empty());
8142    }
8143
8144    #[tokio::test]
8145    async fn one_instrument_failing_does_not_hold_up_another() {
8146        let server = venue_after_a_disconnect(Duration::ZERO).await;
8147        wiremock::Mock::given(wiremock::matchers::method("GET"))
8148            .and(wiremock::matchers::path("/api/v3/openOrders"))
8149            .and(wiremock::matchers::query_param("symbol", "ETHUSDT"))
8150            .respond_with(venue_error(-1100))
8151            .mount(&server)
8152            .await;
8153        let known = held_orders();
8154        let eth = spot_key("ETHUSDT", "eth");
8155        known.lock().live(
8156            &eth,
8157            Decimal::TWO,
8158            OrderKind::Limit,
8159            &Open::new(
8160                VenueOrderId::Assigned(OrderId::new("9")),
8161                Utc::now(),
8162                Decimal::ZERO,
8163            ),
8164        );
8165        let mut unchecked = btcusdt_unchecked();
8166        unchecked.open([eth.instrument.clone()]);
8167        let (tx, mut rx) = mpsc::unbounded_channel();
8168
8169        recover_spot_ended_orders(
8170            &rest_at(&server),
8171            &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8172            &known,
8173            &mut unchecked,
8174            &UnrecoveredFills::default(),
8175            &tx,
8176        )
8177        .await;
8178
8179        let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
8180        assert_eq!(sent.len(), 1, "BTCUSDT's ended order: {sent:?}");
8181        assert!(!unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
8182        assert!(
8183            unchecked.contains(&eth.instrument),
8184            "ETHUSDT waits for its retry"
8185        );
8186        assert!(known.lock().contains(&eth.cid));
8187    }
8188
8189    #[tokio::test]
8190    async fn a_check_dropped_part_way_changes_nothing() {
8191        let server = venue_after_a_disconnect(Duration::from_millis(500)).await;
8192        let known = held_orders();
8193        let mut unchecked = btcusdt_unchecked();
8194        let (tx, mut rx) = mpsc::unbounded_channel();
8195
8196        let dropped = tokio::time::timeout(
8197            Duration::from_millis(100),
8198            recover_spot_ended_orders(
8199                &rest_at(&server),
8200                &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8201                &known,
8202                &mut unchecked,
8203                &UnrecoveredFills::default(),
8204                &tx,
8205            ),
8206        )
8207        .await;
8208
8209        assert!(dropped.is_err(), "the lookup of `gone` was still waiting");
8210        assert!(rx.try_recv().is_err());
8211        assert!(known.lock().contains(&ClientOrderId::new("gone")));
8212        assert!(unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
8213        assert!(
8214            unchecked
8215                .next_due(&UnrecoveredFills::default())
8216                .is_some_and(|due| due <= tokio::time::Instant::now()),
8217            "still due, not charged a failure"
8218        );
8219    }
8220
8221    /// Binance's documented `account/commission` answer for `symbol`, with distinct rates.
8222    fn commission_answer(symbol: &str) -> serde_json::Value {
8223        let part = |maker: &str, taker: &str, buyer: &str, seller: &str| serde_json::json!({"maker": maker, "taker": taker, "buyer": buyer, "seller": seller});
8224        serde_json::json!({
8225            "symbol": symbol,
8226            "standardCommission": part("0.00000010", "0.00000020", "0.00000030", "0.00000040"),
8227            "specialCommission": part("0.01000000", "0.02000000", "0.03000000", "0.04000000"),
8228            "taxCommission": part("0.00000112", "0.00000114", "0.00000118", "0.00000116"),
8229            "discount": {
8230                "enabledForAccount": true,
8231                "enabledForSymbol": true,
8232                "discountAsset": "BNB",
8233                "discount": "0.75000000"
8234            }
8235        })
8236    }
8237
8238    #[tokio::test]
8239    async fn spot_commission_rates_are_read_signed_for_the_symbol() {
8240        use crate::client::binance::{CommissionDiscount, CommissionPart};
8241        use rust_decimal_macros::dec;
8242        use rustrade_instrument::asset::name::AssetNameExchange;
8243        let server = wiremock::MockServer::start().await;
8244        wiremock::Mock::given(wiremock::matchers::method("GET"))
8245            .and(wiremock::matchers::path("/api/v3/account/commission"))
8246            .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
8247            .and(wiremock::matchers::header("X-MBX-APIKEY", "key"))
8248            .respond_with(
8249                wiremock::ResponseTemplate::new(200).set_body_json(commission_answer("BTCUSDT")),
8250            )
8251            .expect(1)
8252            .mount(&server)
8253            .await;
8254        let mut client = <BinanceSpot as ExecutionClient>::new(
8255            BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
8256        );
8257        client.rest = rest_at(&server);
8258        let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8259
8260        let rates = client.fetch_commission_rates(&btcusdt).await.unwrap();
8261        let requests = server.received_requests().await.unwrap();
8262        assert!(
8263            requests[0]
8264                .url
8265                .query_pairs()
8266                .any(|(key, _)| key == "signature"),
8267            "signed: {}",
8268            requests[0].url
8269        );
8270        assert_eq!(rates.instrument, btcusdt);
8271        assert_eq!(
8272            rates.special,
8273            CommissionPart::new(dec!(0.01), dec!(0.02), dec!(0.03), dec!(0.04))
8274        );
8275        assert_eq!(
8276            rates.discount,
8277            Some(CommissionDiscount::new(
8278                true,
8279                true,
8280                Some(AssetNameExchange::new("BNB")),
8281                dec!(0.75)
8282            ))
8283        );
8284    }
8285
8286    #[tokio::test]
8287    async fn spot_commission_rates_of_an_unlisted_symbol_name_it() {
8288        let server = wiremock::MockServer::start().await;
8289        wiremock::Mock::given(wiremock::matchers::path("/api/v3/account/commission"))
8290            .respond_with(
8291                wiremock::ResponseTemplate::new(400)
8292                    .set_body_json(serde_json::json!({"code": -1121, "msg": "Invalid symbol."})),
8293            )
8294            .mount(&server)
8295            .await;
8296        let mut client = <BinanceSpot as ExecutionClient>::new(
8297            BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
8298        );
8299        client.rest = rest_at(&server);
8300        let nope = InstrumentNameExchange::new("NOPEUSDT");
8301        match client.fetch_commission_rates(&nope).await {
8302            Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(instrument, _))) => {
8303                assert_eq!(instrument, nope);
8304            }
8305            other => panic!("{other:?}"),
8306        }
8307    }
8308}