1use super::shared::{
37 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
38 CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
39 MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing, RateLimitTracker, RequestKind,
40 SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, UnrecoveredFills, UserDataFrame, WeightPool,
41 classify_order_kind_tif, classify_rest_query_error, classify_ws_order_error,
42 convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
43 dedup_key_from_event, drop_after, gap_failed, gap_time, is_duplicate, is_handshake_rate_limit,
44 log_unrecognised_frame, new_dedup_cache, parse_user_data_frame, recovered_order_totals,
45 response_decode_error, rest_call_with_retry, unix_ms,
46};
47use crate::{
48 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
49 UnindexedAccountSnapshot,
50 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
51 client::ExecutionClient,
52 emit_stream_terminated,
53 error::{
54 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
55 UnindexedOrderError,
56 },
57 order::{
58 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
59 id::{OrderId, StrategyId, VenueOrderId},
60 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
61 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
62 },
63 parse_env_bool,
64 position::PositionReport,
65 trade::{AssetFees, Trade, TradeId},
66};
67use binance_sdk::{
68 common::{
69 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
70 models::WebsocketEvent,
71 },
72 spot::{
73 SpotRestApi, SpotWsApi,
74 rest_api::{GetAccountParams, GetOpenOrdersParams, MyTradesParams, RestApi},
75 websocket_api::{
76 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
77 OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
78 WebsocketApiHandle,
79 },
80 },
81};
82use chrono::{DateTime, TimeZone, Utc};
83use futures::stream::BoxStream;
84use rust_decimal::Decimal;
85use rustrade_instrument::{
86 Side,
87 asset::name::AssetNameExchange,
88 exchange::ExchangeId,
89 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
90};
91use serde::Deserialize;
92use smol_str::format_smolstr;
93use std::{
94 str::FromStr,
95 sync::{
96 Arc,
97 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
98 },
99 time::Duration,
100};
101use tokio::sync::{RwLock, mpsc, oneshot};
102use tracing::{debug, error, info, trace, warn};
103
104#[derive(Clone, Deserialize)]
111pub struct BinanceSpotConfig {
112 api_key: String,
115 secret_key: String,
116
117 #[serde(default = "default_testnet")]
119 pub testnet: bool,
120}
121
122fn default_testnet() -> bool {
128 true
129}
130
131impl std::fmt::Debug for BinanceSpotConfig {
133 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
134 f.debug_struct("BinanceSpotConfig")
135 .field("api_key", &"***")
136 .field("secret_key", &"***")
137 .field("testnet", &self.testnet)
138 .finish()
139 }
140}
141
142impl BinanceSpotConfig {
143 pub fn new(api_key: String, secret_key: String) -> Self {
146 Self::testnet(api_key, secret_key)
147 }
148
149 pub fn testnet(api_key: String, secret_key: String) -> Self {
151 Self {
152 api_key,
153 secret_key,
154 testnet: true,
155 }
156 }
157
158 pub fn production(api_key: String, secret_key: String) -> Self {
163 Self {
164 api_key,
165 secret_key,
166 testnet: false,
167 }
168 }
169
170 pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
189 let api_key = match std::env::var("BINANCE_API_KEY") {
190 Ok(value) => value,
191 Err(std::env::VarError::NotPresent) => {
192 return Err(BinanceSpotConfigError::MissingApiKey);
193 }
194 Err(std::env::VarError::NotUnicode(_)) => {
195 return Err(BinanceSpotConfigError::InvalidApiKey);
196 }
197 };
198 let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
199 Ok(value) => value,
200 Err(std::env::VarError::NotPresent) => {
201 return Err(BinanceSpotConfigError::MissingSecretKey);
202 }
203 Err(std::env::VarError::NotUnicode(_)) => {
204 return Err(BinanceSpotConfigError::InvalidSecretKey);
205 }
206 };
207 let testnet = match std::env::var("BINANCE_TESTNET") {
208 Ok(value) => {
209 parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
210 }
211 Err(std::env::VarError::NotPresent) => true,
212 Err(std::env::VarError::NotUnicode(value)) => {
215 return Err(BinanceSpotConfigError::InvalidTestnet(
216 value.to_string_lossy().into_owned(),
217 ));
218 }
219 };
220
221 if testnet {
222 Ok(Self::testnet(api_key, secret_key))
223 } else {
224 Ok(Self::production(api_key, secret_key))
225 }
226 }
227
228 pub fn api_key(&self) -> &str {
230 &self.api_key
231 }
232}
233
234#[derive(Debug, PartialEq, thiserror::Error)]
235pub enum BinanceSpotConfigError {
236 #[error("BINANCE_API_KEY environment variable not set")]
237 MissingApiKey,
238
239 #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
242 InvalidApiKey,
243
244 #[error("BINANCE_SECRET_KEY environment variable not set")]
245 MissingSecretKey,
246
247 #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
248 InvalidSecretKey,
249
250 #[error("BINANCE_TESTNET must be true or false, got {0}")]
251 InvalidTestnet(String),
252}
253
254#[derive(Debug)]
261enum WsApiUnavailable {
262 RateLimited(String),
264 Connect(String),
266}
267
268impl WsApiUnavailable {
269 fn into_order_error(self) -> UnindexedOrderError {
272 match self {
273 Self::RateLimited(msg) => {
274 warn!(%msg, "BinanceSpot WS-API rate-limited, request not sent");
275 UnindexedOrderError::Rejected(ApiError::RateLimit)
276 }
277 Self::Connect(msg) => UnindexedOrderError::Connectivity(ConnectivityError::Socket(msg)),
278 }
279 }
280}
281
282#[derive(Clone)]
298pub struct BinanceSpot {
299 config: Arc<BinanceSpotConfig>,
300 rest: Arc<RestApi>,
301 ws_handle: WebsocketApiHandle,
303 ws_api: Arc<RwLock<Option<WebsocketApi>>>,
309 rate_limiter: Arc<RateLimitTracker>,
311}
312
313impl std::fmt::Debug for BinanceSpot {
314 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315 f.debug_struct("BinanceSpot")
316 .field("testnet", &self.config.testnet)
317 .finish_non_exhaustive()
318 }
319}
320
321impl BinanceSpot {
322 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
326 let rest_config = ConfigurationRestApi::builder()
327 .api_key(config.api_key.clone())
328 .api_secret(config.secret_key.clone())
329 .build()
330 .expect("failed to build Binance REST configuration");
331
332 Arc::new(if config.testnet {
333 SpotRestApi::testnet(rest_config)
334 } else {
335 SpotRestApi::production(rest_config)
336 })
337 }
338
339 #[allow(clippy::expect_used)] fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
343 let ws_config = ConfigurationWebsocketApi::builder()
344 .api_key(config.api_key.clone())
345 .api_secret(config.secret_key.clone())
346 .build()
347 .expect("failed to build Binance WebSocket configuration");
348
349 if config.testnet {
350 SpotWsApi::testnet(ws_config)
351 } else {
352 SpotWsApi::production(ws_config)
353 }
354 }
355
356 async fn get_ws_api(&self) -> Result<WebsocketApi, WsApiUnavailable> {
365 {
367 let guard = self.ws_api.read().await;
368 if let Some(ref ws) = *guard {
369 return Ok(ws.clone());
370 }
371 }
372 let mut guard = self.ws_api.write().await;
379 if let Some(ref ws) = *guard {
381 return Ok(ws.clone());
382 }
383 if self.rate_limiter.is_blocked() {
384 return Err(WsApiUnavailable::RateLimited(
385 "rate-limit cooldown active, not connecting the WS-API session".into(),
386 ));
387 }
388 let ws = match tokio::time::timeout(
389 Duration::from_secs(CONNECT_TIMEOUT_SECS),
390 self.ws_handle.connect(),
391 )
392 .await
393 {
394 Ok(Ok(ws)) => ws,
395 Ok(Err(e)) if is_handshake_rate_limit(&e) => {
396 self.rate_limiter.on_rate_limited(None);
397 return Err(WsApiUnavailable::RateLimited(format!("{e:#}")));
398 }
399 Ok(Err(e)) => return Err(WsApiUnavailable::Connect(format!("{e:#}"))),
400 Err(_) => {
401 return Err(WsApiUnavailable::Connect(format!(
402 "BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s"
403 )));
404 }
405 };
406 *guard = Some(ws.clone());
407 Ok(ws)
408 }
409
410 async fn clear_ws_api(&self) {
413 let ws = {
424 let mut guard = self.ws_api.write().await;
425 guard.take()
426 };
427 if let Some(ws) = ws {
428 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
429 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
430 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
431 Ok(Ok(())) => {}
432 }
433 }
434 }
435}
436
437async fn fetch_open_orders_for_instrument(
444 rest: Arc<RestApi>,
445 rate_limiter: Arc<RateLimitTracker>,
446 instrument: InstrumentNameExchange,
447) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
448 let symbol_str = instrument.name().to_string();
450 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
451 let sym = symbol_str.clone();
452 Box::pin(async move {
453 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
454 rest.get_open_orders(params).await
455 })
456 })
457 .await
458 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
459
460 let orders_data = response.data().await.map_err(response_decode_error)?;
461
462 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceSpot, &instrument);
463
464 Ok((instrument, listing))
465}
466
467async fn fetch_all_open_orders(
472 rest: Arc<RestApi>,
473 rate_limiter: Arc<RateLimitTracker>,
474) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
475 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
476 Box::pin(async move {
477 let params = GetOpenOrdersParams::builder().build()?;
478 rest.get_open_orders(params).await
479 })
480 })
481 .await
482 .map_err(|e| classify_rest_query_error(&e, None))?;
483
484 let orders_data = response.data().await.map_err(response_decode_error)?;
485
486 let orders = orders_data
487 .into_iter()
488 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot))
489 .collect();
490
491 Ok(orders)
492}
493
494async fn paginate_my_trades(
505 rest: &Arc<RestApi>,
506 rate_limiter: &Arc<RateLimitTracker>,
507 instrument: &InstrumentNameExchange,
508 from: MyTradesFrom,
509 kind: RequestKind,
510) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
511 let symbol_str = instrument.name().to_string();
513 let mut all_pages = Vec::new();
518 let mut cursor: Option<i64> = None;
519 loop {
520 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
522 let sym = symbol_str.clone();
523 Box::pin(async move {
524 #[allow(clippy::cast_possible_truncation)]
526 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
527 let params = match (from, fid) {
528 (
529 MyTradesFrom::Time(start_time_ms)
530 | MyTradesFrom::Span {
531 start: start_time_ms,
532 ..
533 },
534 None,
535 ) => builder.start_time(start_time_ms),
536 (MyTradesFrom::Time(_) | MyTradesFrom::Span { .. }, Some(id)) => {
537 builder.from_id(id)
538 }
539 (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
540 (MyTradesFrom::Order(order_id), Some(id)) => {
541 builder.order_id(order_id).from_id(id)
542 }
543 }
544 .build()?;
545 rest.my_trades(params).await
546 })
547 })
548 .await
549 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
550
551 let mut page = response.data().await.map_err(response_decode_error)?;
552
553 let page_len = page.len();
554 let last_id = page.last().and_then(|t| t.id);
555 let past_end = match from {
557 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
558 MyTradesFrom::Time(_) | MyTradesFrom::Order(_) => false,
559 };
560 all_pages.extend(page);
561
562 if past_end || page_len < BINANCE_MAX_TRADES {
563 break;
564 }
565 match last_id {
566 Some(id) => {
567 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
568 match id.checked_add(1) {
569 Some(next) => cursor = Some(next),
570 None => break, }
572 }
573 None => {
574 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
575 break;
576 }
577 }
578 }
579 Ok(all_pages)
580}
581
582impl ExecutionClient for BinanceSpot {
587 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
588
589 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
592 &[InstrumentKindDiscriminant::Spot];
593 type Config = BinanceSpotConfig;
594 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
595
596 fn new(config: Self::Config) -> Self {
601 let rest = Self::build_rest(&config);
602 let ws_handle = Self::build_ws_handle(&config);
603 Self {
604 config: Arc::new(config),
605 rest,
606 ws_handle,
607 ws_api: Arc::new(RwLock::new(None)),
608 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Spot)),
609 }
610 }
611
612 async fn account_snapshot(
613 &self,
614 assets: &[AssetNameExchange],
615 instruments: &[InstrumentNameExchange],
616 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
617 let response =
619 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
620 Box::pin(async move {
621 let params = GetAccountParams::builder().build()?;
622 rest.get_account(params).await
623 })
624 })
625 .await
626 .map_err(|e| classify_rest_query_error(&e, None))?;
627
628 let account = response.data().await.map_err(response_decode_error)?;
629
630 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
632
633 use futures::{StreamExt as _, TryStreamExt};
639 let instrument_snapshots: Vec<_> =
640 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
641 fetch_open_orders_for_instrument(
642 self.rest.clone(),
643 self.rate_limiter.clone(),
644 instrument,
645 )
646 }))
647 .buffer_unordered(8)
648 .map(|result| {
649 let (inst, listing) = result?;
650 let wrapped = listing
651 .orders
652 .into_iter()
653 .map(|o| Order {
654 key: o.key,
655 side: o.side,
656 price: o.price,
657 quantity: o.quantity,
658 kind: o.kind,
659 time_in_force: o.time_in_force,
660 state: OrderState::active(o.state),
661 })
662 .collect();
663 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
664 inst,
665 wrapped,
666 listing.complete,
667 PositionReport::Unreported,
668 None,
669 ))
670 })
671 .try_collect()
672 .await?;
673
674 Ok(AccountSnapshot::new(
675 ExchangeId::BinanceSpot,
676 balances,
677 instrument_snapshots,
678 ))
679 }
680
681 async fn account_stream(
726 &self,
727 _assets: &[AssetNameExchange],
731 instruments: &[InstrumentNameExchange],
732 ) -> Result<Self::AccountStream, UnindexedClientError> {
733 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
748 let dedup = new_dedup_cache();
749 let ws_handle = self.ws_handle.clone();
750 let rest = self.rest.clone();
751 let rate_limiter = self.rate_limiter.clone();
752 let instruments = instruments.to_vec();
753 debug_assert!(
757 instruments.iter().all(|i| i.name().len() <= 23),
758 "instrument name exceeds SmolStr inline capacity: {:?}",
759 instruments.iter().find(|i| i.name().len() > 23)
760 );
761
762 let initial_ws = ws_handle.connect().await.map_err(|e| {
766 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
767 })?;
768
769 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
771 .build()
772 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
773
774 match initial_ws
775 .user_data_stream_subscribe_signature(params)
776 .await
777 {
778 Ok(_) => {}
779 Err(e) => {
780 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
785 Ok(Err(de)) => {
786 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
787 }
788 Err(_) => {
789 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
790 }
791 Ok(Ok(())) => {}
792 }
793 return Err(UnindexedClientError::Internal(e.to_string()));
794 }
795 }
796
797 let cm_handle = tokio::spawn(connection_manager(
809 tx,
810 dedup,
811 ws_handle,
812 rest,
813 rate_limiter,
814 instruments,
815 Some(initial_ws),
816 ));
817
818 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
822 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
823 Ok(futures::StreamExt::boxed(guarded_stream))
824 }
825
826 async fn cancel_order(
827 &self,
828 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
829 ) -> Option<UnindexedOrderResponseCancel> {
830 let instrument = request.key.instrument.clone();
831 let key = OrderKey {
832 exchange: request.key.exchange,
833 instrument: instrument.clone(),
834 strategy: request.key.strategy.clone(),
835 cid: request.key.cid.clone(),
836 };
837
838 let ws = match self.get_ws_api().await {
839 Ok(ws) => ws,
840 Err(unavailable) => {
841 return Some(UnindexedOrderResponseCancel {
842 key,
843 state: Err(unavailable.into_order_error()),
844 });
845 }
846 };
847
848 let mut params_builder =
851 OrderCancelParams::builder(request.key.instrument.name().to_string());
852
853 if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
855 if let Ok(id) = order_id.0.parse::<i64>() {
856 params_builder = params_builder.order_id(id);
857 } else {
858 error!(
863 order_id = %order_id.0,
864 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
865 );
866 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
867 }
868 } else {
869 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
870 }
871
872 let params = match params_builder.build() {
873 Ok(p) => p,
874 Err(e) => {
875 error!(%e, "BinanceSpot failed to build cancel order params");
876 return Some(UnindexedOrderResponseCancel {
877 key,
878 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
879 e.to_string(),
880 ))),
881 });
882 }
883 };
884
885 let sent_ms = unix_ms();
886 let result = ws.order_cancel(params).await;
887 if let Ok(response) = &result {
889 self.rate_limiter
890 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
891 }
892 match result {
893 Ok(response) => match response.data() {
894 Ok(data) => {
895 let time_exchange = data
896 .transact_time
897 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
898 .unwrap_or_else(Utc::now);
899
900 let exchange_order_id = match data.order_id {
901 Some(id) => OrderId(format_smolstr!("{id}")),
902 None => {
903 error!("BinanceSpot cancel response missing orderId");
904 return Some(UnindexedOrderResponseCancel {
905 key,
906 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
907 "cancel response missing orderId".into(),
908 ))),
909 });
910 }
911 };
912
913 let filled_qty = data
914 .executed_qty
915 .as_deref()
916 .and_then(|q| Decimal::from_str(q).ok())
917 .unwrap_or(Decimal::ZERO);
918
919 Some(UnindexedOrderResponseCancel {
920 key,
921 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
922 })
923 }
924 Err(e) => {
925 Some(UnindexedOrderResponseCancel {
927 key,
928 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
929 e.to_string(),
930 ))),
931 })
932 }
933 },
934 Err(e) => {
935 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
943 if matches!(order_err, OrderError::Rejected(ApiError::RateLimit)) {
947 self.rate_limiter.on_rate_limited(None);
948 }
949 Some(UnindexedOrderResponseCancel {
954 key,
955 state: Err(order_err),
956 })
957 } else {
958 self.clear_ws_api().await;
961 Some(UnindexedOrderResponseCancel {
962 key,
963 state: Err(UnindexedOrderError::Connectivity(
964 ConnectivityError::Socket(format!("{e:#}")),
965 )),
966 })
967 }
968 }
969 }
970 }
971
972 async fn open_order(
973 &self,
974 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
975 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
976 let instrument = request.key.instrument.clone();
977 let side = request.state.side;
978 let price = request.state.price;
979 let quantity = request.state.quantity;
980 let kind = request.state.kind;
981 let time_in_force = request.state.time_in_force;
982 let cid = request.key.cid.clone();
983
984 let order_key = OrderKey::new(
985 ExchangeId::BinanceSpot,
986 instrument.clone(),
987 request.key.strategy.clone(),
988 cid.clone(),
989 );
990
991 let ws = match self.get_ws_api().await {
992 Ok(ws) => ws,
993 Err(unavailable) => {
994 return Some(Order {
995 key: order_key,
996 side,
997 price,
998 quantity,
999 kind,
1000 time_in_force,
1001 state: OrderState::inactive(unavailable.into_order_error()),
1002 });
1003 }
1004 };
1005
1006 let binance_side = match side {
1007 Side::Buy => OrderPlaceSideEnum::Buy,
1008 Side::Sell => OrderPlaceSideEnum::Sell,
1009 };
1010
1011 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
1012 Some(converted) => converted,
1013 None => {
1014 return Some(Order {
1015 key: order_key,
1016 side,
1017 price,
1018 quantity,
1019 kind,
1020 time_in_force,
1021 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1022 "Binance Spot does not yet support OrderKind::{kind:?}"
1023 ))),
1024 });
1025 }
1026 };
1027
1028 let mut params_builder =
1035 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
1036 .quantity(quantity)
1037 .new_client_order_id(cid.0.to_string());
1038
1039 match kind {
1042 OrderKind::Limit => {
1043 params_builder = params_builder.price(price);
1044 }
1045 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
1046 params_builder = params_builder.stop_price(trigger_price);
1047 }
1048 OrderKind::StopLimit { trigger_price }
1049 | OrderKind::TakeProfitLimit { trigger_price } => {
1050 params_builder = params_builder.price(price).stop_price(trigger_price);
1051 }
1052 OrderKind::TrailingStop {
1053 offset,
1054 offset_type,
1055 } => {
1056 let basis_points: i32 = match offset_type {
1058 TrailingOffsetType::BasisPoints => {
1059 let Ok(bp) = i32::try_from(offset) else {
1060 return Some(Order {
1061 key: order_key,
1062 side,
1063 price,
1064 quantity,
1065 kind,
1066 time_in_force,
1067 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1068 format!(
1069 "TrailingStop basis-point offset {offset} overflows i32 \
1070 (Binance trailingDelta filter typically caps at 2000)"
1071 ),
1072 )),
1073 });
1074 };
1075 bp
1076 }
1077 TrailingOffsetType::Percentage => {
1078 let Ok(bp) = i32::try_from(offset * Decimal::from(100)) else {
1079 return Some(Order {
1080 key: order_key,
1081 side,
1082 price,
1083 quantity,
1084 kind,
1085 time_in_force,
1086 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1087 format!(
1088 "TrailingStop percentage offset {offset} overflows i32 \
1089 after scaling to basis points"
1090 ),
1091 )),
1092 });
1093 };
1094 bp
1095 }
1096 TrailingOffsetType::Absolute => {
1097 return Some(Order {
1101 key: order_key,
1102 side,
1103 price,
1104 quantity,
1105 kind,
1106 time_in_force,
1107 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1108 "TrailingStop with Absolute offset is not supported by Binance; \
1109 convert to basis points: (absolute / price) * 10000"
1110 .into(),
1111 )),
1112 });
1113 }
1114 };
1115 params_builder = params_builder.trailing_delta(basis_points);
1116 }
1117 _ => {}
1121 }
1122
1123 if let Some(tif) = binance_tif {
1124 params_builder = params_builder.time_in_force(tif);
1125 }
1126
1127 let params = match params_builder.build() {
1128 Ok(p) => p,
1129 Err(e) => {
1130 error!(%e, "BinanceSpot failed to build new order params");
1131 return Some(Order {
1132 key: order_key,
1133 side,
1134 price,
1135 quantity,
1136 kind,
1137 time_in_force,
1138 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1139 e.to_string(),
1140 ))),
1141 });
1142 }
1143 };
1144
1145 let sent_ms = unix_ms();
1146 let result = ws.order_place(params).await;
1147 if let Ok(response) = &result {
1149 self.rate_limiter
1150 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1151 }
1152 match result {
1153 Ok(response) => match response.data() {
1154 Ok(data) => {
1155 let time_exchange = data
1156 .transact_time
1157 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1158 .unwrap_or_else(Utc::now);
1159
1160 let exchange_order_id = match data.order_id {
1161 Some(id) => OrderId(format_smolstr!("{id}")),
1162 None => {
1163 error!("BinanceSpot open_order response missing orderId");
1164 return Some(Order {
1165 key: order_key,
1166 side,
1167 price,
1168 quantity,
1169 kind,
1170 time_in_force,
1171 state: OrderState::inactive(OrderError::Rejected(
1172 ApiError::OrderRejected(
1173 "open_order response missing orderId".into(),
1174 ),
1175 )),
1176 });
1177 }
1178 };
1179
1180 let filled_qty = data
1181 .executed_qty
1182 .as_deref()
1183 .and_then(|q| Decimal::from_str(q).ok())
1184 .unwrap_or(Decimal::ZERO);
1185
1186 let state = if filled_qty >= quantity {
1187 OrderState::fully_filled(Filled::new(
1189 exchange_order_id,
1190 time_exchange,
1191 filled_qty,
1192 None, ))
1194 } else {
1195 OrderState::active(Open::new(
1197 VenueOrderId::Assigned(exchange_order_id),
1198 time_exchange,
1199 filled_qty,
1200 ))
1201 };
1202
1203 Some(Order {
1204 key: order_key,
1205 side,
1206 price,
1207 quantity,
1208 kind,
1209 time_in_force,
1210 state,
1211 })
1212 }
1213 Err(e) => {
1214 Some(Order {
1216 key: order_key,
1217 side,
1218 price,
1219 quantity,
1220 kind,
1221 time_in_force,
1222 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1223 e.to_string(),
1224 ))),
1225 })
1226 }
1227 },
1228 Err(e) => {
1229 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1237 if matches!(order_err, OrderError::Rejected(ApiError::RateLimit)) {
1241 self.rate_limiter.on_rate_limited(None);
1242 }
1243 Some(Order {
1248 key: order_key,
1249 side,
1250 price,
1251 quantity,
1252 kind,
1253 time_in_force,
1254 state: OrderState::inactive(order_err),
1255 })
1256 } else {
1257 self.clear_ws_api().await;
1260 Some(Order {
1261 key: order_key,
1262 side,
1263 price,
1264 quantity,
1265 kind,
1266 time_in_force,
1267 state: OrderState::inactive(OrderError::Connectivity(
1268 ConnectivityError::Socket(format!("{e:#}")),
1269 )),
1270 })
1271 }
1272 }
1273 }
1274 }
1275
1276 async fn fetch_balances(
1277 &self,
1278 assets: &[AssetNameExchange],
1279 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1280 let response =
1281 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1282 Box::pin(async move {
1283 let params = GetAccountParams::builder().build()?;
1284 rest.get_account(params).await
1285 })
1286 })
1287 .await
1288 .map_err(|e| classify_rest_query_error(&e, None))?;
1289
1290 let account = response.data().await.map_err(response_decode_error)?;
1291
1292 Ok(filter_and_convert_balances(
1293 account.balances.unwrap_or_default(),
1294 assets,
1295 ))
1296 }
1297
1298 async fn fetch_open_orders(
1303 &self,
1304 instruments: &[InstrumentNameExchange],
1305 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1306 if instruments.is_empty() {
1309 return fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await;
1310 }
1311 use futures::{StreamExt as _, TryStreamExt as _};
1316 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1317 fetch_open_orders_for_instrument(
1318 self.rest.clone(),
1319 self.rate_limiter.clone(),
1320 instrument,
1321 )
1322 }))
1323 .buffer_unordered(8)
1324 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1325 acc.extend(listing.orders);
1326 Ok(acc)
1327 })
1328 .await
1329 }
1330
1331 #[allow(clippy::redundant_iter_cloned)]
1339 async fn fetch_trades(
1340 &self,
1341 time_since: DateTime<Utc>,
1342 instruments: &[InstrumentNameExchange],
1343 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1344 use futures::StreamExt;
1345
1346 if instruments.is_empty() {
1347 debug!(
1348 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1349 );
1350 return Ok(Vec::new());
1351 }
1352 let start_time_ms = time_since.timestamp_millis();
1353 let mut all_trades = Vec::new();
1356
1357 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1361 let rest = self.rest.clone();
1362 let rate_limiter = self.rate_limiter.clone();
1363 async move {
1364 let pages = paginate_my_trades(
1365 &rest,
1366 &rate_limiter,
1367 &inst,
1368 MyTradesFrom::Time(start_time_ms),
1369 RequestKind::Query,
1370 )
1371 .await?;
1372 Ok::<_, UnindexedClientError>((inst, pages))
1373 }
1374 }))
1375 .buffer_unordered(8);
1376 while let Some(result) = stream.next().await {
1377 let (instrument, trades_data) = result?;
1378 for t in trades_data {
1379 if let Some(trade) = convert_my_trade(&t, &instrument) {
1380 all_trades.push(trade);
1381 }
1382 }
1383 }
1384
1385 Ok(all_trades)
1386 }
1387}
1388
1389async fn connect_and_subscribe(ws_handle: &WebsocketApiHandle) -> anyhow::Result<WebsocketApi> {
1396 let ws = ws_handle.connect().await?;
1397 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1399 .build()
1400 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1401 match ws.user_data_stream_subscribe_signature(params).await {
1402 Ok(_) => Ok(ws),
1403 Err(e) => {
1404 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1405 let ws_cleanup = ws;
1406 tokio::spawn(async move {
1416 match tokio::time::timeout(Duration::from_secs(5), ws_cleanup.disconnect()).await {
1417 Ok(Err(dc_err)) => warn!(%dc_err, "BinanceSpot WS cleanup disconnect failed"),
1418 Err(_) => warn!("BinanceSpot WS cleanup disconnect timed out (5s)"),
1419 Ok(Ok(())) => {}
1420 }
1421 });
1422 Err(e)
1423 }
1424 }
1425}
1426
1427#[allow(clippy::cognitive_complexity)]
1449async fn connection_manager(
1450 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1451 dedup: SharedDedupCache,
1452 ws_handle: WebsocketApiHandle,
1453 rest: Arc<RestApi>,
1454 rate_limiter: Arc<RateLimitTracker>,
1455 instruments: Vec<InstrumentNameExchange>,
1456 initial_ws: Option<WebsocketApi>,
1457) {
1458 let mut backoff = ExponentialBackoff::new();
1459 let mut disconnect_time: Option<DateTime<Utc>> = None;
1460 let mut unrecovered = UnrecoveredFills::default();
1462 let mut current_ws = initial_ws;
1463
1464 loop {
1465 let ws = match current_ws.take() {
1467 Some(ws) => ws,
1468 None => match connect_and_subscribe(&ws_handle).await {
1469 Ok(ws) => ws,
1475 Err(e) => {
1476 error!(%e, "BinanceSpot WS connect/subscribe failed");
1477 if !backoff.wait().await {
1478 error!("BinanceSpot max reconnect attempts exhausted");
1479 emit_stream_terminated(
1481 &tx,
1482 ExchangeId::BinanceSpot,
1483 StreamTerminationReason::ReconnectBudgetExhausted {
1484 attempts: backoff.attempts(),
1485 last_error: e.to_string(),
1486 },
1487 );
1488 break;
1489 }
1490 continue;
1491 }
1492 },
1493 };
1494 info!("BinanceSpot account_stream connected and subscribed");
1495
1496 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1502 let mut signal_tx_opt = Some(signal_tx);
1503 let heartbeat_flag = Arc::new(AtomicBool::new(true));
1507 let hb_callback = heartbeat_flag.clone();
1508 let dedup_callback = dedup.clone();
1509 let mut event_tx = Some(tx.clone());
1513 let mut event_buf = Vec::with_capacity(32);
1517
1518 let subscription = ws.subscribe_on_ws_events(move |event| {
1525 let Some(ref sender) = event_tx else { return };
1526 match event {
1527 WebsocketEvent::Message(json_str) => {
1528 hb_callback.store(true, Ordering::Release);
1531 let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1535 for ev in event_buf.drain(..) {
1536 if let Some(key) = dedup_key_from_event(&ev)
1538 && is_duplicate(&dedup_callback, key)
1539 {
1540 trace!("BinanceSpot dedup: skipping duplicate event");
1541 continue;
1542 }
1543 if sender.send(ev).is_err() {
1544 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1545 event_tx.take();
1546 if let Some(s) = signal_tx_opt.take() {
1547 let _ = s.send(());
1548 }
1549 return;
1550 }
1551 }
1552 if stream_terminated {
1555 event_tx.take();
1556 if let Some(s) = signal_tx_opt.take() {
1557 let _ = s.send(());
1558 }
1559 }
1560 }
1561 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1562 hb_callback.store(true, Ordering::Release);
1565 }
1566 WebsocketEvent::Error(e) => {
1567 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1571 event_tx.take();
1572 if let Some(s) = signal_tx_opt.take() {
1573 let _ = s.send(());
1574 }
1575 }
1576 WebsocketEvent::Close(code, reason) => {
1577 warn!(code, %reason, "BinanceSpot WebSocket closed");
1578 event_tx.take();
1579 if let Some(s) = signal_tx_opt.take() {
1580 let _ = s.send(());
1581 }
1582 }
1583 _ => {}
1584 }
1585 });
1586
1587 if let Some(dt) = disconnect_time.take() {
1597 unrecovered.open(&instruments, dt, Utc::now());
1598 }
1599 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup).await;
1600
1601 enum DisconnectReason {
1603 Signal,
1604 HeartbeatTimeout,
1605 ConsumerDropped,
1606 }
1607 let reason = {
1608 let mut signal_rx = signal_rx;
1609 let retry_gaps = async {
1613 loop {
1614 match unrecovered.next_due() {
1615 Some(due) => tokio::time::sleep_until(due).await,
1616 None => std::future::pending::<()>().await,
1617 }
1618 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup).await;
1619 }
1620 };
1621 tokio::pin!(retry_gaps);
1622 loop {
1623 tokio::select! {
1624 biased;
1626 _ = tx.closed() => {
1627 debug!("BinanceSpot account_stream consumer dropped, terminating");
1628 break DisconnectReason::ConsumerDropped;
1629 }
1630 _ = &mut signal_rx => {
1631 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1632 break DisconnectReason::Signal;
1633 }
1634 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1635 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1642 backoff.reset();
1646 continue;
1647 }
1648 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1649 break DisconnectReason::HeartbeatTimeout;
1650 }
1651 () = &mut retry_gaps => {}
1653 }
1654 }
1655 };
1656 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1657
1658 if should_reconnect {
1665 disconnect_time = Some(match reason {
1666 DisconnectReason::HeartbeatTimeout => {
1667 Utc::now()
1668 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1669 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1670 }
1671 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1672 });
1673 }
1674
1675 subscription.unsubscribe();
1683 if let Err(e) = ws.disconnect().await {
1686 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1687 }
1688
1689 if !should_reconnect || tx.is_closed() {
1690 debug!("BinanceSpot connection manager exiting");
1693 break;
1694 }
1695 if !backoff.wait().await {
1696 error!("BinanceSpot max reconnect attempts exhausted, stream terminating");
1697 let last_error = match reason {
1700 DisconnectReason::HeartbeatTimeout => {
1701 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
1702 }
1703 _ => "WebSocket disconnected (server close/error)".to_string(),
1704 };
1705 emit_stream_terminated(
1706 &tx,
1707 ExchangeId::BinanceSpot,
1708 StreamTerminationReason::ReconnectBudgetExhausted {
1709 attempts: backoff.attempts(),
1710 last_error,
1711 },
1712 );
1713 break;
1714 }
1715 }
1716}
1717
1718async fn recover_fills(
1735 rest: &Arc<RestApi>,
1736 rate_limiter: &Arc<RateLimitTracker>,
1737 unrecovered: &mut UnrecoveredFills,
1738 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1739 dedup: &SharedDedupCache,
1740) {
1741 use futures::StreamExt;
1742
1743 let due = unrecovered.due(tokio::time::Instant::now());
1744 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
1745 return;
1746 };
1747 info!(
1748 gaps = due.len(),
1749 oldest = %gap_time(oldest),
1750 "BinanceSpot recovering fills missed while disconnected"
1751 );
1752
1753 let mut settled = vec![false; due.len()];
1756 let started = AtomicUsize::new(0);
1757 let recovery = async {
1758 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
1759 let mut recovered = 0u32;
1760 let mut duplicates = 0u32;
1761
1762 let mut stream =
1768 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
1769 started.store(index + 1, Ordering::Relaxed);
1770 let rest = rest.clone();
1771 let rl = rate_limiter.clone();
1772 async move {
1773 let span = MyTradesFrom::Span {
1774 start: gap.start_ms,
1775 end: gap.end_ms,
1776 };
1777 let raw =
1778 match paginate_my_trades(&rest, &rl, &inst, span, RequestKind::Essential)
1779 .await
1780 {
1781 Ok(pages) => pages,
1782 Err(e) => return (index, Err(e)),
1783 };
1784 let totals = recovered_order_totals(
1788 ExchangeId::BinanceSpot,
1789 &inst,
1790 &raw,
1791 order_executions_deadline,
1792 |order_id| {
1793 paginate_my_trades(
1794 &rest,
1795 &rl,
1796 &inst,
1797 MyTradesFrom::Order(order_id),
1798 RequestKind::Essential,
1799 )
1800 },
1801 )
1802 .await;
1803 let trades: Vec<_> = raw
1804 .iter()
1805 .filter_map(|t| {
1806 let mut trade = convert_my_trade(t, &inst)?;
1807 trade.order_filled_quantity =
1808 t.id.and_then(|id| totals.get(&id).copied());
1809 Some(trade)
1810 })
1811 .collect();
1812 (index, Ok(trades))
1813 }
1814 }))
1815 .buffer_unordered(8);
1816 while let Some((index, result)) = stream.next().await {
1817 let (inst, gap) = &due[index];
1818 settled[index] = true;
1819 let trades = match result {
1820 Ok(trades) => trades,
1821 Err(e) => {
1822 gap_failed(
1823 unrecovered,
1824 ExchangeId::BinanceSpot,
1825 inst,
1826 gap,
1827 &e.to_string(),
1828 );
1829 continue;
1830 }
1831 };
1832 for trade in trades {
1833 let event = UnindexedAccountEvent::new(
1836 ExchangeId::BinanceSpot,
1837 AccountEventKind::Trade(trade),
1838 );
1839 if let Some(key) = dedup_key_from_event(&event)
1842 && is_duplicate(dedup, key)
1843 {
1844 duplicates += 1;
1845 continue;
1846 }
1847 if tx.send(event).is_err() {
1848 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
1851 return;
1852 }
1853 recovered += 1;
1854 }
1855 unrecovered.recovered(inst, gap);
1856 }
1857 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
1858 };
1859 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
1863 .await
1864 .is_err()
1865 {
1866 let started = started.load(Ordering::Relaxed);
1867 for ((inst, gap), _) in due[..started]
1868 .iter()
1869 .zip(&settled)
1870 .filter(|(_, settled)| !**settled)
1871 {
1872 gap_failed(
1873 unrecovered,
1874 ExchangeId::BinanceSpot,
1875 inst,
1876 gap,
1877 "fill recovery timed out",
1878 );
1879 }
1880 }
1881}
1882
1883fn convert_balance_entry(
1897 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
1898 now: chrono::DateTime<Utc>,
1899) -> Option<AssetBalance<AssetNameExchange>> {
1900 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
1901 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1902 Some(v) => v,
1903 None => {
1904 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
1905 return None;
1906 }
1907 };
1908 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1909 Some(v) => v,
1910 None => {
1911 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
1912 return None;
1913 }
1914 };
1915 Some(AssetBalance::new(
1916 asset_name,
1917 Balance::new(free + locked, free),
1918 now,
1919 ))
1920}
1921
1922fn filter_and_convert_balances(
1923 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
1924 assets: &[AssetNameExchange],
1925) -> Vec<AssetBalance<AssetNameExchange>> {
1926 let now = Utc::now();
1927 if assets.is_empty() {
1929 return balances
1930 .into_iter()
1931 .filter_map(|b| convert_balance_entry(b, now))
1932 .collect();
1933 }
1934 if assets.len() <= 16 {
1937 return balances
1938 .into_iter()
1939 .filter_map(|b| {
1940 let asset_name_str = b.asset.as_deref()?;
1941 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
1942 return None;
1943 }
1944 convert_balance_entry(b, now)
1945 })
1946 .collect();
1947 }
1948 use std::collections::HashSet;
1949 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
1950 balances
1951 .into_iter()
1952 .filter_map(|b| {
1953 let asset_name_str = b.asset.as_deref()?;
1954 if !asset_set.contains(asset_name_str) {
1955 return None;
1956 }
1957 convert_balance_entry(b, now)
1958 })
1959 .collect()
1960}
1961
1962fn convert_my_trade(
1964 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
1965 instrument: &InstrumentNameExchange,
1966) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
1967 let trade_id_raw = match t.id {
1968 Some(id) => id,
1969 None => {
1970 warn!(%instrument, "BinanceSpot trade missing id");
1971 return None;
1972 }
1973 };
1974 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
1975 let order_id = match t.order_id {
1976 Some(id) => OrderId(format_smolstr!("{}", id)),
1977 None => {
1978 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
1979 return None;
1980 }
1981 };
1982 let side = match t.is_buyer {
1983 Some(is_buyer) => {
1984 if is_buyer {
1985 Side::Buy
1986 } else {
1987 Side::Sell
1988 }
1989 }
1990 None => {
1991 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
1992 return None;
1993 }
1994 };
1995 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1996 Some(v) => v,
1997 None => {
1998 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
1999 return None;
2000 }
2001 };
2002 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2003 Some(v) => v,
2004 None => {
2005 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2006 return None;
2007 }
2008 };
2009 let commission = t
2010 .commission
2011 .as_deref()
2012 .and_then(|s| Decimal::from_str(s).ok())
2013 .unwrap_or(Decimal::ZERO);
2014 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
2015 Some(ts) => ts,
2016 None => {
2017 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2018 Utc::now()
2019 }
2020 };
2021
2022 let fee_asset = t
2027 .commission_asset
2028 .as_deref()
2029 .map(AssetNameExchange::from)
2030 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2031
2032 Some(Trade::new(
2033 trade_id,
2034 order_id,
2035 instrument.clone(),
2036 StrategyId::unknown(), time_exchange,
2038 side,
2039 price,
2040 quantity,
2041 None,
2043 AssetFees::new(fee_asset, commission, None),
2044 ))
2045}
2046
2047fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2072 let (event_type, event) = match parse_user_data_frame(frame) {
2073 UserDataFrame::Response => return false,
2074 UserDataFrame::Event {
2075 event_type, event, ..
2076 } => (event_type, event),
2077 UserDataFrame::Unrecognised => {
2078 static SEEN: AtomicU64 = AtomicU64::new(0);
2079 log_unrecognised_frame("BinanceSpot", &SEEN, frame);
2080 return false;
2081 }
2082 };
2083 match event_type {
2085 "executionReport" => {
2086 match serde_json::from_str::<binance_sdk::spot::websocket_api::ExecutionReport>(event) {
2089 Ok(report) => {
2090 convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
2091 }
2092 Err(e) => {
2093 warn!(error = %e, "BinanceSpot: undeserializable executionReport, dropping")
2094 }
2095 }
2096 false
2097 }
2098 "outboundAccountPosition" => {
2099 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2100 event,
2101 ) {
2102 Ok(position) => convert_account_position(position, buf),
2103 Err(e) => {
2104 warn!(error = %e, "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2105 }
2106 }
2107 false
2108 }
2109 "balanceUpdate" => {
2110 false
2117 }
2118 "eventStreamTerminated" => {
2119 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2123 true
2124 }
2125 _ => {
2128 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2129 false
2130 }
2131 }
2132}
2133
2134fn convert_account_position(
2139 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2140 buf: &mut Vec<UnindexedAccountEvent>,
2141) {
2142 let time_exchange = position
2145 .u
2146 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2147 .unwrap_or_else(Utc::now);
2148
2149 for b in position.b_uppercase.unwrap_or_default() {
2150 let asset = match b.a {
2151 Some(a) => AssetNameExchange::new(a),
2152 None => {
2153 warn!("BinanceSpot account position entry missing asset name");
2154 continue;
2155 }
2156 };
2157 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2158 Some(v) => v,
2159 None => {
2160 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2161 continue;
2162 }
2163 };
2164 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2165 Some(v) => v,
2166 None => {
2167 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2168 continue;
2169 }
2170 };
2171 let update =
2176 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2177 buf.push(UnindexedAccountEvent::new(
2178 ExchangeId::BinanceSpot,
2179 AccountEventKind::BalanceStreamUpdate(
2180 rustrade_integration::collection::snapshot::Snapshot::new(update),
2181 ),
2182 ));
2183 }
2184}
2185
2186fn convert_order_kind_tif(
2213 kind: OrderKind,
2214 tif: TimeInForce,
2215) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2216 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2220 let type_enum = match binance_type {
2221 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2222 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2223 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2224 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2225 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2226 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2227 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2228 };
2229 let tif_enum = binance_tif.map(|t| match t {
2230 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2231 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2232 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2233 });
2234 Some((type_enum, tif_enum))
2235}
2236
2237#[cfg(test)]
2238#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2240 use super::*;
2241 use crate::client::binance::shared::*;
2242 use crate::client::dedup::{DedupEventKind, DedupKey};
2243 use crate::order::{TrailingOffsetType, id::ClientOrderId};
2244 use binance_sdk::common::errors::WebsocketError;
2245 use smol_str::SmolStr;
2246
2247 #[test]
2248 fn test_binance_spot_config_new_uses_testnet_by_default() {
2249 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2250 assert!(cfg.testnet);
2251 assert_eq!(cfg.api_key(), "my_key");
2252 }
2253
2254 #[test]
2255 fn test_binance_spot_config_production_is_explicit() {
2256 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2257 assert!(!cfg.testnet);
2258 }
2259
2260 #[test]
2261 fn test_binance_spot_config_debug_redacts_credentials() {
2262 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2263 let debug = format!("{cfg:?}");
2264
2265 assert!(!debug.contains("my_key"), "api_key should be redacted");
2266 assert!(
2267 !debug.contains("my_secret"),
2268 "secret_key should be redacted"
2269 );
2270 assert!(debug.contains("testnet: true"));
2271 }
2272
2273 #[test]
2274 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2275 let cfg: BinanceSpotConfig = serde_json::from_str(
2276 r#"{
2277 "api_key": "my_key",
2278 "secret_key": "my_secret"
2279 }"#,
2280 )
2281 .unwrap();
2282
2283 assert!(cfg.testnet);
2284 assert_eq!(cfg.api_key(), "my_key");
2285 }
2286
2287 #[test]
2288 fn test_binance_spot_config_deserialize_testnet_true() {
2289 let cfg: BinanceSpotConfig = serde_json::from_str(
2290 r#"{
2291 "api_key": "my_key",
2292 "secret_key": "my_secret",
2293 "testnet": true
2294 }"#,
2295 )
2296 .unwrap();
2297
2298 assert!(cfg.testnet);
2299 }
2300
2301 #[test]
2302 fn test_binance_spot_config_deserialize_testnet_false() {
2303 let cfg: BinanceSpotConfig = serde_json::from_str(
2304 r#"{
2305 "api_key": "my_key",
2306 "secret_key": "my_secret",
2307 "testnet": false
2308 }"#,
2309 )
2310 .unwrap();
2311
2312 assert!(!cfg.testnet);
2313 }
2314
2315 #[test]
2316 #[serial_test::serial]
2317 fn test_binance_spot_config_from_env_defaults_to_testnet() {
2318 temp_env::with_vars(
2319 [
2320 ("BINANCE_API_KEY", Some("my_key")),
2321 ("BINANCE_SECRET_KEY", Some("my_secret")),
2322 ("BINANCE_TESTNET", None),
2323 ],
2324 || {
2325 let cfg = BinanceSpotConfig::from_env().unwrap();
2326 assert!(cfg.testnet);
2327 assert_eq!(cfg.api_key(), "my_key");
2328 },
2329 );
2330 }
2331
2332 #[test]
2333 #[serial_test::serial]
2334 fn test_binance_spot_config_from_env_accepts_explicit_production() {
2335 temp_env::with_vars(
2336 [
2337 ("BINANCE_API_KEY", Some("my_key")),
2338 ("BINANCE_SECRET_KEY", Some("my_secret")),
2339 ("BINANCE_TESTNET", Some("false")),
2340 ],
2341 || {
2342 let cfg = BinanceSpotConfig::from_env().unwrap();
2343 assert!(!cfg.testnet);
2344 },
2345 );
2346 }
2347
2348 #[test]
2349 #[serial_test::serial]
2350 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2351 temp_env::with_vars(
2352 [
2353 ("BINANCE_API_KEY", Some("my_key")),
2354 ("BINANCE_SECRET_KEY", Some("my_secret")),
2355 ("BINANCE_TESTNET", Some("maybe")),
2356 ],
2357 || {
2358 let err = BinanceSpotConfig::from_env().unwrap_err();
2359 assert!(
2360 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2361 );
2362 },
2363 );
2364 }
2365
2366 #[test]
2367 #[serial_test::serial]
2368 fn test_binance_spot_config_from_env_requires_credentials() {
2369 temp_env::with_vars(
2370 [
2371 ("BINANCE_API_KEY", None),
2372 ("BINANCE_SECRET_KEY", Some("my_secret")),
2373 ("BINANCE_TESTNET", None),
2374 ],
2375 || {
2376 let err = BinanceSpotConfig::from_env().unwrap_err();
2377 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2378 },
2379 );
2380 }
2381
2382 #[test]
2383 fn test_parse_side() {
2384 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2385 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2386 assert_eq!(parse_side("buy"), None);
2387 assert_eq!(parse_side("UNKNOWN"), None);
2388 }
2389
2390 #[test]
2391 fn test_parse_order_kind() {
2392 assert_eq!(parse_order_kind("MARKET"), Some(OrderKind::Market));
2393 assert_eq!(parse_order_kind("LIMIT"), Some(OrderKind::Limit));
2394 assert_eq!(parse_order_kind("LIMIT_MAKER"), Some(OrderKind::Limit));
2395 assert_eq!(parse_order_kind("STOP_LOSS"), None);
2396 assert_eq!(parse_order_kind("TAKE_PROFIT"), None);
2397 assert_eq!(parse_order_kind("STOP_LOSS_LIMIT"), Some(OrderKind::Limit));
2398 assert_eq!(
2399 parse_order_kind("TAKE_PROFIT_LIMIT"),
2400 Some(OrderKind::Limit)
2401 );
2402 assert_eq!(parse_order_kind("UNKNOWN_TYPE"), None);
2403 }
2404
2405 #[test]
2406 fn test_parse_time_in_force() {
2407 assert_eq!(
2408 parse_time_in_force("GTC"),
2409 TimeInForce::GoodUntilCancelled { post_only: false }
2410 );
2411 assert_eq!(
2412 parse_time_in_force("GTX"),
2413 TimeInForce::GoodUntilCancelled { post_only: true }
2414 );
2415 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2416 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2417 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2418 assert_eq!(
2420 parse_time_in_force("UNKNOWN"),
2421 TimeInForce::GoodUntilCancelled { post_only: false }
2422 );
2423 }
2424
2425 #[test]
2426 fn test_convert_order_kind_tif() {
2427 use rust_decimal::Decimal;
2428
2429 assert!(matches!(
2431 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2432 Some((OrderPlaceTypeEnum::Market, None))
2433 ));
2434 assert!(matches!(
2435 convert_order_kind_tif(
2436 OrderKind::Limit,
2437 TimeInForce::GoodUntilCancelled { post_only: false }
2438 ),
2439 Some((
2440 OrderPlaceTypeEnum::Limit,
2441 Some(OrderPlaceTimeInForceEnum::Gtc)
2442 ))
2443 ));
2444 assert!(matches!(
2445 convert_order_kind_tif(
2446 OrderKind::Limit,
2447 TimeInForce::GoodUntilCancelled { post_only: true }
2448 ),
2449 Some((OrderPlaceTypeEnum::LimitMaker, None))
2450 ));
2451 assert!(matches!(
2452 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2453 Some((
2454 OrderPlaceTypeEnum::Limit,
2455 Some(OrderPlaceTimeInForceEnum::Fok)
2456 ))
2457 ));
2458 assert!(matches!(
2459 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2460 Some((
2461 OrderPlaceTypeEnum::Limit,
2462 Some(OrderPlaceTimeInForceEnum::Ioc)
2463 ))
2464 ));
2465 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2468
2469 assert!(matches!(
2471 convert_order_kind_tif(
2472 OrderKind::Stop {
2473 trigger_price: Decimal::from(100)
2474 },
2475 TimeInForce::GoodUntilCancelled { post_only: false }
2476 ),
2477 Some((OrderPlaceTypeEnum::StopLoss, None))
2478 ));
2479 assert!(matches!(
2480 convert_order_kind_tif(
2481 OrderKind::StopLimit {
2482 trigger_price: Decimal::from(100)
2483 },
2484 TimeInForce::GoodUntilCancelled { post_only: false }
2485 ),
2486 Some((
2487 OrderPlaceTypeEnum::StopLossLimit,
2488 Some(OrderPlaceTimeInForceEnum::Gtc)
2489 ))
2490 ));
2491 assert!(matches!(
2492 convert_order_kind_tif(
2493 OrderKind::TakeProfit {
2494 trigger_price: Decimal::from(150)
2495 },
2496 TimeInForce::ImmediateOrCancel
2497 ),
2498 Some((OrderPlaceTypeEnum::TakeProfit, None))
2499 ));
2500 assert!(matches!(
2501 convert_order_kind_tif(
2502 OrderKind::TakeProfitLimit {
2503 trigger_price: Decimal::from(150)
2504 },
2505 TimeInForce::FillOrKill
2506 ),
2507 Some((
2508 OrderPlaceTypeEnum::TakeProfitLimit,
2509 Some(OrderPlaceTimeInForceEnum::Fok)
2510 ))
2511 ));
2512
2513 assert!(matches!(
2515 convert_order_kind_tif(
2516 OrderKind::TrailingStop {
2517 offset: Decimal::from(100),
2518 offset_type: TrailingOffsetType::BasisPoints,
2519 },
2520 TimeInForce::GoodUntilCancelled { post_only: false }
2521 ),
2522 Some((OrderPlaceTypeEnum::StopLoss, None))
2523 ));
2524 assert!(matches!(
2525 convert_order_kind_tif(
2526 OrderKind::TrailingStop {
2527 offset: Decimal::from(5),
2528 offset_type: TrailingOffsetType::Percentage,
2529 },
2530 TimeInForce::GoodUntilCancelled { post_only: false }
2531 ),
2532 Some((OrderPlaceTypeEnum::StopLoss, None))
2533 ));
2534
2535 assert!(
2537 convert_order_kind_tif(
2538 OrderKind::TrailingStop {
2539 offset: Decimal::from(10),
2540 offset_type: TrailingOffsetType::Absolute,
2541 },
2542 TimeInForce::GoodUntilCancelled { post_only: false }
2543 )
2544 .is_none()
2545 );
2546
2547 assert!(
2549 convert_order_kind_tif(
2550 OrderKind::TrailingStopLimit {
2551 offset: Decimal::from(100),
2552 offset_type: TrailingOffsetType::BasisPoints,
2553 limit_offset: Decimal::from(10),
2554 },
2555 TimeInForce::GoodUntilCancelled { post_only: false }
2556 )
2557 .is_none()
2558 );
2559 }
2560
2561 #[test]
2562 fn test_parse_binance_api_error() {
2563 let instrument = InstrumentNameExchange::new("BTCUSDT");
2564
2565 assert!(matches!(
2566 parse_binance_api_error("Insufficient balance".into(), &instrument),
2567 ApiError::BalanceInsufficient(_, _)
2568 ));
2569 assert!(matches!(
2570 parse_binance_api_error("Not enough funds".into(), &instrument),
2571 ApiError::BalanceInsufficient(_, _)
2572 ));
2573 assert_eq!(
2574 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2575 ApiError::RateLimit
2576 );
2577 assert_eq!(
2578 parse_binance_api_error("Error code -1015".into(), &instrument),
2579 ApiError::RateLimit
2580 );
2581 assert_eq!(
2583 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2584 ApiError::RateLimit
2585 );
2586 assert_eq!(
2588 parse_binance_api_error("Error code -2011".into(), &instrument),
2589 ApiError::OrderAlreadyCancelled
2590 );
2591 assert!(matches!(
2593 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2594 ApiError::OrderAlreadyCancelled
2595 ));
2596 assert!(matches!(
2598 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2599 ApiError::OrderAlreadyCancelled
2600 ));
2601 assert!(matches!(
2603 parse_binance_api_error("Unknown order encountered".into(), &instrument),
2604 ApiError::OrderRejected(_)
2605 ));
2606 assert!(matches!(
2607 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2608 ApiError::InstrumentInvalid(_, _)
2609 ));
2610 assert!(matches!(
2612 parse_binance_api_error(
2613 "Server-side response error (code -2010): Account has insufficient balance".into(),
2614 &instrument
2615 ),
2616 ApiError::BalanceInsufficient(_, _)
2617 ));
2618 assert!(matches!(
2619 parse_binance_api_error("Some other error".into(), &instrument),
2620 ApiError::OrderRejected(_)
2621 ));
2622 }
2623
2624 #[test]
2625 fn test_contains_error_code_suffix_guard() {
2626 assert!(
2628 !contains_error_code("-20130", "-2013"),
2629 "-20130 should not match -2013"
2630 );
2631 assert!(
2632 !contains_error_code("-20131", "-2013"),
2633 "-20131 should not match -2013"
2634 );
2635 assert!(contains_error_code("-2013", "-2013"), "exact match");
2637 assert!(
2638 contains_error_code("Error -2013: text", "-2013"),
2639 "match with trailing text"
2640 );
2641 }
2642
2643 #[test]
2644 fn test_contains_error_code_prefix_guard() {
2645 assert!(
2648 !contains_error_code("1-2013", "-2013"),
2649 "1-2013 should not match -2013"
2650 );
2651 assert!(
2652 !contains_error_code("error 1-2013 text", "-2013"),
2653 "embedded 1-2013 should not match"
2654 );
2655 assert!(
2657 contains_error_code("code=-2013,", "-2013"),
2658 "=-2013 prefix should match"
2659 );
2660 assert!(
2661 contains_error_code(" -2013 ", "-2013"),
2662 "space prefix should match"
2663 );
2664 }
2665
2666 #[test]
2667 fn test_contains_error_code_second_occurrence_valid() {
2668 assert!(
2671 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
2672 "second valid occurrence must match when first fails digit guard"
2673 );
2674 assert!(
2676 contains_error_code("-2013 or -20130", "-2013"),
2677 "first valid occurrence must match when second is a longer code"
2678 );
2679 }
2680
2681 #[test]
2682 fn test_dedup_cache() {
2683 let cache = new_dedup_cache();
2684 let key = DedupKey {
2685 instrument: SmolStr::from("BTCUSDT"),
2686 id: SmolStr::from("12345"),
2687 kind: DedupEventKind::Trade,
2688 };
2689
2690 assert!(!is_duplicate(&cache, key));
2692 let key = DedupKey {
2694 instrument: SmolStr::from("BTCUSDT"),
2695 id: SmolStr::from("12345"),
2696 kind: DedupEventKind::Trade,
2697 };
2698 assert!(is_duplicate(&cache, key));
2699
2700 let key2 = DedupKey {
2702 instrument: SmolStr::from("BTCUSDT"),
2703 id: SmolStr::from("12345"),
2704 kind: DedupEventKind::OrderState {
2705 filled_quantity: Decimal::ZERO,
2706 },
2707 };
2708 assert!(!is_duplicate(&cache, key2));
2709 }
2710
2711 #[test]
2715 fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
2716 let cache = new_dedup_cache();
2717 let state = |filled: &str| DedupKey {
2718 instrument: SmolStr::from("BTCUSDT"),
2719 id: SmolStr::from("12345"),
2720 kind: DedupEventKind::OrderState {
2721 filled_quantity: Decimal::from_str(filled).unwrap(),
2722 },
2723 };
2724
2725 assert!(!is_duplicate(&cache, state("0")), "ack is new");
2727 assert!(
2728 !is_duplicate(&cache, state("1")),
2729 "first fill is not the ack"
2730 );
2731 assert!(
2732 !is_duplicate(&cache, state("2")),
2733 "second fill is not the first"
2734 );
2735
2736 assert!(
2739 is_duplicate(&cache, state("0")),
2740 "replayed ack is a duplicate"
2741 );
2742 assert!(
2743 is_duplicate(&cache, state("2")),
2744 "replayed fill is a duplicate"
2745 );
2746
2747 assert!(
2749 is_duplicate(&cache, state("2.00")),
2750 "2.00 must be the same key as 2"
2751 );
2752 }
2753
2754 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
2761 use crate::client::dedup::dedup_key_from_event;
2762
2763 let cache = new_dedup_cache();
2764 let mut delivered = Vec::new();
2765 let mut buf = Vec::new();
2766 for frame in frames {
2767 let _ = convert_user_data_events(frame, &mut buf);
2768 for ev in buf.drain(..) {
2769 if let Some(key) = dedup_key_from_event(&ev)
2770 && is_duplicate(&cache, key)
2771 {
2772 continue;
2773 }
2774 delivered.push(ev);
2775 }
2776 }
2777 delivered
2778 }
2779
2780 const WS_NEW: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport","s":"BTCUSDT",
2782 "i":12345,"c":"client-1","x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2",
2783 "p":"100","z":"0","T":1700000000000}}"#;
2784
2785 const WS_PARTIAL_FILL: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport",
2786 "s":"BTCUSDT","i":12345,"c":"client-1","x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY",
2787 "o":"LIMIT","f":"GTC","q":"2","p":"100","z":"1","l":"1","L":"100","t":555,"n":"0.1",
2788 "N":"USDT","T":1700000001000}}"#;
2789
2790 #[test]
2798 fn ws_fill_snapshot_survives_the_dedup_gate() {
2799 use crate::order::state::ActiveOrderState;
2800
2801 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
2802
2803 assert_eq!(
2804 delivered.len(),
2805 3,
2806 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
2807 );
2808
2809 let snapshots: Vec<Decimal> = delivered
2810 .iter()
2811 .filter_map(|ev| match &ev.kind {
2812 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
2813 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
2814 _ => None,
2815 },
2816 _ => None,
2817 })
2818 .collect();
2819
2820 assert_eq!(
2821 snapshots,
2822 vec![Decimal::ZERO, Decimal::ONE],
2823 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
2824 );
2825 }
2826
2827 #[test]
2830 fn ws_replayed_frame_is_still_deduplicated() {
2831 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
2832
2833 assert_eq!(
2834 delivered.len(),
2835 3,
2836 "re-delivering both frames must add nothing, got: {delivered:?}"
2837 );
2838 }
2839
2840 #[test]
2841 fn test_is_rate_limit_error() {
2842 assert!(is_rate_limit_error(&anyhow::anyhow!(
2844 "Too many requests. You are being rate-limited. Please slow down."
2845 )));
2846 assert!(is_rate_limit_error(&anyhow::anyhow!(
2848 "The IP address has been banned for exceeding rate limits. Contact support."
2849 )));
2850 assert!(is_rate_limit_error(&anyhow::anyhow!(
2852 "Error -1015: too many new orders"
2853 )));
2854 assert!(is_rate_limit_error(&anyhow::anyhow!(
2855 "Error -1003: too many requests"
2856 )));
2857 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
2859 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
2860 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
2861 assert!(
2863 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
2864 "-10150 should not match -1015"
2865 );
2866 assert!(
2867 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
2868 "-10030 should not match -1003"
2869 );
2870 }
2871
2872 #[test]
2873 fn test_classify_ws_order_error_recognises_venue_responses_only() {
2874 let instrument = InstrumentNameExchange::new("BTCUSDT");
2875
2876 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
2878 code: -2010,
2879 message: "Account has insufficient balance for requested action.".into(),
2880 });
2881 assert!(
2882 matches!(
2883 classify_ws_order_error(&rejection, &instrument),
2884 Some(OrderError::Rejected(ApiError::BalanceInsufficient(..)))
2885 ),
2886 "ResponseError should be classified as a venue response"
2887 );
2888
2889 let transport = anyhow::anyhow!("connection reset by peer");
2891 assert!(
2892 classify_ws_order_error(&transport, &instrument).is_none(),
2893 "plain transport error should not be classified as a venue response"
2894 );
2895
2896 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
2898 assert!(
2899 classify_ws_order_error(&rate_limit, &instrument).is_none(),
2900 "rate-limit string error should not be classified as a venue response"
2901 );
2902 }
2903
2904 #[test]
2905 fn test_dedup_key_from_event_trade_includes_instrument() {
2906 use crate::order::id::{OrderId, StrategyId};
2907 use crate::trade::{AssetFees, Trade, TradeId};
2908 use chrono::Utc;
2909 use rust_decimal::Decimal;
2910 use rustrade_instrument::Side;
2911
2912 let instrument = InstrumentNameExchange::new("BTCUSDT");
2913 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
2914 TradeId::new("9001"),
2915 OrderId::new("4242"),
2916 instrument.clone(),
2917 StrategyId::unknown(),
2918 Utc::now(),
2919 Side::Buy,
2920 Decimal::ZERO,
2921 Decimal::ZERO,
2922 None,
2923 AssetFees::new(
2924 AssetNameExchange::from("USDT"),
2925 Decimal::ZERO,
2926 Some(Decimal::ZERO),
2927 ),
2928 );
2929 let event =
2930 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
2931
2932 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
2933 assert_eq!(key.kind, DedupEventKind::Trade);
2934 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
2935 assert_eq!(
2936 key.instrument.as_str(),
2937 "BTCUSDT",
2938 "key.instrument should be the symbol"
2939 );
2940
2941 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
2943 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
2944 TradeId::new("9001"),
2945 OrderId::new("7777"),
2946 instrument2,
2947 StrategyId::unknown(),
2948 Utc::now(),
2949 Side::Buy,
2950 Decimal::ZERO,
2951 Decimal::ZERO,
2952 None,
2953 AssetFees::new(
2954 AssetNameExchange::from("USDT"),
2955 Decimal::ZERO,
2956 Some(Decimal::ZERO),
2957 ),
2958 );
2959 let event2 =
2960 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
2961 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
2962 assert_ne!(
2963 key, key2,
2964 "same trade ID on different symbols must produce distinct keys"
2965 );
2966 }
2967
2968 fn make_balance_inner(
2973 asset: &str,
2974 free: &str,
2975 locked: &str,
2976 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2977 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2978 a: Some(asset.to_string()),
2979 f: Some(free.to_string()),
2980 l: Some(locked.to_string()),
2981 }
2982 }
2983
2984 #[test]
2985 fn test_convert_account_position_happy_path() {
2986 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2987 u: Some(1_700_000_000_000),
2988 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
2989 ..Default::default()
2990 };
2991 let mut buf = Vec::new();
2992 convert_account_position(position, &mut buf);
2993
2994 assert_eq!(buf.len(), 1);
2995 match &buf[0].kind {
2996 AccountEventKind::BalanceStreamUpdate(snap) => {
2997 let update = &snap.0;
2998 assert_eq!(update.asset.as_ref(), "BTC");
2999 let expected_free = Decimal::from_str("1.5").unwrap();
3001 let expected_locked = Decimal::from_str("0.5").unwrap();
3002 assert_eq!(update.update.free, expected_free);
3003 assert_eq!(update.update.locked, expected_locked);
3004 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3005 }
3006 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3007 }
3008 }
3009
3010 #[test]
3011 fn test_convert_account_position_u_field_none_uses_now() {
3012 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3014 u: None,
3015 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3016 ..Default::default()
3017 };
3018 let mut buf = Vec::new();
3019 convert_account_position(position, &mut buf);
3020 assert_eq!(buf.len(), 1);
3021 }
3022
3023 #[test]
3024 fn test_convert_account_position_missing_asset_name_skipped() {
3025 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3026 u: Some(1_700_000_000_000),
3027 b_uppercase: Some(vec![
3028 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3029 a: None, f: Some("1.0".to_string()),
3031 l: Some("0.0".to_string()),
3032 },
3033 make_balance_inner("USDT", "100.0", "0.0"), ]),
3035 ..Default::default()
3036 };
3037 let mut buf = Vec::new();
3038 convert_account_position(position, &mut buf);
3039 assert_eq!(buf.len(), 1);
3041 match &buf[0].kind {
3042 AccountEventKind::BalanceStreamUpdate(snap) => {
3043 assert_eq!(snap.0.asset.as_ref(), "USDT");
3044 }
3045 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3046 }
3047 }
3048
3049 #[test]
3050 fn test_convert_account_position_unparseable_free_skipped() {
3051 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3052 u: Some(1_700_000_000_000),
3053 b_uppercase: Some(vec![
3054 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3055 a: Some("BTC".to_string()),
3056 f: Some("not-a-number".to_string()),
3057 l: Some("0.0".to_string()),
3058 },
3059 make_balance_inner("ETH", "1.0", "0.0"), ]),
3061 ..Default::default()
3062 };
3063 let mut buf = Vec::new();
3064 convert_account_position(position, &mut buf);
3065 assert_eq!(buf.len(), 1);
3067 match &buf[0].kind {
3068 AccountEventKind::BalanceStreamUpdate(snap) => {
3069 assert_eq!(snap.0.asset.as_ref(), "ETH");
3070 }
3071 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3072 }
3073 }
3074
3075 #[test]
3076 fn test_convert_account_position_empty_balances() {
3077 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3078 u: Some(1_700_000_000_000),
3079 b_uppercase: Some(vec![]),
3080 ..Default::default()
3081 };
3082 let mut buf = Vec::new();
3083 convert_account_position(position, &mut buf);
3084 assert!(
3085 buf.is_empty(),
3086 "empty balance list should produce no events"
3087 );
3088 }
3089
3090 #[test]
3091 fn test_convert_account_position_b_field_none() {
3092 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3093 u: Some(1_700_000_000_000),
3094 b_uppercase: None, ..Default::default()
3096 };
3097 let mut buf = Vec::new();
3098 convert_account_position(position, &mut buf);
3099 assert!(buf.is_empty(), "None B field should produce no events");
3100 }
3101
3102 #[tokio::test]
3104 async fn test_exponential_backoff_exhaustion() {
3105 tokio::time::pause(); let mut backoff = ExponentialBackoff::new();
3107
3108 for i in 0..MAX_RECONNECT_ATTEMPTS {
3110 assert!(
3111 backoff.wait().await,
3112 "expected true on attempt {i} (before exhaustion)"
3113 );
3114 }
3115
3116 assert!(!backoff.wait().await, "expected false after max attempts");
3118 }
3119
3120 #[tokio::test]
3121 async fn test_exponential_backoff_reset() {
3122 tokio::time::pause();
3123 let mut backoff = ExponentialBackoff::new();
3124
3125 for _ in 0..MAX_RECONNECT_ATTEMPTS {
3126 backoff.wait().await;
3127 }
3128 assert!(!backoff.wait().await, "should be exhausted");
3129
3130 backoff.reset();
3131 assert!(backoff.wait().await, "should succeed again after reset");
3132 }
3133
3134 fn make_base_report() -> binance_sdk::spot::websocket_api::ExecutionReport {
3138 binance_sdk::spot::websocket_api::ExecutionReport {
3139 s: Some("BTCUSDT".to_string()),
3140 i: Some(12345),
3141 c: Some("client-1".to_string()),
3142 t_uppercase: Some(1_700_000_000_000),
3143 ..Default::default()
3144 }
3145 }
3146
3147 fn convert(
3149 report: binance_sdk::spot::websocket_api::ExecutionReport,
3150 ) -> Vec<UnindexedAccountEvent> {
3151 let mut buf = Vec::new();
3152 convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
3153 buf
3154 }
3155
3156 fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
3161 assert!(
3162 events.len() <= 1,
3163 "expected at most a single event, got: {events:?}"
3164 );
3165 events.pop()
3166 }
3167
3168 #[test]
3169 fn test_convert_execution_report_new() {
3170 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3171 x: Some("NEW".to_string()),
3172 s_uppercase: Some("BUY".to_string()),
3173 o: Some("LIMIT".to_string()),
3174 p: Some("50000.00".to_string()),
3175 q: Some("0.01".to_string()),
3176 f: Some("GTC".to_string()),
3177 z: Some("0".to_string()),
3178 ..make_base_report()
3179 };
3180
3181 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3182 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3183 match &event.kind {
3184 AccountEventKind::OrderSnapshot(snap) => {
3185 let order = &snap.0;
3186 assert_eq!(order.side, Side::Buy);
3187 assert_eq!(order.kind, OrderKind::Limit);
3188 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3189 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3190 assert_eq!(order.key.cid.0.as_str(), "client-1");
3191 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3192 }
3193 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3194 }
3195 }
3196
3197 #[test]
3198 fn test_convert_execution_report_trade() {
3199 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3202 x: Some("TRADE".to_string()),
3203 s_uppercase: Some("BUY".to_string()),
3204 t: Some(9999),
3205 l_uppercase: Some("50000.00".to_string()),
3206 l: Some("0.01".to_string()),
3207 n: Some("0.000001".to_string()),
3208 ..make_base_report()
3209 };
3210
3211 let event = sole_event(convert(report)).expect("TRADE event should produce Some");
3212 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3213 match &event.kind {
3214 AccountEventKind::Trade(trade) => {
3215 assert_eq!(trade.side, Side::Buy);
3216 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3217 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3218 assert_eq!(trade.id.0.as_str(), "9999");
3219 assert_eq!(trade.order_id.0.as_str(), "12345");
3220 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3221 }
3222 other => panic!("TRADE should yield Trade, got {other:?}"),
3223 }
3224 }
3225
3226 fn trade_events(
3228 events: Vec<UnindexedAccountEvent>,
3229 ) -> (
3230 Trade<AssetNameExchange, InstrumentNameExchange>,
3231 crate::order::Order<
3232 ExchangeId,
3233 InstrumentNameExchange,
3234 OrderState<AssetNameExchange, InstrumentNameExchange>,
3235 >,
3236 ) {
3237 let [first, second]: [UnindexedAccountEvent; 2] = events
3238 .try_into()
3239 .expect("a fill report must produce an execution and an order snapshot");
3240 let AccountEventKind::Trade(trade) = first.kind else {
3241 panic!("first event must be the Trade, got {:?}", first.kind);
3242 };
3243 let AccountEventKind::OrderSnapshot(snap) = second.kind else {
3244 panic!(
3245 "second event must be the OrderSnapshot, got {:?}",
3246 second.kind
3247 );
3248 };
3249 (trade, snap.0)
3250 }
3251
3252 #[test]
3256 fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
3257 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3258 x: Some("TRADE".to_string()),
3259 x_uppercase: Some("PARTIALLY_FILLED".to_string()),
3260 s_uppercase: Some("BUY".to_string()),
3261 o: Some("LIMIT".to_string()),
3262 p: Some("50000.00".to_string()),
3263 q: Some("0.01".to_string()),
3264 f: Some("GTC".to_string()),
3265 t: Some(9999),
3266 l_uppercase: Some("50000.00".to_string()),
3267 l: Some("0.004".to_string()),
3268 z: Some("0.004".to_string()),
3269 ..make_base_report()
3270 };
3271
3272 let (trade, order) = trade_events(convert(report));
3273 assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
3274
3275 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3276 else {
3277 panic!("expected an Open snapshot, got {:?}", order.state);
3278 };
3279 assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
3280 assert_eq!(
3281 open.quantity_remaining(order.quantity),
3282 Decimal::from_str("0.006").unwrap()
3283 );
3284 }
3285
3286 #[test]
3289 fn a_filled_report_reports_nothing_left_to_fill() {
3290 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3291 x: Some("TRADE".to_string()),
3292 x_uppercase: Some("FILLED".to_string()),
3293 s_uppercase: Some("BUY".to_string()),
3294 o: Some("LIMIT".to_string()),
3295 p: Some("50000.00".to_string()),
3296 q: Some("0.01".to_string()),
3297 f: Some("GTC".to_string()),
3298 t: Some(10_000),
3299 l_uppercase: Some("50000.00".to_string()),
3300 l: Some("0.006".to_string()),
3301 z: Some("0.01".to_string()),
3302 ..make_base_report()
3303 };
3304
3305 let (_trade, order) = trade_events(convert(report));
3306 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3307 else {
3308 panic!("expected an Open snapshot, got {:?}", order.state);
3309 };
3310 assert!(
3311 open.quantity_remaining(order.quantity).is_zero(),
3312 "a completed order must report nothing left to fill so the engine retires it"
3313 );
3314 }
3315
3316 #[test]
3320 fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
3321 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
3322 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3323 x: Some("TRADE".to_string()),
3324 x_uppercase: Some(status.to_string()),
3325 s_uppercase: Some("BUY".to_string()),
3326 o: Some("LIMIT".to_string()),
3327 p: Some("50000.00".to_string()),
3328 q: Some("0.01".to_string()),
3329 f: Some("GTC".to_string()),
3330 t: Some(9999),
3331 l_uppercase: Some("50000.00".to_string()),
3332 l: Some("0.004".to_string()),
3333 z: Some("0.004".to_string()),
3334 ..make_base_report()
3335 };
3336 let events = convert(report);
3337 assert_eq!(
3338 events.len(),
3339 1,
3340 "status {status} must produce the execution alone, got: {events:?}"
3341 );
3342 assert!(
3343 matches!(events[0].kind, AccountEventKind::Trade(_)),
3344 "the execution must still be reported for status {status}"
3345 );
3346 }
3347 }
3348
3349 #[test]
3350 fn test_convert_execution_report_trade_missing_last_price() {
3351 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3353 x: Some("TRADE".to_string()),
3354 s_uppercase: Some("BUY".to_string()),
3355 t: Some(9999),
3356 l_uppercase: None, l: Some("0.01".to_string()),
3358 ..make_base_report()
3359 };
3360 assert!(
3361 sole_event(convert(report)).is_none(),
3362 "missing last price (L) should return None"
3363 );
3364 }
3365
3366 #[test]
3367 fn test_convert_execution_report_trade_missing_last_qty() {
3368 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3370 x: Some("TRADE".to_string()),
3371 s_uppercase: Some("BUY".to_string()),
3372 t: Some(9999),
3373 l_uppercase: Some("50000.00".to_string()),
3374 l: None, ..make_base_report()
3376 };
3377 assert!(
3378 sole_event(convert(report)).is_none(),
3379 "missing last qty (l) should return None"
3380 );
3381 }
3382
3383 #[test]
3384 fn test_convert_execution_report_canceled() {
3385 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3386 x: Some("CANCELED".to_string()),
3387 ..make_base_report()
3388 };
3389
3390 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3391 assert!(
3392 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3393 "CANCELED should yield OrderCancelled with Ok state"
3394 );
3395 }
3396
3397 #[test]
3398 fn test_convert_execution_report_expired() {
3399 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3400 x: Some("EXPIRED".to_string()),
3401 ..make_base_report()
3402 };
3403 let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
3404 assert!(
3405 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3406 "EXPIRED should yield OrderCancelled with Ok state"
3407 );
3408 }
3409
3410 #[test]
3411 fn test_convert_execution_report_expired_in_match() {
3412 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3413 x: Some("EXPIRED_IN_MATCH".to_string()),
3414 ..make_base_report()
3415 };
3416 let event = sole_event(convert(report)).expect("EXPIRED_IN_MATCH should produce Some");
3417 assert!(
3418 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3419 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3420 );
3421 }
3422
3423 #[test]
3424 fn test_convert_execution_report_rejected() {
3425 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3426 x: Some("REJECTED".to_string()),
3427 r: Some("INSUFFICIENT_FUNDS".to_string()),
3428 ..make_base_report()
3429 };
3430
3431 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
3432 assert!(
3433 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_err()),
3434 "REJECTED should yield OrderCancelled with Err state"
3435 );
3436 }
3437
3438 #[test]
3439 fn test_convert_execution_report_missing_exec_type() {
3440 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3442 s: Some("BTCUSDT".to_string()),
3443 ..Default::default()
3444 };
3445 assert!(
3446 sole_event(convert(report)).is_none(),
3447 "missing execution type should return None"
3448 );
3449 }
3450
3451 #[test]
3452 fn test_convert_execution_report_missing_symbol() {
3453 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3455 x: Some("NEW".to_string()),
3456 ..Default::default()
3457 };
3458 assert!(
3459 sole_event(convert(report)).is_none(),
3460 "missing symbol should return None"
3461 );
3462 }
3463
3464 #[test]
3465 fn test_convert_execution_report_missing_order_id() {
3466 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3468 x: Some("NEW".to_string()),
3469 s: Some("BTCUSDT".to_string()),
3470 i: None,
3471 ..Default::default()
3472 };
3473 assert!(
3474 sole_event(convert(report)).is_none(),
3475 "missing orderId should return None"
3476 );
3477 }
3478
3479 #[test]
3480 fn test_convert_execution_report_trade_missing_trade_id() {
3481 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3483 x: Some("TRADE".to_string()),
3484 t: None,
3485 ..make_base_report()
3486 };
3487 assert!(
3488 sole_event(convert(report)).is_none(),
3489 "TRADE event missing tradeId should return None"
3490 );
3491 }
3492
3493 #[test]
3494 fn test_convert_execution_report_replace_yields_cancelled() {
3495 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3496 x: Some("REPLACE".to_string()),
3497 ..make_base_report()
3498 };
3499 let event = sole_event(convert(report)).expect("REPLACE should produce Some");
3502 assert!(
3503 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3504 "REPLACE should yield OrderCancelled with Ok state"
3505 );
3506 }
3507
3508 fn make_balance(
3513 asset: &str,
3514 free: &str,
3515 locked: &str,
3516 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3517 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3518 asset: Some(asset.to_string()),
3519 free: Some(free.to_string()),
3520 locked: Some(locked.to_string()),
3521 }
3522 }
3523
3524 #[test]
3525 fn test_filter_balances_empty_assets_returns_all() {
3526 let balances = vec![
3527 make_balance("BTC", "1.0", "0.0"),
3528 make_balance("USDT", "500.0", "50.0"),
3529 ];
3530 let result = filter_and_convert_balances(balances, &[]);
3531 assert_eq!(result.len(), 2, "empty filter should return all balances");
3532 }
3533
3534 #[test]
3535 fn test_filter_balances_matching_asset_returned() {
3536 let balances = vec![
3537 make_balance("BTC", "1.5", "0.5"),
3538 make_balance("ETH", "10.0", "0.0"),
3539 ];
3540 let assets = vec![AssetNameExchange::new("BTC")];
3541 let result = filter_and_convert_balances(balances, &assets);
3542 assert_eq!(result.len(), 1);
3543 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
3544 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
3546 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
3547 }
3548
3549 #[test]
3550 fn test_filter_balances_non_matching_asset_filtered_out() {
3551 let balances = vec![
3552 make_balance("BTC", "1.0", "0.0"),
3553 make_balance("ETH", "2.0", "0.0"),
3554 ];
3555 let assets = vec![AssetNameExchange::new("USDT")];
3556 let result = filter_and_convert_balances(balances, &assets);
3557 assert!(
3558 result.is_empty(),
3559 "non-matching asset should be filtered out"
3560 );
3561 }
3562
3563 #[test]
3564 fn test_filter_balances_missing_asset_field_skipped() {
3565 let balances = vec![
3566 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3567 asset: None,
3568 free: Some("1.0".to_string()),
3569 locked: Some("0.0".to_string()),
3570 },
3571 make_balance("USDT", "100.0", "0.0"),
3572 ];
3573 let result = filter_and_convert_balances(balances, &[]);
3574 assert_eq!(
3575 result.len(),
3576 1,
3577 "entry with missing asset should be skipped"
3578 );
3579 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
3580 }
3581
3582 #[test]
3583 fn test_filter_balances_unparseable_free_skipped() {
3584 let balances = vec![
3585 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3586 asset: Some("BTC".to_string()),
3587 free: Some("not-a-number".to_string()),
3588 locked: Some("0.0".to_string()),
3589 },
3590 ];
3591 let result = filter_and_convert_balances(balances, &[]);
3592 assert!(
3593 result.is_empty(),
3594 "unparseable free field should be skipped"
3595 );
3596 }
3597
3598 #[test]
3599 fn test_filter_balances_zero_balance_included() {
3600 let balances = vec![
3603 make_balance("BTC", "0.00000000", "0.00000000"),
3604 make_balance("USDT", "100.0", "0.0"),
3605 ];
3606 let result = filter_and_convert_balances(balances, &[]);
3607 assert_eq!(result.len(), 2, "zero-balance entries must be included");
3608 let btc = result
3609 .iter()
3610 .find(|b| b.asset == AssetNameExchange::new("BTC"))
3611 .unwrap();
3612 assert_eq!(btc.balance.total, Decimal::ZERO);
3613 assert_eq!(btc.balance.free, Decimal::ZERO);
3614 }
3615
3616 #[test]
3617 fn test_filter_balances_duplicate_assets_in_response() {
3618 let balances = vec![
3622 make_balance("BTC", "1.0", "0.0"),
3623 make_balance("BTC", "2.0", "0.0"),
3624 ];
3625 let result = filter_and_convert_balances(balances, &[]);
3626 assert_eq!(
3627 result.len(),
3628 2,
3629 "duplicate asset entries produce two AssetBalance entries"
3630 );
3631 }
3632
3633 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
3638 binance_sdk::spot::rest_api::MyTradesResponseInner {
3639 id: Some(9001),
3640 order_id: Some(4242),
3641 price: Some("50000.00".to_string()),
3642 qty: Some("0.01".to_string()),
3643 commission: Some("0.05".to_string()),
3644 time: Some(1_700_000_000_000),
3645 is_buyer: Some(true),
3646 ..Default::default()
3647 }
3648 }
3649
3650 #[test]
3651 fn test_convert_my_trade_happy_path() {
3652 let instrument = InstrumentNameExchange::new("BTCUSDT");
3653 let trade =
3654 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
3655 assert_eq!(trade.instrument, instrument);
3656 assert_eq!(trade.side, Side::Buy);
3657 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3658 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3659 }
3660
3661 #[test]
3662 fn test_convert_my_trade_sell_side() {
3663 let instrument = InstrumentNameExchange::new("BTCUSDT");
3664 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3665 is_buyer: Some(false),
3666 ..make_base_trade()
3667 };
3668 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
3669 assert_eq!(trade.side, Side::Sell);
3670 }
3671
3672 #[test]
3673 fn test_convert_my_trade_missing_id_returns_none() {
3674 let instrument = InstrumentNameExchange::new("BTCUSDT");
3675 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3676 id: None,
3677 ..make_base_trade()
3678 };
3679 assert!(
3680 convert_my_trade(&t, &instrument).is_none(),
3681 "missing id should return None"
3682 );
3683 }
3684
3685 #[test]
3686 fn test_convert_my_trade_missing_order_id_returns_none() {
3687 let instrument = InstrumentNameExchange::new("BTCUSDT");
3688 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3689 order_id: None,
3690 ..make_base_trade()
3691 };
3692 assert!(
3693 convert_my_trade(&t, &instrument).is_none(),
3694 "missing orderId should return None"
3695 );
3696 }
3697
3698 #[test]
3699 fn test_convert_my_trade_missing_is_buyer_returns_none() {
3700 let instrument = InstrumentNameExchange::new("BTCUSDT");
3701 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3702 is_buyer: None,
3703 ..make_base_trade()
3704 };
3705 assert!(
3706 convert_my_trade(&t, &instrument).is_none(),
3707 "missing isBuyer should return None"
3708 );
3709 }
3710
3711 #[test]
3712 fn test_convert_my_trade_commission_none_defaults_to_zero() {
3713 let instrument = InstrumentNameExchange::new("BTCUSDT");
3714 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3715 commission: None,
3716 ..make_base_trade()
3717 };
3718 let trade =
3719 convert_my_trade(&t, &instrument).expect("None commission should still convert");
3720 assert_eq!(trade.fees.fees, Decimal::ZERO);
3721 }
3722
3723 fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3728 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3729 order_id: Some(12345),
3730 client_order_id: Some("cid-abc".to_string()),
3731 side: Some("BUY".to_string()),
3732 r#type: Some("LIMIT".to_string()),
3733 price: Some("50000.00".to_string()),
3734 orig_qty: Some("0.01".to_string()),
3735 executed_qty: Some("0.0".to_string()),
3736 time_in_force: Some("GTC".to_string()),
3737 time: Some(1_700_000_000_000),
3738 status: Some("NEW".to_string()),
3739 ..Default::default()
3740 }
3741 }
3742
3743 #[test]
3744 fn test_convert_open_order_all_orders_response_converts_identically() {
3745 let instrument = InstrumentNameExchange::new("BTCUSDT");
3752 let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3753 order_id: Some(12345),
3754 client_order_id: Some("cid-abc".to_string()),
3755 side: Some("BUY".to_string()),
3756 r#type: Some("LIMIT".to_string()),
3757 price: Some("50000.00".to_string()),
3758 orig_qty: Some("0.01".to_string()),
3759 executed_qty: Some("0.0".to_string()),
3760 time_in_force: Some("GTC".to_string()),
3761 time: Some(1_700_000_000_000),
3762 status: Some("NEW".to_string()),
3763 ..Default::default()
3764 };
3765 let from_all_orders = convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument)
3766 .expect("valid order should convert");
3767 let from_open_orders = convert_open_order(
3768 &make_base_open_order(),
3769 ExchangeId::BinanceSpot,
3770 &instrument,
3771 )
3772 .expect("valid order should convert");
3773 assert_eq!(from_all_orders, from_open_orders);
3774 }
3775
3776 #[test]
3782 fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
3783 let instrument = InstrumentNameExchange::new("BTCUSDT");
3784 for status in [
3785 "CANCELED",
3786 "FILLED",
3787 "EXPIRED",
3788 "EXPIRED_IN_MATCH",
3789 "REJECTED",
3790 "PENDING_CANCEL",
3791 "SOME_FUTURE_STATUS",
3792 ] {
3793 let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3794 order_id: Some(12345),
3795 client_order_id: Some("cid-abc".to_string()),
3796 side: Some("BUY".to_string()),
3797 r#type: Some("LIMIT".to_string()),
3798 price: Some("50000.00".to_string()),
3799 orig_qty: Some("0.01".to_string()),
3800 executed_qty: Some("0.004".to_string()),
3801 time_in_force: Some("GTC".to_string()),
3802 time: Some(1_700_000_000_000),
3803 status: Some(status.to_string()),
3804 ..Default::default()
3805 };
3806 assert_eq!(
3807 convert_open_order(&row, ExchangeId::BinanceSpot, &instrument),
3808 None,
3809 "{status}"
3810 );
3811 }
3812 }
3813
3814 #[test]
3815 fn test_convert_open_order_admits_every_live_status() {
3816 let instrument = InstrumentNameExchange::new("BTCUSDT");
3817 for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
3818 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3819 status: Some(status.to_string()),
3820 ..make_base_open_order()
3821 };
3822 assert!(
3823 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_some(),
3824 "{status}"
3825 );
3826 }
3827 }
3828
3829 #[test]
3834 fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
3835 let instrument = InstrumentNameExchange::new("BTCUSDT");
3836 let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3837 order_id: Some(67890),
3838 client_order_id: Some("cid-def".to_string()),
3839 ..make_base_open_order()
3840 };
3841
3842 let listing = convert_open_order_listing(
3843 &[make_base_open_order(), second()],
3844 ExchangeId::BinanceSpot,
3845 &instrument,
3846 );
3847 assert_eq!(listing.orders.len(), 2);
3848 assert!(listing.complete);
3849
3850 let empty = convert_open_order_listing::<
3851 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
3852 >(&[], ExchangeId::BinanceSpot, &instrument);
3853 assert!(empty.orders.is_empty());
3854 assert!(empty.complete, "no open orders is a complete answer");
3855
3856 let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3857 side: None,
3858 ..second()
3859 };
3860 let listing = convert_open_order_listing(
3861 &[make_base_open_order(), dropped],
3862 ExchangeId::BinanceSpot,
3863 &instrument,
3864 );
3865 assert_eq!(listing.orders.len(), 1);
3866 assert!(!listing.complete, "a dropped row may be a live order");
3867
3868 let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3869 client_order_id: None,
3870 ..second()
3871 };
3872 let listing = convert_open_order_listing(
3873 &[make_base_open_order(), without_cid],
3874 ExchangeId::BinanceSpot,
3875 &instrument,
3876 );
3877 assert_eq!(listing.orders.len(), 2, "the row is still converted");
3878 assert!(
3879 !listing.complete,
3880 "an order kept under its orderId cannot be found under the cid it was placed with"
3881 );
3882 }
3883
3884 #[test]
3885 fn test_convert_open_order_missing_status_returns_none() {
3886 let instrument = InstrumentNameExchange::new("BTCUSDT");
3887 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3888 status: None,
3889 ..make_base_open_order()
3890 };
3891 assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none());
3892 }
3893
3894 #[test]
3900 fn test_convert_open_order_prefers_update_time_over_creation_time() {
3901 let instrument = InstrumentNameExchange::new("BTCUSDT");
3902
3903 let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3904 update_time: Some(1_700_000_005_000),
3905 ..make_base_open_order()
3906 };
3907 let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument)
3908 .expect("valid order should convert");
3909 assert_eq!(
3910 order.state.time_exchange,
3911 Utc.timestamp_millis_opt(1_700_000_005_000)
3912 .single()
3913 .unwrap(),
3914 "update_time must win over time"
3915 );
3916
3917 let order = convert_open_order(
3920 &make_base_open_order(),
3921 ExchangeId::BinanceSpot,
3922 &instrument,
3923 )
3924 .expect("valid order should convert");
3925 assert_eq!(
3926 order.state.time_exchange,
3927 Utc.timestamp_millis_opt(1_700_000_000_000)
3928 .single()
3929 .unwrap(),
3930 "absent update_time falls back to time"
3931 );
3932 }
3933
3934 #[test]
3935 fn test_convert_open_order_happy_path() {
3936 let instrument = InstrumentNameExchange::new("BTCUSDT");
3937 let order = convert_open_order(
3938 &make_base_open_order(),
3939 ExchangeId::BinanceSpot,
3940 &instrument,
3941 )
3942 .expect("valid order should convert");
3943 assert_eq!(order.key.instrument, instrument);
3944 assert_eq!(order.side, Side::Buy);
3945 assert_eq!(order.kind, OrderKind::Limit);
3946 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3947 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3948 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
3949 }
3950
3951 #[test]
3952 fn test_convert_open_order_owned_symbol_recovers_instrument() {
3953 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3955 symbol: Some("ETHUSDT".to_string()),
3956 ..make_base_open_order()
3957 };
3958 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot)
3959 .expect("valid order should convert");
3960 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
3961 assert_eq!(order.side, Side::Buy);
3962 }
3963
3964 #[test]
3965 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
3966 let o = make_base_open_order();
3968 assert!(o.symbol.is_none());
3969 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot).is_none());
3970 }
3971
3972 #[test]
3973 fn test_convert_open_order_missing_order_id_returns_none() {
3974 let instrument = InstrumentNameExchange::new("BTCUSDT");
3975 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3976 order_id: None,
3977 ..make_base_open_order()
3978 };
3979 assert!(
3980 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
3981 "missing orderId should return None"
3982 );
3983 }
3984
3985 #[test]
3986 fn test_convert_open_order_missing_side_returns_none() {
3987 let instrument = InstrumentNameExchange::new("BTCUSDT");
3988 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3989 side: None,
3990 ..make_base_open_order()
3991 };
3992 assert!(
3993 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
3994 "missing side should return None"
3995 );
3996 }
3997
3998 #[test]
3999 fn test_convert_open_order_missing_type_returns_none() {
4000 let instrument = InstrumentNameExchange::new("BTCUSDT");
4001 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4002 r#type: None,
4003 ..make_base_open_order()
4004 };
4005 assert!(
4006 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4007 "missing type should return None"
4008 );
4009 }
4010
4011 #[test]
4012 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
4013 let instrument = InstrumentNameExchange::new("BTCUSDT");
4014 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4015 executed_qty: None,
4016 ..make_base_open_order()
4017 };
4018 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4019 .expect("None executedQty should still convert");
4020 assert_eq!(
4021 order.state.filled_quantity,
4022 Decimal::ZERO,
4023 "None executedQty should default to zero"
4024 );
4025 }
4026
4027 #[test]
4028 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
4029 let instrument = InstrumentNameExchange::new("BTCUSDT");
4030 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4031 executed_qty: Some("bad-value".to_string()),
4032 ..make_base_open_order()
4033 };
4034 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4035 .expect("unparseable executedQty should still convert");
4036 assert_eq!(
4037 order.state.filled_quantity,
4038 Decimal::ZERO,
4039 "unparseable executedQty should default to zero"
4040 );
4041 }
4042
4043 #[test]
4048 fn test_dedup_key_from_event_non_open_states_return_none() {
4049 use crate::order::state::{
4050 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
4051 };
4052 use rustrade_integration::collection::snapshot::Snapshot;
4053
4054 let key = OrderKey::new(
4055 ExchangeId::BinanceSpot,
4056 InstrumentNameExchange::new("BTCUSDT"),
4057 StrategyId::unknown(),
4058 ClientOrderId::new("cid1"),
4059 );
4060
4061 let event = UnindexedAccountEvent::new(
4063 ExchangeId::BinanceSpot,
4064 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4065 key.clone(),
4066 Side::Buy,
4067 None, Decimal::ZERO,
4069 OrderKind::Market,
4070 TimeInForce::ImmediateOrCancel,
4071 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4072 ActiveOrderState::OpenInFlight(OpenInFlight),
4073 ),
4074 ))),
4075 );
4076 assert!(
4077 dedup_key_from_event(&event).is_none(),
4078 "OpenInFlight should return None — dedup not meaningful before exchange ack"
4079 );
4080
4081 let event = UnindexedAccountEvent::new(
4083 ExchangeId::BinanceSpot,
4084 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4085 key.clone(),
4086 Side::Buy,
4087 None, Decimal::ZERO,
4089 OrderKind::Market,
4090 TimeInForce::ImmediateOrCancel,
4091 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4092 ActiveOrderState::CancelInFlight(CancelInFlight { order: None }),
4093 ),
4094 ))),
4095 );
4096 assert!(
4097 dedup_key_from_event(&event).is_none(),
4098 "CancelInFlight should return None"
4099 );
4100
4101 let event = UnindexedAccountEvent::new(
4103 ExchangeId::BinanceSpot,
4104 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4105 key,
4106 Side::Buy,
4107 None, Decimal::ONE,
4109 OrderKind::Market,
4110 TimeInForce::ImmediateOrCancel,
4111 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
4112 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
4113 OrderId::new("123"),
4114 Utc::now(),
4115 Decimal::ONE, None,
4117 )),
4118 ),
4119 ))),
4120 );
4121 assert!(
4122 dedup_key_from_event(&event).is_none(),
4123 "Inactive state should return None"
4124 );
4125 }
4126
4127 #[test]
4128 fn test_dedup_key_from_event_cancelled_error_returns_none() {
4129 let report = binance_sdk::spot::websocket_api::ExecutionReport {
4132 x: Some("REJECTED".to_string()),
4133 r: Some("INSUFFICIENT_FUNDS".to_string()),
4134 ..make_base_report()
4135 };
4136 let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
4137 assert!(
4138 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
4139 "prerequisite: event is OrderCancelled with Err"
4140 );
4141 assert!(
4142 dedup_key_from_event(&event).is_none(),
4143 "OrderCancelled with Err state should return None"
4144 );
4145 }
4146
4147 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
4157 let mut value = serde_json::to_value(event).expect("event serializes to Value");
4158 value
4159 .as_object_mut()
4160 .expect("event serializes to a JSON object")
4161 .insert(
4162 "e".to_string(),
4163 serde_json::Value::String(event_type.to_string()),
4164 );
4165 serde_json::json!({ "subscriptionId": 0, "event": value }).to_string()
4166 }
4167
4168 #[test]
4169 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
4170 let report = binance_sdk::spot::websocket_api::ExecutionReport {
4171 x: Some("NEW".to_string()),
4172 s_uppercase: Some("BUY".to_string()),
4173 o: Some("LIMIT".to_string()),
4174 p: Some("50000.00".to_string()),
4175 q: Some("0.01".to_string()),
4176 f: Some("GTC".to_string()),
4177 z: Some("0".to_string()),
4178 ..make_base_report()
4179 };
4180 let frame = user_data_frame("executionReport", &report);
4181 let mut buf = Vec::new();
4182 let terminated = convert_user_data_events(&frame, &mut buf);
4183 assert!(
4184 !terminated,
4185 "ExecutionReport should not signal stream termination"
4186 );
4187 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
4188 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
4189 }
4190
4191 #[test]
4192 fn test_convert_user_data_events_account_position_pushes_to_buf() {
4193 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4194 u: Some(1_700_000_000_000),
4195 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
4196 ..Default::default()
4197 };
4198 let frame = user_data_frame("outboundAccountPosition", &position);
4199 let mut buf = Vec::new();
4200 let terminated = convert_user_data_events(&frame, &mut buf);
4201 assert!(
4202 !terminated,
4203 "OutboundAccountPosition should not signal stream termination"
4204 );
4205 assert_eq!(
4206 buf.len(),
4207 1,
4208 "OutboundAccountPosition should push one balance event"
4209 );
4210 }
4211
4212 #[test]
4213 fn test_convert_user_data_events_balance_update_ignored() {
4214 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4215 ..Default::default()
4216 };
4217 let frame = user_data_frame("balanceUpdate", &update);
4218 let mut buf = Vec::new();
4219 let terminated = convert_user_data_events(&frame, &mut buf);
4220 assert!(
4221 !terminated,
4222 "BalanceUpdate should not signal stream termination"
4223 );
4224 assert!(buf.is_empty(), "BalanceUpdate should push no events");
4225 }
4226
4227 #[test]
4228 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4229 let frame =
4231 r#"{"subscriptionId":0,"event":{"e":"eventStreamTerminated","E":1700000000000}}"#;
4232 let mut buf = Vec::new();
4233 let terminated = convert_user_data_events(frame, &mut buf);
4234 assert!(
4235 terminated,
4236 "eventStreamTerminated must signal stream termination"
4237 );
4238 assert!(
4239 buf.is_empty(),
4240 "eventStreamTerminated should push no events"
4241 );
4242 }
4243
4244 #[test]
4245 fn test_convert_user_data_events_unknown_event_ignored() {
4246 let frame =
4249 r#"{"subscriptionId":0,"event":{"e":"listStatus","E":1700000000000,"s":"BTCUSDT"}}"#;
4250 let mut buf = Vec::new();
4251 let terminated = convert_user_data_events(frame, &mut buf);
4252 assert!(!terminated, "unknown event must not signal termination");
4253 assert!(buf.is_empty(), "unknown event should push no events");
4254 }
4255
4256 #[test]
4261 fn test_convert_user_data_events_reads_the_ws_api_envelope() {
4262 let fill = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000001000,
4263 "s":"BTCUSDT","c":"client-1","S":"SELL","o":"MARKET","f":"GTC","q":"0.5","p":"0",
4264 "x":"TRADE","X":"FILLED","i":777,"l":"0.5","z":"0.5","L":"40000","n":"0.02",
4265 "N":"USDT","T":1700000001000,"t":888}}"#;
4266 let mut buf = Vec::new();
4267 assert!(!convert_user_data_events(fill, &mut buf));
4268 assert!(
4269 buf.iter()
4270 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
4271 "the enveloped fill must produce its trade, got: {buf:?}"
4272 );
4273
4274 let bare = r#"{"e":"executionReport","s":"BTCUSDT","i":777,"x":"NEW","X":"NEW"}"#;
4276 buf.clear();
4277 assert!(!convert_user_data_events(bare, &mut buf));
4278 assert!(buf.is_empty());
4279 }
4280
4281 #[test]
4282 fn test_convert_user_data_events_non_user_data_frame_ignored() {
4283 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4285 let mut buf = Vec::new();
4286 let terminated = convert_user_data_events(frame, &mut buf);
4287 assert!(
4288 !terminated,
4289 "non-user-data frame must not signal termination"
4290 );
4291 assert!(buf.is_empty(), "non-user-data frame should push no events");
4292 }
4293
4294 #[tokio::test]
4299 async fn test_rate_limit_tracker_not_blocked_initially() {
4300 tokio::time::pause();
4301 let tracker = RateLimitTracker::new(WeightPool::Spot);
4302 tokio::time::timeout(
4304 std::time::Duration::from_millis(1),
4305 tracker.wait_if_blocked(RequestKind::Order),
4306 )
4307 .await
4308 .expect("wait_if_blocked should return immediately when not blocked");
4309 }
4310
4311 #[tokio::test]
4312 async fn test_rate_limit_tracker_blocks_until_deadline() {
4313 tokio::time::pause();
4314 let tracker = RateLimitTracker::new(WeightPool::Spot);
4315 let delay = Duration::from_secs(5);
4316 tracker.on_rate_limited(Some(delay));
4317
4318 assert!(
4320 tokio::time::timeout(
4321 Duration::from_millis(1),
4322 tracker.wait_if_blocked(RequestKind::Order)
4323 )
4324 .await
4325 .is_err(),
4326 "wait_if_blocked should block while cooldown is active"
4327 );
4328
4329 tokio::time::advance(delay + Duration::from_millis(1)).await;
4331 tokio::time::timeout(
4332 Duration::from_millis(1),
4333 tracker.wait_if_blocked(RequestKind::Order),
4334 )
4335 .await
4336 .expect("wait_if_blocked should return after cooldown expires");
4337 }
4338
4339 #[tokio::test]
4340 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
4341 tokio::time::pause();
4342 let tracker = RateLimitTracker::new(WeightPool::Spot);
4343
4344 tracker.on_rate_limited(Some(Duration::from_secs(5)));
4346 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4348
4349 tokio::time::advance(Duration::from_secs(6)).await;
4351 assert!(
4352 tokio::time::timeout(
4353 Duration::from_millis(1),
4354 tracker.wait_if_blocked(RequestKind::Order)
4355 )
4356 .await
4357 .is_err(),
4358 "cooldown should have been extended to 10s"
4359 );
4360
4361 tokio::time::advance(Duration::from_secs(5)).await;
4363 tokio::time::timeout(
4364 Duration::from_millis(1),
4365 tracker.wait_if_blocked(RequestKind::Order),
4366 )
4367 .await
4368 .expect("wait_if_blocked should return after extended cooldown expires");
4369 }
4370
4371 #[tokio::test]
4372 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
4373 tokio::time::pause();
4374 let tracker = RateLimitTracker::new(WeightPool::Spot);
4375
4376 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4378 tracker.on_rate_limited(Some(Duration::from_secs(2)));
4379
4380 tokio::time::advance(Duration::from_secs(3)).await;
4382 assert!(
4383 tokio::time::timeout(
4384 Duration::from_millis(1),
4385 tracker.wait_if_blocked(RequestKind::Order)
4386 )
4387 .await
4388 .is_err(),
4389 "shorter on_rate_limited must not shorten existing cooldown"
4390 );
4391 }
4392
4393 #[test]
4398 fn test_parse_binance_api_error_balance_insufficient_holds_instrument_name() {
4399 let instrument = InstrumentNameExchange::new("BTCUSDT");
4400 match parse_binance_api_error("Insufficient balance".into(), &instrument) {
4401 ApiError::BalanceInsufficient(asset_field, _) => {
4402 assert_eq!(
4407 asset_field.name().as_str(),
4408 "BTCUSDT",
4409 "BalanceInsufficient.0 holds the instrument name, not an asset name"
4410 );
4411 }
4412 other => panic!("expected BalanceInsufficient, got {other:?}"),
4413 }
4414 }
4415
4416 #[test]
4421 fn test_classify_ws_order_error_with_wrapped_error_chain() {
4422 let instrument = InstrumentNameExchange::new("BTCUSDT");
4427 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
4428 code: -2010,
4429 message: "insufficient balance".into(),
4430 });
4431 assert!(
4433 classify_ws_order_error(&raw, &instrument).is_some(),
4434 "unwrapped ResponseError at root must be detected"
4435 );
4436 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
4438 assert!(
4439 classify_ws_order_error(&wrapped, &instrument).is_some(),
4440 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
4441 );
4442 }
4443
4444 #[tokio::test]
4447 async fn rest_used_weight_near_the_limit_pauses_queries() {
4448 for (used, pauses) in [(5_399, false), (5_400, true)] {
4449 let server = wiremock::MockServer::start().await;
4450 wiremock::Mock::given(wiremock::matchers::method("GET"))
4451 .and(wiremock::matchers::path("/api/v3/openOrders"))
4452 .respond_with(
4453 wiremock::ResponseTemplate::new(200)
4454 .insert_header("x-mbx-used-weight-1m", used.to_string())
4455 .set_body_json(serde_json::json!([])),
4456 )
4457 .mount(&server)
4458 .await;
4459 let rest = Arc::new(SpotRestApi::from_config(
4460 ConfigurationRestApi::builder()
4461 .api_key("key")
4462 .api_secret("secret")
4463 .base_path(server.uri())
4464 .build()
4465 .unwrap(),
4466 ));
4467 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4468
4469 fetch_all_open_orders(rest, Arc::clone(&tracker))
4470 .await
4471 .unwrap();
4472
4473 let query_waits = tokio::time::timeout(
4474 Duration::from_millis(50),
4475 tracker.wait_if_blocked(RequestKind::Query),
4476 )
4477 .await
4478 .is_err();
4479 assert_eq!(query_waits, pauses, "used weight {used}");
4480 tokio::time::timeout(
4481 Duration::from_millis(50),
4482 tracker.wait_if_blocked(RequestKind::Order),
4483 )
4484 .await
4485 .expect("orders never wait on the pause");
4486 }
4487 }
4488
4489 fn client_with_ws_api(ws_url: String) -> BinanceSpot {
4491 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4492 "key".into(),
4493 "secret".into(),
4494 ));
4495 client.ws_handle = SpotWsApi::from_config(
4496 ConfigurationWebsocketApi::builder()
4497 .api_key("key")
4498 .api_secret("secret")
4499 .ws_url(ws_url)
4500 .build()
4501 .unwrap(),
4502 );
4503 client
4504 }
4505
4506 fn cancel_request(
4508 instrument: &InstrumentNameExchange,
4509 ) -> OrderRequestCancel<ExchangeId, &InstrumentNameExchange> {
4510 crate::order::OrderEvent {
4511 key: OrderKey {
4512 exchange: ExchangeId::BinanceSpot,
4513 instrument,
4514 strategy: StrategyId::new("strategy"),
4515 cid: ClientOrderId::new("cid"),
4516 },
4517 state: crate::order::request::RequestCancel { id: None },
4518 }
4519 }
4520
4521 async fn ws_server<F, Fut>(serve: F) -> String
4523 where
4524 F: Fn(tokio::net::TcpStream) -> Fut + Send + 'static,
4525 Fut: std::future::Future<Output = ()> + Send + 'static,
4526 {
4527 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4528 let url = format!("ws://{}", listener.local_addr().unwrap());
4529 tokio::spawn(async move {
4530 while let Ok((stream, _)) = listener.accept().await {
4531 tokio::spawn(serve(stream));
4532 }
4533 });
4534 url
4535 }
4536
4537 #[tokio::test]
4540 async fn cancel_without_a_session_during_a_cooldown_is_rejected_unsent() {
4541 let client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4542 "key".into(),
4543 "secret".into(),
4544 ));
4545 client
4546 .rate_limiter
4547 .on_rate_limited(Some(Duration::from_secs(60)));
4548 let instrument = InstrumentNameExchange::new("BTCUSDT");
4549 let response = client
4550 .cancel_order(cancel_request(&instrument))
4551 .await
4552 .expect("a cancel always answers");
4553
4554 assert!(
4555 matches!(
4556 response.state,
4557 Err(UnindexedOrderError::Rejected(ApiError::RateLimit))
4558 ),
4559 "{:?}",
4560 response.state
4561 );
4562 assert!(
4563 client.ws_api.read().await.is_none(),
4564 "no session was opened"
4565 );
4566 }
4567
4568 #[tokio::test]
4571 async fn a_handshake_refused_with_429_starts_a_cooldown() {
4572 use tokio_tungstenite::tungstenite::http;
4573
4574 let url = ws_server(|stream| async move {
4575 #[allow(clippy::result_large_err)]
4577 let refuse = |_: &http::Request<()>, _| {
4578 Err(http::Response::builder()
4579 .status(http::StatusCode::TOO_MANY_REQUESTS)
4580 .body(None)
4581 .unwrap())
4582 };
4583 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
4584 })
4585 .await;
4586 let client = client_with_ws_api(url);
4587 let instrument = InstrumentNameExchange::new("BTCUSDT");
4588
4589 let response = client
4590 .cancel_order(cancel_request(&instrument))
4591 .await
4592 .expect("a cancel always answers");
4593
4594 assert!(
4595 matches!(
4596 response.state,
4597 Err(UnindexedOrderError::Rejected(ApiError::RateLimit))
4598 ),
4599 "{:?}",
4600 response.state
4601 );
4602 assert!(client.rate_limiter.is_blocked());
4603 }
4604
4605 #[tokio::test]
4608 async fn a_ws_api_response_near_the_weight_limit_pauses_queries() {
4609 use futures::{SinkExt as _, StreamExt as _};
4610 use tokio_tungstenite::tungstenite::Message;
4611
4612 let url = ws_server(|stream| async move {
4613 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
4614 return;
4615 };
4616 while let Some(Ok(message)) = ws.next().await {
4617 let Message::Text(text) = message else {
4618 continue;
4619 };
4620 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
4621 let response = serde_json::json!({
4622 "id": request["id"],
4623 "status": 200,
4624 "result": {
4625 "symbol": "BTCUSDT",
4626 "origClientOrderId": "cid",
4627 "orderId": 7,
4628 "transactTime": 1_700_000_000_000_i64,
4629 "executedQty": "0",
4630 },
4631 "rateLimits": [{
4632 "rateLimitType": "REQUEST_WEIGHT",
4633 "interval": "MINUTE",
4634 "intervalNum": 1,
4635 "limit": 1_000,
4636 "count": 950,
4637 }],
4638 });
4639 if ws.send(Message::text(response.to_string())).await.is_err() {
4640 return;
4641 }
4642 }
4643 })
4644 .await;
4645 let client = client_with_ws_api(url);
4646 let instrument = InstrumentNameExchange::new("BTCUSDT");
4647
4648 let response = client
4649 .cancel_order(cancel_request(&instrument))
4650 .await
4651 .expect("a cancel always answers");
4652
4653 assert!(response.state.is_ok(), "{:?}", response.state);
4654 assert_eq!(client.rate_limiter.weight_limit(), 1_000);
4655 assert!(
4656 tokio::time::timeout(
4657 Duration::from_millis(50),
4658 client.rate_limiter.wait_if_blocked(RequestKind::Query),
4659 )
4660 .await
4661 .is_err(),
4662 "queries pause"
4663 );
4664 tokio::time::timeout(
4665 Duration::from_millis(50),
4666 client.rate_limiter.wait_if_blocked(RequestKind::Order),
4667 )
4668 .await
4669 .expect("orders never wait on the pause");
4670 }
4671
4672 #[tokio::test]
4675 async fn fill_recovery_does_not_wait_for_the_weight_pause() {
4676 let server = wiremock::MockServer::start().await;
4677 wiremock::Mock::given(wiremock::matchers::method("GET"))
4678 .and(wiremock::matchers::path("/api/v3/myTrades"))
4679 .respond_with(
4681 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
4682 "symbol": "BTCUSDT",
4683 "id": 1,
4684 "orderId": 7,
4685 "price": "100",
4686 "qty": "1",
4687 "commission": "0",
4688 "commissionAsset": "USDT",
4689 "time": Utc::now().timestamp_millis(),
4690 "isBuyer": true,
4691 "isMaker": false,
4692 }])),
4693 )
4694 .expect(2)
4695 .mount(&server)
4696 .await;
4697 let rest = Arc::new(SpotRestApi::from_config(
4698 ConfigurationRestApi::builder()
4699 .api_key("key")
4700 .api_secret("secret")
4701 .base_path(server.uri())
4702 .build()
4703 .unwrap(),
4704 ));
4705 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4706 tracker.throttle(Duration::from_secs(60));
4707 let (tx, _rx) = mpsc::unbounded_channel();
4708 let mut unrecovered = UnrecoveredFills::default();
4709 unrecovered.open(
4710 &[InstrumentNameExchange::new("BTCUSDT")],
4711 Utc::now() - chrono::Duration::minutes(1),
4712 Utc::now(),
4713 );
4714
4715 tokio::time::timeout(
4717 Duration::from_millis(800),
4718 recover_fills(&rest, &tracker, &mut unrecovered, &tx, &new_dedup_cache()),
4719 )
4720 .await
4721 .expect("recovery does not wait for the pause");
4722 }
4723
4724 #[tokio::test]
4727 async fn a_failed_recovery_keeps_its_closed_gap_for_the_retry() {
4728 let disconnect = Utc::now() - chrono::Duration::minutes(10);
4729 let reconnect = Utc::now() - chrono::Duration::minutes(5);
4730 let trade = |id: i64, time: DateTime<Utc>| {
4731 serde_json::json!({
4732 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
4733 "commission": "0", "commissionAsset": "USDT", "time": time.timestamp_millis(),
4734 "isBuyer": true, "isMaker": false, "isBestMatch": true,
4735 })
4736 };
4737 let server = wiremock::MockServer::start().await;
4738 wiremock::Mock::given(wiremock::matchers::method("GET"))
4739 .and(wiremock::matchers::path("/api/v3/myTrades"))
4740 .respond_with(
4741 wiremock::ResponseTemplate::new(400)
4742 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
4743 )
4744 .up_to_n_times(1)
4745 .with_priority(1)
4746 .mount(&server)
4747 .await;
4748 wiremock::Mock::given(wiremock::matchers::method("GET"))
4749 .and(wiremock::matchers::path("/api/v3/myTrades"))
4750 .respond_with(
4751 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
4752 trade(1, disconnect + chrono::Duration::minutes(1)),
4753 trade(2, Utc::now() - chrono::Duration::minutes(1)),
4754 ])),
4755 )
4756 .mount(&server)
4757 .await;
4758 let rest = Arc::new(SpotRestApi::from_config(
4759 ConfigurationRestApi::builder()
4760 .api_key("key")
4761 .api_secret("secret")
4762 .base_path(server.uri())
4763 .build()
4764 .unwrap(),
4765 ));
4766 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4767 let (tx, mut rx) = mpsc::unbounded_channel();
4768 let dedup = new_dedup_cache();
4769 let mut unrecovered = UnrecoveredFills::default();
4770 unrecovered.open(
4771 &[InstrumentNameExchange::new("BTCUSDT")],
4772 disconnect,
4773 reconnect,
4774 );
4775
4776 recover_fills(&rest, &tracker, &mut unrecovered, &tx, &dedup).await;
4777 assert!(!unrecovered.is_empty(), "the failed gap is kept");
4778 assert!(rx.try_recv().is_err(), "nothing forwarded");
4779 assert!(
4780 unrecovered.due(tokio::time::Instant::now()).is_empty(),
4781 "and waits for its retry"
4782 );
4783
4784 unrecovered.make_due();
4785 recover_fills(&rest, &tracker, &mut unrecovered, &tx, &dedup).await;
4786 assert!(unrecovered.is_empty(), "the retry recovered it");
4787 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
4788 assert_eq!(
4789 forwarded.len(),
4790 1,
4791 "only the fill inside the gap: {forwarded:?}"
4792 );
4793
4794 let start_times: Vec<String> = server
4795 .received_requests()
4796 .await
4797 .unwrap()
4798 .iter()
4799 .filter_map(|request| {
4800 request
4801 .url
4802 .query_pairs()
4803 .find(|(key, _)| key == "startTime")
4804 .map(|(_, value)| value.into_owned())
4805 })
4806 .collect();
4807 let start = disconnect.timestamp_millis().to_string();
4808 assert_eq!(start_times, [start.clone(), start]);
4809 }
4810
4811 #[tokio::test]
4814 async fn a_span_walk_pages_by_id_and_stops_past_the_end() {
4815 let start = Utc::now().timestamp_millis() - 600_000;
4816 let end = start + 10_000;
4817 let trade = |id: i64, time: i64| {
4818 serde_json::json!({
4819 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
4820 "commission": "0", "commissionAsset": "USDT", "time": time,
4821 "isBuyer": true, "isMaker": false, "isBestMatch": true,
4822 })
4823 };
4824 let server = wiremock::MockServer::start().await;
4825 wiremock::Mock::given(wiremock::matchers::method("GET"))
4826 .and(wiremock::matchers::path("/api/v3/myTrades"))
4827 .and(wiremock::matchers::query_param("fromId", "1001"))
4828 .respond_with(
4830 wiremock::ResponseTemplate::new(200).set_body_json(
4831 (1_001..=2_000)
4832 .map(|id| trade(id, if id == 1_001 { end } else { end + 1 }))
4833 .collect::<Vec<_>>(),
4834 ),
4835 )
4836 .with_priority(1)
4837 .mount(&server)
4838 .await;
4839 let full_page: Vec<_> = (1..=1_000).map(|id| trade(id, start + id)).collect();
4840 wiremock::Mock::given(wiremock::matchers::method("GET"))
4841 .and(wiremock::matchers::path("/api/v3/myTrades"))
4842 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(full_page))
4843 .mount(&server)
4844 .await;
4845 let rest = Arc::new(SpotRestApi::from_config(
4846 ConfigurationRestApi::builder()
4847 .api_key("key")
4848 .api_secret("secret")
4849 .base_path(server.uri())
4850 .build()
4851 .unwrap(),
4852 ));
4853
4854 let read = paginate_my_trades(
4855 &rest,
4856 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
4857 &InstrumentNameExchange::new("BTCUSDT"),
4858 MyTradesFrom::Span { start, end },
4859 RequestKind::Query,
4860 )
4861 .await
4862 .unwrap();
4863
4864 let ids: Vec<_> = read.iter().filter_map(|t| t.id).collect();
4865 assert_eq!(ids, (1..=1_001).collect::<Vec<_>>());
4866 assert_eq!(server.received_requests().await.unwrap().len(), 2);
4867 }
4868
4869 #[tokio::test]
4872 async fn a_timed_out_recovery_charges_only_the_gaps_it_started() {
4873 let server = wiremock::MockServer::start().await;
4874 wiremock::Mock::given(wiremock::matchers::method("GET"))
4875 .and(wiremock::matchers::path("/api/v3/myTrades"))
4876 .respond_with(
4877 wiremock::ResponseTemplate::new(200)
4878 .set_body_json(serde_json::json!([]))
4879 .set_delay(Duration::from_secs(3_600)),
4880 )
4881 .mount(&server)
4882 .await;
4883 let rest = Arc::new(SpotRestApi::from_config(
4884 ConfigurationRestApi::builder()
4885 .api_key("key")
4886 .api_secret("secret")
4887 .base_path(server.uri())
4888 .timeout(3_600_000_u64)
4889 .retries(0_u32)
4890 .build()
4891 .unwrap(),
4892 ));
4893 let instruments: Vec<_> = (0..9)
4894 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
4895 .collect();
4896 let mut unrecovered = UnrecoveredFills::default();
4897 unrecovered.open(
4898 &instruments,
4899 Utc::now() - chrono::Duration::minutes(10),
4900 Utc::now(),
4901 );
4902 let (tx, _rx) = mpsc::unbounded_channel();
4903 tokio::time::pause();
4905
4906 recover_fills(
4907 &rest,
4908 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
4909 &mut unrecovered,
4910 &tx,
4911 &new_dedup_cache(),
4912 )
4913 .await;
4914
4915 let now = tokio::time::Instant::now();
4916 assert_eq!(
4917 unrecovered.due(now).len(),
4918 1,
4919 "the gap never started stays due"
4920 );
4921 assert_eq!(
4922 unrecovered
4923 .due(now + Duration::from_secs(GAP_RETRY_BASE_SECS))
4924 .len(),
4925 9,
4926 "the eight started wait for their first retry"
4927 );
4928 }
4929}