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, PlacementResponse, RateLimitTracker,
40 RequestKind, SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, UnrecoveredFills, UserDataFrame,
41 WeightPool, binance_filled_qty, classify_order_kind_tif, classify_rest_query_error,
42 classify_ws_order_error, convert_ended_order, convert_execution_report,
43 convert_open_order_listing, convert_open_order_owned_symbol, dedup_key_from_event, drop_after,
44 gap_failed, gap_time, is_duplicate, is_handshake_rate_limit, is_unknown_order,
45 log_unrecognised_frame, new_dedup_cache, parse_user_data_frame, placed_order_state,
46 recovered_order_totals, response_decode_error, rest_call_with_retry, unix_ms,
47};
48use crate::{
49 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
50 UnindexedAccountEvent, UnindexedAccountSnapshot,
51 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
52 client::{
53 ExecutionClient, OrderStatusClient,
54 order_recovery::{
55 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
56 fetch_ended_by_key, recover_ended_orders,
57 },
58 },
59 emit_stream_terminated,
60 error::{
61 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
62 UnindexedOrderError,
63 },
64 order::{
65 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType, UnindexedInactiveOrder,
66 UnindexedOrderKey,
67 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
68 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
69 state::{Cancelled, Open, OrderState, UnindexedOrderState},
70 },
71 parse_env_bool,
72 position::PositionReport,
73 trade::{AssetFees, Trade, TradeId, TradesRead},
74};
75use binance_sdk::{
76 common::{
77 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
78 models::WebsocketEvent,
79 },
80 spot::{
81 SpotRestApi, SpotWsApi,
82 rest_api::{
83 GetAccountParams, GetOpenOrdersParams, GetOrderParams, MyTradesParams, RestApi,
84 },
85 websocket_api::{
86 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
87 OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
88 WebsocketApiHandle,
89 },
90 },
91};
92use chrono::{DateTime, TimeZone, Utc};
93use fnv::FnvHashSet;
94use futures::stream::BoxStream;
95use rust_decimal::Decimal;
96use rustrade_instrument::{
97 Side,
98 asset::name::AssetNameExchange,
99 exchange::ExchangeId,
100 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
101};
102use serde::Deserialize;
103use smol_str::format_smolstr;
104use std::{
105 str::FromStr,
106 sync::{
107 Arc,
108 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
109 },
110 time::Duration,
111};
112use tokio::sync::{RwLock, mpsc, oneshot};
113use tracing::{debug, error, info, trace, warn};
114
115#[derive(Clone, Deserialize)]
122pub struct BinanceSpotConfig {
123 api_key: String,
126 secret_key: String,
127
128 #[serde(default = "default_testnet")]
130 pub testnet: bool,
131}
132
133fn default_testnet() -> bool {
139 true
140}
141
142impl std::fmt::Debug for BinanceSpotConfig {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.debug_struct("BinanceSpotConfig")
146 .field("api_key", &"***")
147 .field("secret_key", &"***")
148 .field("testnet", &self.testnet)
149 .finish()
150 }
151}
152
153impl BinanceSpotConfig {
154 pub fn new(api_key: String, secret_key: String) -> Self {
157 Self::testnet(api_key, secret_key)
158 }
159
160 pub fn testnet(api_key: String, secret_key: String) -> Self {
162 Self {
163 api_key,
164 secret_key,
165 testnet: true,
166 }
167 }
168
169 pub fn production(api_key: String, secret_key: String) -> Self {
174 Self {
175 api_key,
176 secret_key,
177 testnet: false,
178 }
179 }
180
181 pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
200 let api_key = match std::env::var("BINANCE_API_KEY") {
201 Ok(value) => value,
202 Err(std::env::VarError::NotPresent) => {
203 return Err(BinanceSpotConfigError::MissingApiKey);
204 }
205 Err(std::env::VarError::NotUnicode(_)) => {
206 return Err(BinanceSpotConfigError::InvalidApiKey);
207 }
208 };
209 let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
210 Ok(value) => value,
211 Err(std::env::VarError::NotPresent) => {
212 return Err(BinanceSpotConfigError::MissingSecretKey);
213 }
214 Err(std::env::VarError::NotUnicode(_)) => {
215 return Err(BinanceSpotConfigError::InvalidSecretKey);
216 }
217 };
218 let testnet = match std::env::var("BINANCE_TESTNET") {
219 Ok(value) => {
220 parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
221 }
222 Err(std::env::VarError::NotPresent) => true,
223 Err(std::env::VarError::NotUnicode(value)) => {
226 return Err(BinanceSpotConfigError::InvalidTestnet(
227 value.to_string_lossy().into_owned(),
228 ));
229 }
230 };
231
232 if testnet {
233 Ok(Self::testnet(api_key, secret_key))
234 } else {
235 Ok(Self::production(api_key, secret_key))
236 }
237 }
238
239 pub fn api_key(&self) -> &str {
241 &self.api_key
242 }
243}
244
245#[derive(Debug, PartialEq, thiserror::Error)]
246pub enum BinanceSpotConfigError {
247 #[error("BINANCE_API_KEY environment variable not set")]
248 MissingApiKey,
249
250 #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
253 InvalidApiKey,
254
255 #[error("BINANCE_SECRET_KEY environment variable not set")]
256 MissingSecretKey,
257
258 #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
259 InvalidSecretKey,
260
261 #[error("BINANCE_TESTNET must be true or false, got {0}")]
262 InvalidTestnet(String),
263}
264
265#[derive(Debug)]
272enum WsApiUnavailable {
273 RateLimited(String),
275 Connect(String),
277}
278
279impl WsApiUnavailable {
280 fn into_order_error(self) -> UnindexedOrderError {
283 match self {
284 Self::RateLimited(msg) => {
285 warn!(%msg, "BinanceSpot WS-API rate-limited, request not sent");
286 UnindexedOrderError::Rejected(ApiError::RateLimit)
287 }
288 Self::Connect(msg) => UnindexedOrderError::Connectivity(ConnectivityError::Socket(msg)),
289 }
290 }
291}
292
293#[derive(Clone)]
309pub struct BinanceSpot {
310 config: Arc<BinanceSpotConfig>,
311 rest: Arc<RestApi>,
312 ws_handle: WebsocketApiHandle,
314 ws_api: Arc<RwLock<Option<WebsocketApi>>>,
320 rate_limiter: Arc<RateLimitTracker>,
322 known_live: SharedKnownLiveOrders,
325}
326
327impl std::fmt::Debug for BinanceSpot {
328 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
329 f.debug_struct("BinanceSpot")
330 .field("testnet", &self.config.testnet)
331 .finish_non_exhaustive()
332 }
333}
334
335impl BinanceSpot {
336 fn remember_live<'a>(
338 &self,
339 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
340 ) {
341 let mut known = self.known_live.lock();
342 for order in orders {
343 known.live(&order.key, order.quantity, &order.state);
344 }
345 }
346
347 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
351 let rest_config = ConfigurationRestApi::builder()
352 .api_key(config.api_key.clone())
353 .api_secret(config.secret_key.clone())
354 .build()
355 .expect("failed to build Binance REST configuration");
356
357 Arc::new(if config.testnet {
358 SpotRestApi::testnet(rest_config)
359 } else {
360 SpotRestApi::production(rest_config)
361 })
362 }
363
364 #[allow(clippy::expect_used)] fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
368 let ws_config = ConfigurationWebsocketApi::builder()
369 .api_key(config.api_key.clone())
370 .api_secret(config.secret_key.clone())
371 .build()
372 .expect("failed to build Binance WebSocket configuration");
373
374 if config.testnet {
375 SpotWsApi::testnet(ws_config)
376 } else {
377 SpotWsApi::production(ws_config)
378 }
379 }
380
381 async fn get_ws_api(&self) -> Result<WebsocketApi, WsApiUnavailable> {
390 {
392 let guard = self.ws_api.read().await;
393 if let Some(ref ws) = *guard {
394 return Ok(ws.clone());
395 }
396 }
397 let mut guard = self.ws_api.write().await;
404 if let Some(ref ws) = *guard {
406 return Ok(ws.clone());
407 }
408 if self.rate_limiter.is_blocked() {
409 return Err(WsApiUnavailable::RateLimited(
410 "rate-limit cooldown active, not connecting the WS-API session".into(),
411 ));
412 }
413 let ws = match tokio::time::timeout(
414 Duration::from_secs(CONNECT_TIMEOUT_SECS),
415 self.ws_handle.connect(),
416 )
417 .await
418 {
419 Ok(Ok(ws)) => ws,
420 Ok(Err(e)) if is_handshake_rate_limit(&e) => {
421 self.rate_limiter.on_rate_limited(None);
422 return Err(WsApiUnavailable::RateLimited(format!("{e:#}")));
423 }
424 Ok(Err(e)) => return Err(WsApiUnavailable::Connect(format!("{e:#}"))),
425 Err(_) => {
426 return Err(WsApiUnavailable::Connect(format!(
427 "BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s"
428 )));
429 }
430 };
431 *guard = Some(ws.clone());
432 Ok(ws)
433 }
434
435 async fn clear_ws_api(&self) {
438 let ws = {
449 let mut guard = self.ws_api.write().await;
450 guard.take()
451 };
452 if let Some(ws) = ws {
453 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
454 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
455 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
456 Ok(Ok(())) => {}
457 }
458 }
459 }
460}
461
462async fn fetch_open_orders_for_instrument(
469 rest: Arc<RestApi>,
470 rate_limiter: Arc<RateLimitTracker>,
471 instrument: InstrumentNameExchange,
472 kind: RequestKind,
473) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
474 let symbol_str = instrument.name().to_string();
476 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
477 let sym = symbol_str.clone();
478 Box::pin(async move {
479 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
480 rest.get_open_orders(params).await
481 })
482 })
483 .await
484 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
485
486 let orders_data = response.data().await.map_err(response_decode_error)?;
487
488 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceSpot, &instrument);
489
490 Ok((instrument, listing))
491}
492
493async fn fetch_all_open_orders(
498 rest: Arc<RestApi>,
499 rate_limiter: Arc<RateLimitTracker>,
500) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
501 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
502 Box::pin(async move {
503 let params = GetOpenOrdersParams::builder().build()?;
504 rest.get_open_orders(params).await
505 })
506 })
507 .await
508 .map_err(|e| classify_rest_query_error(&e, None))?;
509
510 let orders_data = response.data().await.map_err(response_decode_error)?;
511
512 let orders = orders_data
513 .into_iter()
514 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot))
515 .collect();
516
517 Ok(orders)
518}
519
520async fn fetch_order_lookup(
522 rest: Arc<RestApi>,
523 rate_limiter: Arc<RateLimitTracker>,
524 key: UnindexedOrderKey,
525 kind: RequestKind,
526) -> Result<OrderLookup, UnindexedClientError> {
527 let symbol = key.instrument.name().to_string();
529 let cid = key.cid.0.to_string();
530 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
531 let (symbol, cid) = (symbol.clone(), cid.clone());
532 Box::pin(async move {
533 let params = GetOrderParams::builder(symbol)
534 .orig_client_order_id(cid)
535 .build()?;
536 rest.get_order(params).await
537 })
538 })
539 .await
540 {
541 Ok(response) => response,
542 Err(e) if is_unknown_order(&e) => {
543 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceSpot does not know this order");
544 return Ok(OrderLookup::Unknown);
545 }
546 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
547 };
548
549 let row = response.data().await.map_err(response_decode_error)?;
550
551 Ok(convert_ended_order(&row, ExchangeId::BinanceSpot, &key)
552 .map_or(OrderLookup::NotEnded, |order| {
553 OrderLookup::Ended(Box::new(order))
554 }))
555}
556
557async fn listed_open_cids(
560 rest: Arc<RestApi>,
561 rate_limiter: Arc<RateLimitTracker>,
562 instruments: Vec<InstrumentNameExchange>,
563) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
564 let mut listed = FnvHashSet::default();
565 for instrument in instruments {
566 let (_, listing) = fetch_open_orders_for_instrument(
567 rest.clone(),
568 rate_limiter.clone(),
569 instrument,
570 RequestKind::Essential,
571 )
572 .await?;
573 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
574 }
575 Ok(listed)
576}
577
578async fn recover_spot_ended_orders(
581 rest: &Arc<RestApi>,
582 rate_limiter: &Arc<RateLimitTracker>,
583 known: &SharedKnownLiveOrders,
584 unchecked: &mut UncheckedOrders,
585 unrecovered: &UnrecoveredFills,
586 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
587) {
588 recover_ended_orders(
589 ExchangeId::BinanceSpot,
590 known,
591 unchecked,
592 unrecovered,
593 tx,
594 OpenListing::PerInstrument,
595 |instruments| listed_open_cids(rest.clone(), rate_limiter.clone(), instruments),
596 |key| {
597 fetch_order_lookup(
598 rest.clone(),
599 rate_limiter.clone(),
600 key,
601 RequestKind::Essential,
602 )
603 },
604 )
605 .await;
606}
607
608async fn paginate_my_trades(
619 rest: &Arc<RestApi>,
620 rate_limiter: &Arc<RateLimitTracker>,
621 instrument: &InstrumentNameExchange,
622 from: MyTradesFrom,
623 kind: RequestKind,
624) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
625 let symbol_str = instrument.name().to_string();
627 let mut all_pages = Vec::new();
632 let mut cursor: Option<i64> = None;
633 loop {
634 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
636 let sym = symbol_str.clone();
637 Box::pin(async move {
638 #[allow(clippy::cast_possible_truncation)]
640 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
641 let params = match (from, fid) {
642 (MyTradesFrom::Span { start, .. }, None) => builder.start_time(start),
643 (MyTradesFrom::Span { .. }, Some(id)) => builder.from_id(id),
644 (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
645 (MyTradesFrom::Order(order_id), Some(id)) => {
646 builder.order_id(order_id).from_id(id)
647 }
648 }
649 .build()?;
650 rest.my_trades(params).await
651 })
652 })
653 .await
654 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
655
656 let mut page = response.data().await.map_err(response_decode_error)?;
657
658 let page_len = page.len();
659 let last_id = page.last().and_then(|t| t.id);
660 let past_end = match from {
662 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
663 MyTradesFrom::Order(_) => false,
664 };
665 all_pages.extend(page);
666
667 if past_end || page_len < BINANCE_MAX_TRADES {
668 break;
669 }
670 match last_id {
671 Some(id) => {
672 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
673 match id.checked_add(1) {
674 Some(next) => cursor = Some(next),
675 None => break, }
677 }
678 None => {
679 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
680 break;
681 }
682 }
683 }
684 Ok(all_pages)
685}
686
687impl ExecutionClient for BinanceSpot {
692 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
693
694 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
697 &[InstrumentKindDiscriminant::Spot];
698 type Config = BinanceSpotConfig;
699 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
700
701 fn new(config: Self::Config) -> Self {
706 let rest = Self::build_rest(&config);
707 let ws_handle = Self::build_ws_handle(&config);
708 Self {
709 config: Arc::new(config),
710 rest,
711 ws_handle,
712 ws_api: Arc::new(RwLock::new(None)),
713 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Spot)),
714 known_live: KnownLiveOrders::shared(ExchangeId::BinanceSpot),
715 }
716 }
717
718 async fn account_snapshot(
719 &self,
720 assets: &[AssetNameExchange],
721 instruments: &[InstrumentNameExchange],
722 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
723 let response =
725 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
726 Box::pin(async move {
727 let params = GetAccountParams::builder().build()?;
728 rest.get_account(params).await
729 })
730 })
731 .await
732 .map_err(|e| classify_rest_query_error(&e, None))?;
733
734 let account = response.data().await.map_err(response_decode_error)?;
735
736 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
738
739 use futures::{StreamExt as _, TryStreamExt};
745 let instrument_snapshots: Vec<_> =
746 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
747 fetch_open_orders_for_instrument(
748 self.rest.clone(),
749 self.rate_limiter.clone(),
750 instrument,
751 RequestKind::Query,
752 )
753 }))
754 .buffer_unordered(8)
755 .map(|result| {
756 let (inst, listing) = result?;
757 self.remember_live(&listing.orders);
758 let wrapped = listing
759 .orders
760 .into_iter()
761 .map(|o| Order {
762 key: o.key,
763 side: o.side,
764 price: o.price,
765 quantity: o.quantity,
766 kind: o.kind,
767 time_in_force: o.time_in_force,
768 state: OrderState::active(o.state),
769 })
770 .collect();
771 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
772 inst,
773 wrapped,
774 listing.complete,
775 PositionReport::Unreported,
776 None,
777 ))
778 })
779 .try_collect()
780 .await?;
781
782 Ok(AccountSnapshot::new(
783 ExchangeId::BinanceSpot,
784 balances,
785 instrument_snapshots,
786 ))
787 }
788
789 async fn account_stream(
863 &self,
864 _assets: &[AssetNameExchange],
868 instruments: &[InstrumentNameExchange],
869 ) -> Result<Self::AccountStream, UnindexedClientError> {
870 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
885 let dedup = new_dedup_cache();
886 let ws_handle = self.ws_handle.clone();
887 let rest = self.rest.clone();
888 let rate_limiter = self.rate_limiter.clone();
889 let known_live = self.known_live.clone();
890 let instruments = instruments.to_vec();
891 debug_assert!(
895 instruments.iter().all(|i| i.name().len() <= 23),
896 "instrument name exceeds SmolStr inline capacity: {:?}",
897 instruments.iter().find(|i| i.name().len() > 23)
898 );
899
900 let initial_ws = ws_handle.connect().await.map_err(|e| {
904 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
905 })?;
906
907 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
909 .build()
910 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
911
912 match initial_ws
913 .user_data_stream_subscribe_signature(params)
914 .await
915 {
916 Ok(_) => {}
917 Err(e) => {
918 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
923 Ok(Err(de)) => {
924 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
925 }
926 Err(_) => {
927 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
928 }
929 Ok(Ok(())) => {}
930 }
931 return Err(UnindexedClientError::Internal(e.to_string()));
932 }
933 }
934
935 let cm_handle = tokio::spawn(connection_manager(
947 tx,
948 dedup,
949 ws_handle,
950 rest,
951 rate_limiter,
952 known_live,
953 instruments,
954 Some(initial_ws),
955 ));
956
957 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
961 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
962 Ok(futures::StreamExt::boxed(guarded_stream))
963 }
964
965 async fn cancel_order(
966 &self,
967 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
968 ) -> UnindexedOrderResponseCancel {
969 let instrument = request.key.instrument.clone();
970 let key = OrderKey {
971 exchange: request.key.exchange,
972 instrument: instrument.clone(),
973 strategy: request.key.strategy.clone(),
974 cid: request.key.cid.clone(),
975 };
976
977 let ws = match self.get_ws_api().await {
978 Ok(ws) => ws,
979 Err(unavailable) => {
980 return UnindexedOrderResponseCancel {
981 key,
982 state: Err(unavailable.into_order_error()),
983 };
984 }
985 };
986
987 let mut params_builder =
990 OrderCancelParams::builder(request.key.instrument.name().to_string());
991
992 if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
994 if let Ok(id) = order_id.0.parse::<i64>() {
995 params_builder = params_builder.order_id(id);
996 } else {
997 error!(
1002 order_id = %order_id.0,
1003 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
1004 );
1005 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1006 }
1007 } else {
1008 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1009 }
1010
1011 let params = match params_builder.build() {
1012 Ok(p) => p,
1013 Err(e) => {
1014 error!(%e, "BinanceSpot failed to build cancel order params");
1015 return UnindexedOrderResponseCancel {
1016 key,
1017 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1018 e.to_string(),
1019 ))),
1020 };
1021 }
1022 };
1023
1024 let sent_ms = unix_ms();
1025 let result = ws.order_cancel(params).await;
1026 if let Ok(response) = &result {
1028 self.rate_limiter
1029 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1030 }
1031 match result {
1032 Ok(response) => match response.data() {
1033 Ok(data) => {
1034 let time_exchange = data
1035 .transact_time
1036 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1037 .unwrap_or_else(Utc::now);
1038
1039 let exchange_order_id = match data.order_id {
1040 Some(id) => OrderId(format_smolstr!("{id}")),
1041 None => {
1042 error!("BinanceSpot cancel response missing orderId");
1043 return UnindexedOrderResponseCancel {
1044 key,
1045 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1046 "cancel response missing orderId".into(),
1047 ))),
1048 };
1049 }
1050 };
1051
1052 let filled_qty = binance_filled_qty(
1053 ExchangeId::BinanceSpot,
1054 &exchange_order_id,
1055 data.executed_qty.as_deref(),
1056 );
1057
1058 self.known_live.lock().ended(&key.cid);
1059 UnindexedOrderResponseCancel {
1060 key,
1061 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1062 }
1063 }
1064 Err(e) => {
1065 UnindexedOrderResponseCancel {
1067 key,
1068 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1069 e.to_string(),
1070 ))),
1071 }
1072 }
1073 },
1074 Err(e) => {
1075 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1083 if matches!(order_err, OrderError::Rejected(ApiError::RateLimit)) {
1087 self.rate_limiter.on_rate_limited(None);
1088 }
1089 UnindexedOrderResponseCancel {
1090 key,
1091 state: Err(order_err),
1092 }
1093 } else {
1094 self.clear_ws_api().await;
1097 UnindexedOrderResponseCancel {
1098 key,
1099 state: Err(UnindexedOrderError::Connectivity(
1100 ConnectivityError::Socket(format!("{e:#}")),
1101 )),
1102 }
1103 }
1104 }
1105 }
1106 }
1107
1108 async fn open_order(
1109 &self,
1110 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1111 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1112 let instrument = request.key.instrument.clone();
1113 let side = request.state.side;
1114 let price = request.state.price;
1115 let quantity = request.state.quantity;
1116 let kind = request.state.kind;
1117 let time_in_force = request.state.time_in_force;
1118 let cid = request.key.cid.clone();
1119
1120 let order_key = OrderKey::new(
1121 ExchangeId::BinanceSpot,
1122 instrument.clone(),
1123 request.key.strategy.clone(),
1124 cid.clone(),
1125 );
1126
1127 let ws = match self.get_ws_api().await {
1128 Ok(ws) => ws,
1129 Err(unavailable) => {
1130 return Order {
1131 key: order_key,
1132 side,
1133 price,
1134 quantity,
1135 kind,
1136 time_in_force,
1137 state: OrderState::inactive(unavailable.into_order_error()),
1138 };
1139 }
1140 };
1141
1142 let binance_side = match side {
1143 Side::Buy => OrderPlaceSideEnum::Buy,
1144 Side::Sell => OrderPlaceSideEnum::Sell,
1145 };
1146
1147 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
1148 Some(converted) => converted,
1149 None => {
1150 return Order {
1151 key: order_key,
1152 side,
1153 price,
1154 quantity,
1155 kind,
1156 time_in_force,
1157 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1158 "Binance Spot does not yet support OrderKind::{kind:?}"
1159 ))),
1160 };
1161 }
1162 };
1163
1164 let mut params_builder =
1171 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
1172 .quantity(quantity)
1173 .new_client_order_id(cid.0.to_string());
1174
1175 match kind {
1178 OrderKind::Limit => {
1179 params_builder = params_builder.price(price);
1180 }
1181 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
1182 params_builder = params_builder.stop_price(trigger_price);
1183 }
1184 OrderKind::StopLimit { trigger_price }
1185 | OrderKind::TakeProfitLimit { trigger_price } => {
1186 params_builder = params_builder.price(price).stop_price(trigger_price);
1187 }
1188 OrderKind::TrailingStop {
1189 offset,
1190 offset_type,
1191 } => {
1192 let basis_points: i32 = match offset_type {
1194 TrailingOffsetType::BasisPoints => {
1195 let Ok(bp) = i32::try_from(offset) else {
1196 return Order {
1197 key: order_key,
1198 side,
1199 price,
1200 quantity,
1201 kind,
1202 time_in_force,
1203 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1204 format!(
1205 "TrailingStop basis-point offset {offset} overflows i32 \
1206 (Binance trailingDelta filter typically caps at 2000)"
1207 ),
1208 )),
1209 };
1210 };
1211 bp
1212 }
1213 TrailingOffsetType::Percentage => {
1214 let Ok(bp) = i32::try_from(offset * Decimal::from(100)) else {
1215 return Order {
1216 key: order_key,
1217 side,
1218 price,
1219 quantity,
1220 kind,
1221 time_in_force,
1222 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1223 format!(
1224 "TrailingStop percentage offset {offset} overflows i32 \
1225 after scaling to basis points"
1226 ),
1227 )),
1228 };
1229 };
1230 bp
1231 }
1232 TrailingOffsetType::Absolute => {
1233 return Order {
1237 key: order_key,
1238 side,
1239 price,
1240 quantity,
1241 kind,
1242 time_in_force,
1243 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1244 "TrailingStop with Absolute offset is not supported by Binance; \
1245 convert to basis points: (absolute / price) * 10000"
1246 .into(),
1247 )),
1248 };
1249 }
1250 };
1251 params_builder = params_builder.trailing_delta(basis_points);
1252 }
1253 _ => {}
1257 }
1258
1259 if let Some(tif) = binance_tif {
1260 params_builder = params_builder.time_in_force(tif);
1261 }
1262
1263 let params = match params_builder.build() {
1264 Ok(p) => p,
1265 Err(e) => {
1266 error!(%e, "BinanceSpot failed to build new order params");
1267 return Order {
1268 key: order_key,
1269 side,
1270 price,
1271 quantity,
1272 kind,
1273 time_in_force,
1274 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1275 e.to_string(),
1276 ))),
1277 };
1278 }
1279 };
1280
1281 let sent_ms = unix_ms();
1282 let result = ws.order_place(params).await;
1283 if let Ok(response) = &result {
1285 self.rate_limiter
1286 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1287 }
1288 match result {
1289 Ok(response) => match response.data() {
1290 Ok(data) => {
1291 let time_exchange = data
1292 .transact_time
1293 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1294 .unwrap_or_else(Utc::now);
1295
1296 let exchange_order_id = match data.order_id {
1297 Some(id) => OrderId(format_smolstr!("{id}")),
1298 None => {
1299 error!("BinanceSpot open_order response missing orderId");
1300 return Order {
1301 key: order_key,
1302 side,
1303 price,
1304 quantity,
1305 kind,
1306 time_in_force,
1307 state: OrderState::inactive(OrderError::Rejected(
1308 ApiError::OrderRejected(
1309 "open_order response missing orderId".into(),
1310 ),
1311 )),
1312 };
1313 }
1314 };
1315
1316 let state = placed_order_state(
1321 ExchangeId::BinanceSpot,
1322 &instrument,
1323 quantity,
1324 PlacementResponse {
1325 status: data.status.as_deref(),
1326 order_id: exchange_order_id,
1327 time_exchange,
1328 executed_qty: data.executed_qty.as_deref(),
1329 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
1330 },
1331 );
1332 self.known_live.lock().placed(&order_key, quantity, &state);
1333
1334 Order {
1335 key: order_key,
1336 side,
1337 price,
1338 quantity,
1339 kind,
1340 time_in_force,
1341 state,
1342 }
1343 }
1344 Err(e) => {
1345 Order {
1347 key: order_key,
1348 side,
1349 price,
1350 quantity,
1351 kind,
1352 time_in_force,
1353 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1354 e.to_string(),
1355 ))),
1356 }
1357 }
1358 },
1359 Err(e) => {
1360 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1368 if matches!(order_err, OrderError::Rejected(ApiError::RateLimit)) {
1372 self.rate_limiter.on_rate_limited(None);
1373 }
1374 Order {
1375 key: order_key,
1376 side,
1377 price,
1378 quantity,
1379 kind,
1380 time_in_force,
1381 state: OrderState::inactive(order_err),
1382 }
1383 } else {
1384 self.clear_ws_api().await;
1387 Order {
1388 key: order_key,
1389 side,
1390 price,
1391 quantity,
1392 kind,
1393 time_in_force,
1394 state: OrderState::inactive(OrderError::Connectivity(
1395 ConnectivityError::Socket(format!("{e:#}")),
1396 )),
1397 }
1398 }
1399 }
1400 }
1401 }
1402
1403 async fn fetch_balances(
1404 &self,
1405 assets: &[AssetNameExchange],
1406 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1407 let response =
1408 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1409 Box::pin(async move {
1410 let params = GetAccountParams::builder().build()?;
1411 rest.get_account(params).await
1412 })
1413 })
1414 .await
1415 .map_err(|e| classify_rest_query_error(&e, None))?;
1416
1417 let account = response.data().await.map_err(response_decode_error)?;
1418
1419 Ok(filter_and_convert_balances(
1420 account.balances.unwrap_or_default(),
1421 assets,
1422 ))
1423 }
1424
1425 async fn fetch_open_orders(
1430 &self,
1431 instruments: &[InstrumentNameExchange],
1432 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1433 if instruments.is_empty() {
1436 let orders =
1437 fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await?;
1438 self.remember_live(&orders);
1439 return Ok(orders);
1440 }
1441 use futures::{StreamExt as _, TryStreamExt as _};
1446 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1447 fetch_open_orders_for_instrument(
1448 self.rest.clone(),
1449 self.rate_limiter.clone(),
1450 instrument,
1451 RequestKind::Query,
1452 )
1453 }))
1454 .buffer_unordered(8)
1455 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1456 acc.extend(listing.orders);
1457 Ok(acc)
1458 })
1459 .await
1460 .inspect(|orders| self.remember_live(orders))
1461 }
1462
1463 #[allow(clippy::redundant_iter_cloned)]
1474 async fn fetch_trades(
1475 &self,
1476 start: DateTime<Utc>,
1477 end: DateTime<Utc>,
1478 instruments: &[InstrumentNameExchange],
1479 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1480 use futures::StreamExt;
1481
1482 if instruments.is_empty() {
1483 debug!(
1484 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1485 );
1486 return Ok(TradesRead::complete(Vec::new()));
1487 }
1488 if start > end {
1489 return Ok(TradesRead::complete(Vec::new()));
1490 }
1491 let span = MyTradesFrom::Span {
1493 start: start.timestamp_millis(),
1494 end: end.timestamp_millis(),
1495 };
1496 let mut all_trades = Vec::new();
1499
1500 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1504 let rest = self.rest.clone();
1505 let rate_limiter = self.rate_limiter.clone();
1506 async move {
1507 let pages =
1508 paginate_my_trades(&rest, &rate_limiter, &inst, span, RequestKind::Query)
1509 .await?;
1510 Ok::<_, UnindexedClientError>((inst, pages))
1511 }
1512 }))
1513 .buffer_unordered(8);
1514 while let Some(result) = stream.next().await {
1515 let (instrument, trades_data) = result?;
1516 for t in trades_data {
1517 if let Some(trade) = convert_my_trade(&t, &instrument)
1518 && (start..=end).contains(&trade.time_exchange)
1519 {
1520 all_trades.push(trade);
1521 }
1522 }
1523 }
1524
1525 Ok(TradesRead::complete(all_trades))
1526 }
1527}
1528
1529impl OrderStatusClient for BinanceSpot {
1544 async fn fetch_ended_orders(
1545 &self,
1546 orders: &[UnindexedOrderKey],
1547 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1548 fetch_ended_by_key(orders, |key| {
1549 fetch_order_lookup(
1550 self.rest.clone(),
1551 self.rate_limiter.clone(),
1552 key,
1553 RequestKind::Query,
1554 )
1555 })
1556 .await
1557 }
1558}
1559
1560async fn connect_and_subscribe(ws_handle: &WebsocketApiHandle) -> anyhow::Result<WebsocketApi> {
1563 let ws = ws_handle.connect().await?;
1564 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1566 .build()
1567 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1568 match ws.user_data_stream_subscribe_signature(params).await {
1569 Ok(_) => Ok(ws),
1570 Err(e) => {
1571 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1572 let ws_cleanup = ws;
1573 tokio::spawn(async move {
1583 match tokio::time::timeout(Duration::from_secs(5), ws_cleanup.disconnect()).await {
1584 Ok(Err(dc_err)) => warn!(%dc_err, "BinanceSpot WS cleanup disconnect failed"),
1585 Err(_) => warn!("BinanceSpot WS cleanup disconnect timed out (5s)"),
1586 Ok(Ok(())) => {}
1587 }
1588 });
1589 Err(e)
1590 }
1591 }
1592}
1593
1594#[allow(clippy::cognitive_complexity)]
1616async fn connection_manager(
1617 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1618 dedup: SharedDedupCache,
1619 ws_handle: WebsocketApiHandle,
1620 rest: Arc<RestApi>,
1621 rate_limiter: Arc<RateLimitTracker>,
1622 known: SharedKnownLiveOrders,
1623 instruments: Vec<InstrumentNameExchange>,
1624 initial_ws: Option<WebsocketApi>,
1625) {
1626 let mut backoff = ExponentialBackoff::new();
1627 let mut disconnect_time: Option<DateTime<Utc>> = None;
1628 let mut unrecovered = UnrecoveredFills::default();
1630 let mut unchecked = UncheckedOrders::default();
1632 let mut current_ws = initial_ws;
1633
1634 loop {
1635 let ws = match current_ws.take() {
1637 Some(ws) => ws,
1638 None => match connect_and_subscribe(&ws_handle).await {
1639 Ok(ws) => ws,
1645 Err(e) => {
1646 error!(%e, "BinanceSpot WS connect/subscribe failed");
1647 if !backoff.wait().await {
1648 error!("BinanceSpot max reconnect attempts exhausted");
1649 emit_stream_terminated(
1651 &tx,
1652 ExchangeId::BinanceSpot,
1653 StreamTerminationReason::ReconnectBudgetExhausted {
1654 attempts: backoff.attempts(),
1655 last_error: e.to_string(),
1656 },
1657 );
1658 break;
1659 }
1660 continue;
1661 }
1662 },
1663 };
1664 info!("BinanceSpot account_stream connected and subscribed");
1665
1666 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1672 let mut signal_tx_opt = Some(signal_tx);
1673 let heartbeat_flag = Arc::new(AtomicBool::new(true));
1677 let hb_callback = heartbeat_flag.clone();
1678 let dedup_callback = dedup.clone();
1679 let known_callback = known.clone();
1680 let mut event_tx = Some(tx.clone());
1684 let mut event_buf = Vec::with_capacity(32);
1688
1689 let subscription = ws.subscribe_on_ws_events(move |event| {
1696 let Some(ref sender) = event_tx else { return };
1697 match event {
1698 WebsocketEvent::Message(json_str) => {
1699 hb_callback.store(true, Ordering::Release);
1702 let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1706 for ev in event_buf.drain(..) {
1707 if let Some(key) = dedup_key_from_event(&ev)
1709 && is_duplicate(&dedup_callback, key)
1710 {
1711 trace!("BinanceSpot dedup: skipping duplicate event");
1712 continue;
1713 }
1714 let known = KnownLiveOrders::observes(&ev.kind).then(|| {
1718 let mut known = known_callback.lock();
1719 known.observe(&ev);
1720 known
1721 });
1722 if sender.send(ev).is_err() {
1723 drop(known);
1724 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1725 event_tx.take();
1726 if let Some(s) = signal_tx_opt.take() {
1727 let _ = s.send(());
1728 }
1729 return;
1730 }
1731 }
1732 if stream_terminated {
1735 event_tx.take();
1736 if let Some(s) = signal_tx_opt.take() {
1737 let _ = s.send(());
1738 }
1739 }
1740 }
1741 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1742 hb_callback.store(true, Ordering::Release);
1745 }
1746 WebsocketEvent::Error(e) => {
1747 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1751 event_tx.take();
1752 if let Some(s) = signal_tx_opt.take() {
1753 let _ = s.send(());
1754 }
1755 }
1756 WebsocketEvent::Close(code, reason) => {
1757 warn!(code, %reason, "BinanceSpot WebSocket closed");
1758 event_tx.take();
1759 if let Some(s) = signal_tx_opt.take() {
1760 let _ = s.send(());
1761 }
1762 }
1763 _ => {}
1764 }
1765 });
1766
1767 if let Some(dt) = disconnect_time.take() {
1774 unrecovered.open(&instruments, dt, Utc::now());
1775 let held = known.lock().instruments_among(&instruments);
1777 unchecked.open(held);
1778 }
1779 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known).await;
1780 recover_spot_ended_orders(
1781 &rest,
1782 &rate_limiter,
1783 &known,
1784 &mut unchecked,
1785 &unrecovered,
1786 &tx,
1787 )
1788 .await;
1789
1790 enum DisconnectReason {
1792 Signal,
1793 HeartbeatTimeout,
1794 ConsumerDropped,
1795 }
1796 let reason = {
1797 let mut signal_rx = signal_rx;
1798 let retry_gaps = async {
1804 loop {
1805 let next_check = unchecked.next_due(&unrecovered);
1806 match unrecovered.next_due().into_iter().chain(next_check).min() {
1807 Some(due) => tokio::time::sleep_until(due).await,
1808 None => std::future::pending::<()>().await,
1809 }
1810 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known)
1811 .await;
1812 recover_spot_ended_orders(
1813 &rest,
1814 &rate_limiter,
1815 &known,
1816 &mut unchecked,
1817 &unrecovered,
1818 &tx,
1819 )
1820 .await;
1821 }
1822 };
1823 tokio::pin!(retry_gaps);
1824 loop {
1825 tokio::select! {
1826 biased;
1828 _ = tx.closed() => {
1829 debug!("BinanceSpot account_stream consumer dropped, terminating");
1830 break DisconnectReason::ConsumerDropped;
1831 }
1832 _ = &mut signal_rx => {
1833 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1834 break DisconnectReason::Signal;
1835 }
1836 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1837 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1844 backoff.reset();
1848 continue;
1849 }
1850 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1851 break DisconnectReason::HeartbeatTimeout;
1852 }
1853 () = &mut retry_gaps => {}
1855 }
1856 }
1857 };
1858 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1859
1860 if should_reconnect {
1867 disconnect_time = Some(match reason {
1868 DisconnectReason::HeartbeatTimeout => {
1869 Utc::now()
1870 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1871 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1872 }
1873 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1874 });
1875 }
1876
1877 subscription.unsubscribe();
1885 if let Err(e) = ws.disconnect().await {
1888 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1889 }
1890
1891 if !should_reconnect || tx.is_closed() {
1892 debug!("BinanceSpot connection manager exiting");
1895 break;
1896 }
1897 if !backoff.wait().await {
1898 error!("BinanceSpot max reconnect attempts exhausted, stream terminating");
1899 let last_error = match reason {
1902 DisconnectReason::HeartbeatTimeout => {
1903 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
1904 }
1905 _ => "WebSocket disconnected (server close/error)".to_string(),
1906 };
1907 emit_stream_terminated(
1908 &tx,
1909 ExchangeId::BinanceSpot,
1910 StreamTerminationReason::ReconnectBudgetExhausted {
1911 attempts: backoff.attempts(),
1912 last_error,
1913 },
1914 );
1915 break;
1916 }
1917 }
1918}
1919
1920async fn recover_fills(
1937 rest: &Arc<RestApi>,
1938 rate_limiter: &Arc<RateLimitTracker>,
1939 unrecovered: &mut UnrecoveredFills,
1940 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1941 dedup: &SharedDedupCache,
1942 known: &SharedKnownLiveOrders,
1943) {
1944 use futures::StreamExt;
1945
1946 let due = unrecovered.due(tokio::time::Instant::now());
1947 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
1948 return;
1949 };
1950 info!(
1951 gaps = due.len(),
1952 oldest = %gap_time(oldest),
1953 "BinanceSpot recovering fills missed while disconnected"
1954 );
1955
1956 let mut settled = vec![false; due.len()];
1959 let started = AtomicUsize::new(0);
1960 let recovery = async {
1961 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
1962 let mut recovered = 0u32;
1963 let mut duplicates = 0u32;
1964
1965 let mut stream =
1971 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
1972 started.store(index + 1, Ordering::Relaxed);
1973 let rest = rest.clone();
1974 let rl = rate_limiter.clone();
1975 async move {
1976 let span = MyTradesFrom::Span {
1977 start: gap.start_ms,
1978 end: gap.end_ms,
1979 };
1980 let raw =
1981 match paginate_my_trades(&rest, &rl, &inst, span, RequestKind::Essential)
1982 .await
1983 {
1984 Ok(pages) => pages,
1985 Err(e) => return (index, Err(e)),
1986 };
1987 let totals = recovered_order_totals(
1991 ExchangeId::BinanceSpot,
1992 &inst,
1993 &raw,
1994 order_executions_deadline,
1995 |order_id| {
1996 paginate_my_trades(
1997 &rest,
1998 &rl,
1999 &inst,
2000 MyTradesFrom::Order(order_id),
2001 RequestKind::Essential,
2002 )
2003 },
2004 )
2005 .await;
2006 let trades: Vec<_> = raw
2007 .iter()
2008 .filter_map(|t| {
2009 let mut trade = convert_my_trade(t, &inst)?;
2010 trade.order_filled_quantity =
2011 t.id.and_then(|id| totals.get(&id).copied());
2012 Some(trade)
2013 })
2014 .collect();
2015 (index, Ok(trades))
2016 }
2017 }))
2018 .buffer_unordered(8);
2019 while let Some((index, result)) = stream.next().await {
2020 let (inst, gap) = &due[index];
2021 settled[index] = true;
2022 let trades = match result {
2023 Ok(trades) => trades,
2024 Err(e) => {
2025 gap_failed(
2026 unrecovered,
2027 tx,
2028 ExchangeId::BinanceSpot,
2029 inst,
2030 gap,
2031 FillRecoveryFailure::Request(e.to_string()),
2032 );
2033 continue;
2034 }
2035 };
2036 for trade in trades {
2037 let event = UnindexedAccountEvent::new(
2040 ExchangeId::BinanceSpot,
2041 AccountEventKind::Trade(trade),
2042 );
2043 if let Some(key) = dedup_key_from_event(&event)
2046 && is_duplicate(dedup, key)
2047 {
2048 duplicates += 1;
2049 continue;
2050 }
2051 let sent = {
2054 let mut known = known.lock();
2055 known.observe(&event);
2056 tx.send(event)
2057 };
2058 if sent.is_err() {
2059 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
2062 return;
2063 }
2064 recovered += 1;
2065 }
2066 unrecovered.recovered(inst, gap);
2067 }
2068 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
2069 };
2070 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2074 .await
2075 .is_err()
2076 {
2077 let started = started.load(Ordering::Relaxed);
2078 for ((inst, gap), _) in due[..started]
2079 .iter()
2080 .zip(&settled)
2081 .filter(|(_, settled)| !**settled)
2082 {
2083 gap_failed(
2084 unrecovered,
2085 tx,
2086 ExchangeId::BinanceSpot,
2087 inst,
2088 gap,
2089 FillRecoveryFailure::TimedOut {
2090 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2091 },
2092 );
2093 }
2094 }
2095}
2096
2097fn convert_balance_entry(
2111 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
2112 now: chrono::DateTime<Utc>,
2113) -> Option<AssetBalance<AssetNameExchange>> {
2114 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2115 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2116 Some(v) => v,
2117 None => {
2118 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
2119 return None;
2120 }
2121 };
2122 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2123 Some(v) => v,
2124 None => {
2125 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
2126 return None;
2127 }
2128 };
2129 Some(AssetBalance::new(
2130 asset_name,
2131 Balance::new(free + locked, free),
2132 now,
2133 ))
2134}
2135
2136fn filter_and_convert_balances(
2137 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
2138 assets: &[AssetNameExchange],
2139) -> Vec<AssetBalance<AssetNameExchange>> {
2140 let now = Utc::now();
2141 if assets.is_empty() {
2143 return balances
2144 .into_iter()
2145 .filter_map(|b| convert_balance_entry(b, now))
2146 .collect();
2147 }
2148 if assets.len() <= 16 {
2151 return balances
2152 .into_iter()
2153 .filter_map(|b| {
2154 let asset_name_str = b.asset.as_deref()?;
2155 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
2156 return None;
2157 }
2158 convert_balance_entry(b, now)
2159 })
2160 .collect();
2161 }
2162 use std::collections::HashSet;
2163 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
2164 balances
2165 .into_iter()
2166 .filter_map(|b| {
2167 let asset_name_str = b.asset.as_deref()?;
2168 if !asset_set.contains(asset_name_str) {
2169 return None;
2170 }
2171 convert_balance_entry(b, now)
2172 })
2173 .collect()
2174}
2175
2176fn convert_my_trade(
2178 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
2179 instrument: &InstrumentNameExchange,
2180) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
2181 let trade_id_raw = match t.id {
2182 Some(id) => id,
2183 None => {
2184 warn!(%instrument, "BinanceSpot trade missing id");
2185 return None;
2186 }
2187 };
2188 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
2189 let order_id = match t.order_id {
2190 Some(id) => OrderId(format_smolstr!("{}", id)),
2191 None => {
2192 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
2193 return None;
2194 }
2195 };
2196 let side = match t.is_buyer {
2197 Some(is_buyer) => {
2198 if is_buyer {
2199 Side::Buy
2200 } else {
2201 Side::Sell
2202 }
2203 }
2204 None => {
2205 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
2206 return None;
2207 }
2208 };
2209 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2210 Some(v) => v,
2211 None => {
2212 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
2213 return None;
2214 }
2215 };
2216 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2217 Some(v) => v,
2218 None => {
2219 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2220 return None;
2221 }
2222 };
2223 let commission = t
2224 .commission
2225 .as_deref()
2226 .and_then(|s| Decimal::from_str(s).ok())
2227 .unwrap_or(Decimal::ZERO);
2228 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
2229 Some(ts) => ts,
2230 None => {
2231 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2232 Utc::now()
2233 }
2234 };
2235
2236 let fee_asset = t
2241 .commission_asset
2242 .as_deref()
2243 .map(AssetNameExchange::from)
2244 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2245
2246 Some(Trade::new(
2247 trade_id,
2248 order_id,
2249 instrument.clone(),
2250 StrategyId::unknown(), time_exchange,
2252 side,
2253 price,
2254 quantity,
2255 None,
2257 AssetFees::new(fee_asset, commission, None),
2258 ))
2259}
2260
2261fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2286 let (event_type, event) = match parse_user_data_frame(frame) {
2287 UserDataFrame::Response => return false,
2288 UserDataFrame::Event {
2289 event_type, event, ..
2290 } => (event_type, event),
2291 UserDataFrame::Unrecognised => {
2292 static SEEN: AtomicU64 = AtomicU64::new(0);
2293 log_unrecognised_frame("BinanceSpot", &SEEN, frame);
2294 return false;
2295 }
2296 };
2297 match event_type {
2299 "executionReport" => {
2300 match serde_json::from_str::<binance_sdk::spot::websocket_api::ExecutionReport>(event) {
2303 Ok(report) => {
2304 convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
2305 }
2306 Err(e) => {
2307 warn!(error = %e, "BinanceSpot: undeserializable executionReport, dropping")
2308 }
2309 }
2310 false
2311 }
2312 "outboundAccountPosition" => {
2313 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2314 event,
2315 ) {
2316 Ok(position) => convert_account_position(position, buf),
2317 Err(e) => {
2318 warn!(error = %e, "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2319 }
2320 }
2321 false
2322 }
2323 "balanceUpdate" => {
2324 false
2331 }
2332 "eventStreamTerminated" => {
2333 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2337 true
2338 }
2339 _ => {
2342 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2343 false
2344 }
2345 }
2346}
2347
2348fn convert_account_position(
2353 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2354 buf: &mut Vec<UnindexedAccountEvent>,
2355) {
2356 let time_exchange = position
2359 .u
2360 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2361 .unwrap_or_else(Utc::now);
2362
2363 for b in position.b_uppercase.unwrap_or_default() {
2364 let asset = match b.a {
2365 Some(a) => AssetNameExchange::new(a),
2366 None => {
2367 warn!("BinanceSpot account position entry missing asset name");
2368 continue;
2369 }
2370 };
2371 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2372 Some(v) => v,
2373 None => {
2374 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2375 continue;
2376 }
2377 };
2378 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2379 Some(v) => v,
2380 None => {
2381 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2382 continue;
2383 }
2384 };
2385 let update =
2390 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2391 buf.push(UnindexedAccountEvent::new(
2392 ExchangeId::BinanceSpot,
2393 AccountEventKind::BalanceStreamUpdate(
2394 rustrade_integration::collection::snapshot::Snapshot::new(update),
2395 ),
2396 ));
2397 }
2398}
2399
2400fn convert_order_kind_tif(
2427 kind: OrderKind,
2428 tif: TimeInForce,
2429) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2430 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2434 let type_enum = match binance_type {
2435 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2436 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2437 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2438 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2439 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2440 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2441 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2442 };
2443 let tif_enum = binance_tif.map(|t| match t {
2444 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2445 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2446 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2447 });
2448 Some((type_enum, tif_enum))
2449}
2450
2451#[cfg(test)]
2452#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2454 use super::*;
2455 use crate::client::binance::shared::*;
2456 use crate::client::dedup::{DedupEventKind, DedupKey};
2457 use crate::order::{TrailingOffsetType, id::ClientOrderId};
2458 use binance_sdk::common::errors::WebsocketError;
2459 use smol_str::SmolStr;
2460
2461 #[test]
2462 fn test_binance_spot_config_new_uses_testnet_by_default() {
2463 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2464 assert!(cfg.testnet);
2465 assert_eq!(cfg.api_key(), "my_key");
2466 }
2467
2468 #[test]
2469 fn test_binance_spot_config_production_is_explicit() {
2470 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2471 assert!(!cfg.testnet);
2472 }
2473
2474 #[test]
2475 fn test_binance_spot_config_debug_redacts_credentials() {
2476 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2477 let debug = format!("{cfg:?}");
2478
2479 assert!(!debug.contains("my_key"), "api_key should be redacted");
2480 assert!(
2481 !debug.contains("my_secret"),
2482 "secret_key should be redacted"
2483 );
2484 assert!(debug.contains("testnet: true"));
2485 }
2486
2487 #[test]
2488 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2489 let cfg: BinanceSpotConfig = serde_json::from_str(
2490 r#"{
2491 "api_key": "my_key",
2492 "secret_key": "my_secret"
2493 }"#,
2494 )
2495 .unwrap();
2496
2497 assert!(cfg.testnet);
2498 assert_eq!(cfg.api_key(), "my_key");
2499 }
2500
2501 #[test]
2502 fn test_binance_spot_config_deserialize_testnet_true() {
2503 let cfg: BinanceSpotConfig = serde_json::from_str(
2504 r#"{
2505 "api_key": "my_key",
2506 "secret_key": "my_secret",
2507 "testnet": true
2508 }"#,
2509 )
2510 .unwrap();
2511
2512 assert!(cfg.testnet);
2513 }
2514
2515 #[test]
2516 fn test_binance_spot_config_deserialize_testnet_false() {
2517 let cfg: BinanceSpotConfig = serde_json::from_str(
2518 r#"{
2519 "api_key": "my_key",
2520 "secret_key": "my_secret",
2521 "testnet": false
2522 }"#,
2523 )
2524 .unwrap();
2525
2526 assert!(!cfg.testnet);
2527 }
2528
2529 #[test]
2530 #[serial_test::serial]
2531 fn test_binance_spot_config_from_env_defaults_to_testnet() {
2532 temp_env::with_vars(
2533 [
2534 ("BINANCE_API_KEY", Some("my_key")),
2535 ("BINANCE_SECRET_KEY", Some("my_secret")),
2536 ("BINANCE_TESTNET", None),
2537 ],
2538 || {
2539 let cfg = BinanceSpotConfig::from_env().unwrap();
2540 assert!(cfg.testnet);
2541 assert_eq!(cfg.api_key(), "my_key");
2542 },
2543 );
2544 }
2545
2546 #[test]
2547 #[serial_test::serial]
2548 fn test_binance_spot_config_from_env_accepts_explicit_production() {
2549 temp_env::with_vars(
2550 [
2551 ("BINANCE_API_KEY", Some("my_key")),
2552 ("BINANCE_SECRET_KEY", Some("my_secret")),
2553 ("BINANCE_TESTNET", Some("false")),
2554 ],
2555 || {
2556 let cfg = BinanceSpotConfig::from_env().unwrap();
2557 assert!(!cfg.testnet);
2558 },
2559 );
2560 }
2561
2562 #[test]
2563 #[serial_test::serial]
2564 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2565 temp_env::with_vars(
2566 [
2567 ("BINANCE_API_KEY", Some("my_key")),
2568 ("BINANCE_SECRET_KEY", Some("my_secret")),
2569 ("BINANCE_TESTNET", Some("maybe")),
2570 ],
2571 || {
2572 let err = BinanceSpotConfig::from_env().unwrap_err();
2573 assert!(
2574 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2575 );
2576 },
2577 );
2578 }
2579
2580 #[test]
2581 #[serial_test::serial]
2582 fn test_binance_spot_config_from_env_requires_credentials() {
2583 temp_env::with_vars(
2584 [
2585 ("BINANCE_API_KEY", None),
2586 ("BINANCE_SECRET_KEY", Some("my_secret")),
2587 ("BINANCE_TESTNET", None),
2588 ],
2589 || {
2590 let err = BinanceSpotConfig::from_env().unwrap_err();
2591 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2592 },
2593 );
2594 }
2595
2596 #[test]
2597 fn test_parse_side() {
2598 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2599 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2600 assert_eq!(parse_side("buy"), None);
2601 assert_eq!(parse_side("UNKNOWN"), None);
2602 }
2603
2604 #[test]
2605 fn test_parse_order_kind() {
2606 assert_eq!(parse_order_kind("MARKET"), Some(OrderKind::Market));
2607 assert_eq!(parse_order_kind("LIMIT"), Some(OrderKind::Limit));
2608 assert_eq!(parse_order_kind("LIMIT_MAKER"), Some(OrderKind::Limit));
2609 assert_eq!(parse_order_kind("STOP_LOSS"), None);
2610 assert_eq!(parse_order_kind("TAKE_PROFIT"), None);
2611 assert_eq!(parse_order_kind("STOP_LOSS_LIMIT"), Some(OrderKind::Limit));
2612 assert_eq!(
2613 parse_order_kind("TAKE_PROFIT_LIMIT"),
2614 Some(OrderKind::Limit)
2615 );
2616 assert_eq!(parse_order_kind("UNKNOWN_TYPE"), None);
2617 }
2618
2619 #[test]
2620 fn test_parse_time_in_force() {
2621 assert_eq!(
2622 parse_time_in_force("GTC"),
2623 TimeInForce::GoodUntilCancelled { post_only: false }
2624 );
2625 assert_eq!(
2626 parse_time_in_force("GTX"),
2627 TimeInForce::GoodUntilCancelled { post_only: true }
2628 );
2629 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2630 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2631 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2632 assert_eq!(
2634 parse_time_in_force("UNKNOWN"),
2635 TimeInForce::GoodUntilCancelled { post_only: false }
2636 );
2637 }
2638
2639 #[test]
2640 fn test_convert_order_kind_tif() {
2641 use rust_decimal::Decimal;
2642
2643 assert!(matches!(
2645 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2646 Some((OrderPlaceTypeEnum::Market, None))
2647 ));
2648 assert!(matches!(
2649 convert_order_kind_tif(
2650 OrderKind::Limit,
2651 TimeInForce::GoodUntilCancelled { post_only: false }
2652 ),
2653 Some((
2654 OrderPlaceTypeEnum::Limit,
2655 Some(OrderPlaceTimeInForceEnum::Gtc)
2656 ))
2657 ));
2658 assert!(matches!(
2659 convert_order_kind_tif(
2660 OrderKind::Limit,
2661 TimeInForce::GoodUntilCancelled { post_only: true }
2662 ),
2663 Some((OrderPlaceTypeEnum::LimitMaker, None))
2664 ));
2665 assert!(matches!(
2666 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2667 Some((
2668 OrderPlaceTypeEnum::Limit,
2669 Some(OrderPlaceTimeInForceEnum::Fok)
2670 ))
2671 ));
2672 assert!(matches!(
2673 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2674 Some((
2675 OrderPlaceTypeEnum::Limit,
2676 Some(OrderPlaceTimeInForceEnum::Ioc)
2677 ))
2678 ));
2679 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2682
2683 assert!(matches!(
2685 convert_order_kind_tif(
2686 OrderKind::Stop {
2687 trigger_price: Decimal::from(100)
2688 },
2689 TimeInForce::GoodUntilCancelled { post_only: false }
2690 ),
2691 Some((OrderPlaceTypeEnum::StopLoss, None))
2692 ));
2693 assert!(matches!(
2694 convert_order_kind_tif(
2695 OrderKind::StopLimit {
2696 trigger_price: Decimal::from(100)
2697 },
2698 TimeInForce::GoodUntilCancelled { post_only: false }
2699 ),
2700 Some((
2701 OrderPlaceTypeEnum::StopLossLimit,
2702 Some(OrderPlaceTimeInForceEnum::Gtc)
2703 ))
2704 ));
2705 assert!(matches!(
2706 convert_order_kind_tif(
2707 OrderKind::TakeProfit {
2708 trigger_price: Decimal::from(150)
2709 },
2710 TimeInForce::ImmediateOrCancel
2711 ),
2712 Some((OrderPlaceTypeEnum::TakeProfit, None))
2713 ));
2714 assert!(matches!(
2715 convert_order_kind_tif(
2716 OrderKind::TakeProfitLimit {
2717 trigger_price: Decimal::from(150)
2718 },
2719 TimeInForce::FillOrKill
2720 ),
2721 Some((
2722 OrderPlaceTypeEnum::TakeProfitLimit,
2723 Some(OrderPlaceTimeInForceEnum::Fok)
2724 ))
2725 ));
2726
2727 assert!(matches!(
2729 convert_order_kind_tif(
2730 OrderKind::TrailingStop {
2731 offset: Decimal::from(100),
2732 offset_type: TrailingOffsetType::BasisPoints,
2733 },
2734 TimeInForce::GoodUntilCancelled { post_only: false }
2735 ),
2736 Some((OrderPlaceTypeEnum::StopLoss, None))
2737 ));
2738 assert!(matches!(
2739 convert_order_kind_tif(
2740 OrderKind::TrailingStop {
2741 offset: Decimal::from(5),
2742 offset_type: TrailingOffsetType::Percentage,
2743 },
2744 TimeInForce::GoodUntilCancelled { post_only: false }
2745 ),
2746 Some((OrderPlaceTypeEnum::StopLoss, None))
2747 ));
2748
2749 assert!(
2751 convert_order_kind_tif(
2752 OrderKind::TrailingStop {
2753 offset: Decimal::from(10),
2754 offset_type: TrailingOffsetType::Absolute,
2755 },
2756 TimeInForce::GoodUntilCancelled { post_only: false }
2757 )
2758 .is_none()
2759 );
2760
2761 assert!(
2763 convert_order_kind_tif(
2764 OrderKind::TrailingStopLimit {
2765 offset: Decimal::from(100),
2766 offset_type: TrailingOffsetType::BasisPoints,
2767 stop_price: Decimal::from(95),
2768 limit_offset: Decimal::from(10),
2769 },
2770 TimeInForce::GoodUntilCancelled { post_only: false }
2771 )
2772 .is_none()
2773 );
2774 }
2775
2776 #[test]
2777 fn test_parse_binance_api_error() {
2778 let instrument = InstrumentNameExchange::new("BTCUSDT");
2779
2780 assert!(matches!(
2781 parse_binance_api_error("Insufficient balance".into(), &instrument),
2782 ApiError::BalanceInsufficient(None, _)
2783 ));
2784 assert!(matches!(
2785 parse_binance_api_error("Not enough funds".into(), &instrument),
2786 ApiError::BalanceInsufficient(None, _)
2787 ));
2788 assert_eq!(
2789 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2790 ApiError::RateLimit
2791 );
2792 assert_eq!(
2793 parse_binance_api_error("Error code -1015".into(), &instrument),
2794 ApiError::RateLimit
2795 );
2796 assert_eq!(
2798 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2799 ApiError::RateLimit
2800 );
2801 assert_eq!(
2803 parse_binance_api_error("Error code -2011".into(), &instrument),
2804 ApiError::OrderAlreadyCancelled
2805 );
2806 assert!(matches!(
2808 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2809 ApiError::OrderAlreadyCancelled
2810 ));
2811 assert!(matches!(
2813 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2814 ApiError::OrderAlreadyCancelled
2815 ));
2816 assert!(matches!(
2818 parse_binance_api_error("Unknown order encountered".into(), &instrument),
2819 ApiError::OrderRejected(_)
2820 ));
2821 assert!(matches!(
2822 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2823 ApiError::InstrumentInvalid(_, _)
2824 ));
2825 assert!(matches!(
2827 parse_binance_api_error(
2828 "Server-side response error (code -2010): Account has insufficient balance".into(),
2829 &instrument
2830 ),
2831 ApiError::BalanceInsufficient(None, _)
2832 ));
2833 assert!(matches!(
2834 parse_binance_api_error(
2835 "Server-side response error (code -2010): Order would trigger immediately.".into(),
2836 &instrument
2837 ),
2838 ApiError::OrderRejected(_)
2839 ));
2840 assert!(matches!(
2841 parse_binance_api_error("Some other error".into(), &instrument),
2842 ApiError::OrderRejected(_)
2843 ));
2844 }
2845
2846 #[test]
2847 fn test_contains_error_code_suffix_guard() {
2848 assert!(
2850 !contains_error_code("-20130", "-2013"),
2851 "-20130 should not match -2013"
2852 );
2853 assert!(
2854 !contains_error_code("-20131", "-2013"),
2855 "-20131 should not match -2013"
2856 );
2857 assert!(contains_error_code("-2013", "-2013"), "exact match");
2859 assert!(
2860 contains_error_code("Error -2013: text", "-2013"),
2861 "match with trailing text"
2862 );
2863 }
2864
2865 #[test]
2866 fn test_contains_error_code_prefix_guard() {
2867 assert!(
2870 !contains_error_code("1-2013", "-2013"),
2871 "1-2013 should not match -2013"
2872 );
2873 assert!(
2874 !contains_error_code("error 1-2013 text", "-2013"),
2875 "embedded 1-2013 should not match"
2876 );
2877 assert!(
2879 contains_error_code("code=-2013,", "-2013"),
2880 "=-2013 prefix should match"
2881 );
2882 assert!(
2883 contains_error_code(" -2013 ", "-2013"),
2884 "space prefix should match"
2885 );
2886 }
2887
2888 #[test]
2889 fn test_contains_error_code_second_occurrence_valid() {
2890 assert!(
2893 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
2894 "second valid occurrence must match when first fails digit guard"
2895 );
2896 assert!(
2898 contains_error_code("-2013 or -20130", "-2013"),
2899 "first valid occurrence must match when second is a longer code"
2900 );
2901 }
2902
2903 #[test]
2904 fn test_dedup_cache() {
2905 let cache = new_dedup_cache();
2906 let key = DedupKey {
2907 instrument: SmolStr::from("BTCUSDT"),
2908 id: SmolStr::from("12345"),
2909 kind: DedupEventKind::Trade,
2910 };
2911
2912 assert!(!is_duplicate(&cache, key));
2914 let key = DedupKey {
2916 instrument: SmolStr::from("BTCUSDT"),
2917 id: SmolStr::from("12345"),
2918 kind: DedupEventKind::Trade,
2919 };
2920 assert!(is_duplicate(&cache, key));
2921
2922 let key2 = DedupKey {
2924 instrument: SmolStr::from("BTCUSDT"),
2925 id: SmolStr::from("12345"),
2926 kind: DedupEventKind::OrderState {
2927 filled_quantity: Decimal::ZERO,
2928 },
2929 };
2930 assert!(!is_duplicate(&cache, key2));
2931 }
2932
2933 #[test]
2937 fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
2938 let cache = new_dedup_cache();
2939 let state = |filled: &str| DedupKey {
2940 instrument: SmolStr::from("BTCUSDT"),
2941 id: SmolStr::from("12345"),
2942 kind: DedupEventKind::OrderState {
2943 filled_quantity: Decimal::from_str(filled).unwrap(),
2944 },
2945 };
2946
2947 assert!(!is_duplicate(&cache, state("0")), "ack is new");
2949 assert!(
2950 !is_duplicate(&cache, state("1")),
2951 "first fill is not the ack"
2952 );
2953 assert!(
2954 !is_duplicate(&cache, state("2")),
2955 "second fill is not the first"
2956 );
2957
2958 assert!(
2961 is_duplicate(&cache, state("0")),
2962 "replayed ack is a duplicate"
2963 );
2964 assert!(
2965 is_duplicate(&cache, state("2")),
2966 "replayed fill is a duplicate"
2967 );
2968
2969 assert!(
2971 is_duplicate(&cache, state("2.00")),
2972 "2.00 must be the same key as 2"
2973 );
2974 }
2975
2976 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
2983 use crate::client::dedup::dedup_key_from_event;
2984
2985 let cache = new_dedup_cache();
2986 let mut delivered = Vec::new();
2987 let mut buf = Vec::new();
2988 for frame in frames {
2989 let _ = convert_user_data_events(frame, &mut buf);
2990 for ev in buf.drain(..) {
2991 if let Some(key) = dedup_key_from_event(&ev)
2992 && is_duplicate(&cache, key)
2993 {
2994 continue;
2995 }
2996 delivered.push(ev);
2997 }
2998 }
2999 delivered
3000 }
3001
3002 const WS_NEW: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport","s":"BTCUSDT",
3004 "i":12345,"c":"client-1","x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2",
3005 "p":"100","z":"0","T":1700000000000}}"#;
3006
3007 const WS_PARTIAL_FILL: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport",
3008 "s":"BTCUSDT","i":12345,"c":"client-1","x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY",
3009 "o":"LIMIT","f":"GTC","q":"2","p":"100","z":"1","l":"1","L":"100","t":555,"n":"0.1",
3010 "N":"USDT","T":1700000001000}}"#;
3011
3012 #[test]
3020 fn ws_fill_snapshot_survives_the_dedup_gate() {
3021 use crate::order::state::ActiveOrderState;
3022
3023 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
3024
3025 assert_eq!(
3026 delivered.len(),
3027 3,
3028 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
3029 );
3030
3031 let snapshots: Vec<Decimal> = delivered
3032 .iter()
3033 .filter_map(|ev| match &ev.kind {
3034 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
3035 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
3036 _ => None,
3037 },
3038 _ => None,
3039 })
3040 .collect();
3041
3042 assert_eq!(
3043 snapshots,
3044 vec![Decimal::ZERO, Decimal::ONE],
3045 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
3046 );
3047 }
3048
3049 #[test]
3052 fn ws_replayed_frame_is_still_deduplicated() {
3053 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
3054
3055 assert_eq!(
3056 delivered.len(),
3057 3,
3058 "re-delivering both frames must add nothing, got: {delivered:?}"
3059 );
3060 }
3061
3062 #[test]
3063 fn test_is_rate_limit_error() {
3064 assert!(is_rate_limit_error(&anyhow::anyhow!(
3066 "Too many requests. You are being rate-limited. Please slow down."
3067 )));
3068 assert!(is_rate_limit_error(&anyhow::anyhow!(
3070 "The IP address has been banned for exceeding rate limits. Contact support."
3071 )));
3072 assert!(is_rate_limit_error(&anyhow::anyhow!(
3074 "Error -1015: too many new orders"
3075 )));
3076 assert!(is_rate_limit_error(&anyhow::anyhow!(
3077 "Error -1003: too many requests"
3078 )));
3079 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
3081 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
3082 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
3083 assert!(
3085 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
3086 "-10150 should not match -1015"
3087 );
3088 assert!(
3089 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
3090 "-10030 should not match -1003"
3091 );
3092 }
3093
3094 #[test]
3095 fn test_classify_ws_order_error_recognises_venue_responses_only() {
3096 let instrument = InstrumentNameExchange::new("BTCUSDT");
3097
3098 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
3100 code: -2010,
3101 message: "Account has insufficient balance for requested action.".into(),
3102 });
3103 assert!(
3104 matches!(
3105 classify_ws_order_error(&rejection, &instrument),
3106 Some(OrderError::Rejected(ApiError::BalanceInsufficient(None, _)))
3107 ),
3108 "ResponseError should be classified as a venue response"
3109 );
3110
3111 let transport = anyhow::anyhow!("connection reset by peer");
3113 assert!(
3114 classify_ws_order_error(&transport, &instrument).is_none(),
3115 "plain transport error should not be classified as a venue response"
3116 );
3117
3118 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
3120 assert!(
3121 classify_ws_order_error(&rate_limit, &instrument).is_none(),
3122 "rate-limit string error should not be classified as a venue response"
3123 );
3124 }
3125
3126 #[test]
3127 fn test_dedup_key_from_event_trade_includes_instrument() {
3128 use crate::order::id::{OrderId, StrategyId};
3129 use crate::trade::{AssetFees, Trade, TradeId};
3130 use chrono::Utc;
3131 use rust_decimal::Decimal;
3132 use rustrade_instrument::Side;
3133
3134 let instrument = InstrumentNameExchange::new("BTCUSDT");
3135 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3136 TradeId::new("9001"),
3137 OrderId::new("4242"),
3138 instrument.clone(),
3139 StrategyId::unknown(),
3140 Utc::now(),
3141 Side::Buy,
3142 Decimal::ZERO,
3143 Decimal::ZERO,
3144 None,
3145 AssetFees::new(
3146 AssetNameExchange::from("USDT"),
3147 Decimal::ZERO,
3148 Some(Decimal::ZERO),
3149 ),
3150 );
3151 let event =
3152 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
3153
3154 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
3155 assert_eq!(key.kind, DedupEventKind::Trade);
3156 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
3157 assert_eq!(
3158 key.instrument.as_str(),
3159 "BTCUSDT",
3160 "key.instrument should be the symbol"
3161 );
3162
3163 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
3165 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3166 TradeId::new("9001"),
3167 OrderId::new("7777"),
3168 instrument2,
3169 StrategyId::unknown(),
3170 Utc::now(),
3171 Side::Buy,
3172 Decimal::ZERO,
3173 Decimal::ZERO,
3174 None,
3175 AssetFees::new(
3176 AssetNameExchange::from("USDT"),
3177 Decimal::ZERO,
3178 Some(Decimal::ZERO),
3179 ),
3180 );
3181 let event2 =
3182 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
3183 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
3184 assert_ne!(
3185 key, key2,
3186 "same trade ID on different symbols must produce distinct keys"
3187 );
3188 }
3189
3190 fn make_balance_inner(
3195 asset: &str,
3196 free: &str,
3197 locked: &str,
3198 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3199 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3200 a: Some(asset.to_string()),
3201 f: Some(free.to_string()),
3202 l: Some(locked.to_string()),
3203 }
3204 }
3205
3206 #[test]
3207 fn test_convert_account_position_happy_path() {
3208 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3209 u: Some(1_700_000_000_000),
3210 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
3211 ..Default::default()
3212 };
3213 let mut buf = Vec::new();
3214 convert_account_position(position, &mut buf);
3215
3216 assert_eq!(buf.len(), 1);
3217 match &buf[0].kind {
3218 AccountEventKind::BalanceStreamUpdate(snap) => {
3219 let update = &snap.0;
3220 assert_eq!(update.asset.as_ref(), "BTC");
3221 let expected_free = Decimal::from_str("1.5").unwrap();
3223 let expected_locked = Decimal::from_str("0.5").unwrap();
3224 assert_eq!(update.update.free, expected_free);
3225 assert_eq!(update.update.locked, expected_locked);
3226 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3227 }
3228 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3229 }
3230 }
3231
3232 #[test]
3233 fn test_convert_account_position_u_field_none_uses_now() {
3234 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3236 u: None,
3237 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3238 ..Default::default()
3239 };
3240 let mut buf = Vec::new();
3241 convert_account_position(position, &mut buf);
3242 assert_eq!(buf.len(), 1);
3243 }
3244
3245 #[test]
3246 fn test_convert_account_position_missing_asset_name_skipped() {
3247 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3248 u: Some(1_700_000_000_000),
3249 b_uppercase: Some(vec![
3250 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3251 a: None, f: Some("1.0".to_string()),
3253 l: Some("0.0".to_string()),
3254 },
3255 make_balance_inner("USDT", "100.0", "0.0"), ]),
3257 ..Default::default()
3258 };
3259 let mut buf = Vec::new();
3260 convert_account_position(position, &mut buf);
3261 assert_eq!(buf.len(), 1);
3263 match &buf[0].kind {
3264 AccountEventKind::BalanceStreamUpdate(snap) => {
3265 assert_eq!(snap.0.asset.as_ref(), "USDT");
3266 }
3267 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3268 }
3269 }
3270
3271 #[test]
3272 fn test_convert_account_position_unparseable_free_skipped() {
3273 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3274 u: Some(1_700_000_000_000),
3275 b_uppercase: Some(vec![
3276 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3277 a: Some("BTC".to_string()),
3278 f: Some("not-a-number".to_string()),
3279 l: Some("0.0".to_string()),
3280 },
3281 make_balance_inner("ETH", "1.0", "0.0"), ]),
3283 ..Default::default()
3284 };
3285 let mut buf = Vec::new();
3286 convert_account_position(position, &mut buf);
3287 assert_eq!(buf.len(), 1);
3289 match &buf[0].kind {
3290 AccountEventKind::BalanceStreamUpdate(snap) => {
3291 assert_eq!(snap.0.asset.as_ref(), "ETH");
3292 }
3293 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3294 }
3295 }
3296
3297 #[test]
3298 fn test_convert_account_position_empty_balances() {
3299 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3300 u: Some(1_700_000_000_000),
3301 b_uppercase: Some(vec![]),
3302 ..Default::default()
3303 };
3304 let mut buf = Vec::new();
3305 convert_account_position(position, &mut buf);
3306 assert!(
3307 buf.is_empty(),
3308 "empty balance list should produce no events"
3309 );
3310 }
3311
3312 #[test]
3313 fn test_convert_account_position_b_field_none() {
3314 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3315 u: Some(1_700_000_000_000),
3316 b_uppercase: None, ..Default::default()
3318 };
3319 let mut buf = Vec::new();
3320 convert_account_position(position, &mut buf);
3321 assert!(buf.is_empty(), "None B field should produce no events");
3322 }
3323
3324 #[tokio::test]
3326 async fn test_exponential_backoff_exhaustion() {
3327 tokio::time::pause(); let mut backoff = ExponentialBackoff::new();
3329
3330 for i in 0..MAX_RECONNECT_ATTEMPTS {
3332 assert!(
3333 backoff.wait().await,
3334 "expected true on attempt {i} (before exhaustion)"
3335 );
3336 }
3337
3338 assert!(!backoff.wait().await, "expected false after max attempts");
3340 }
3341
3342 #[tokio::test]
3343 async fn test_exponential_backoff_reset() {
3344 tokio::time::pause();
3345 let mut backoff = ExponentialBackoff::new();
3346
3347 for _ in 0..MAX_RECONNECT_ATTEMPTS {
3348 backoff.wait().await;
3349 }
3350 assert!(!backoff.wait().await, "should be exhausted");
3351
3352 backoff.reset();
3353 assert!(backoff.wait().await, "should succeed again after reset");
3354 }
3355
3356 fn make_base_report() -> binance_sdk::spot::websocket_api::ExecutionReport {
3360 binance_sdk::spot::websocket_api::ExecutionReport {
3361 s: Some("BTCUSDT".to_string()),
3362 i: Some(12345),
3363 c: Some("client-1".to_string()),
3364 t_uppercase: Some(1_700_000_000_000),
3365 ..Default::default()
3366 }
3367 }
3368
3369 fn convert(
3371 report: binance_sdk::spot::websocket_api::ExecutionReport,
3372 ) -> Vec<UnindexedAccountEvent> {
3373 let mut buf = Vec::new();
3374 convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
3375 buf
3376 }
3377
3378 fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
3383 assert!(
3384 events.len() <= 1,
3385 "expected at most a single event, got: {events:?}"
3386 );
3387 events.pop()
3388 }
3389
3390 #[test]
3391 fn test_convert_execution_report_new() {
3392 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3393 x: Some("NEW".to_string()),
3394 s_uppercase: Some("BUY".to_string()),
3395 o: Some("LIMIT".to_string()),
3396 p: Some("50000.00".to_string()),
3397 q: Some("0.01".to_string()),
3398 f: Some("GTC".to_string()),
3399 z: Some("0".to_string()),
3400 ..make_base_report()
3401 };
3402
3403 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3404 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3405 match &event.kind {
3406 AccountEventKind::OrderSnapshot(snap) => {
3407 let order = &snap.0;
3408 assert_eq!(order.side, Side::Buy);
3409 assert_eq!(order.kind, OrderKind::Limit);
3410 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3411 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3412 assert_eq!(order.key.cid.0.as_str(), "client-1");
3413 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3414 }
3415 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3416 }
3417 }
3418
3419 #[test]
3420 fn test_convert_execution_report_trade() {
3421 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3424 x: Some("TRADE".to_string()),
3425 s_uppercase: Some("BUY".to_string()),
3426 t: Some(9999),
3427 l_uppercase: Some("50000.00".to_string()),
3428 l: Some("0.01".to_string()),
3429 n: Some("0.000001".to_string()),
3430 ..make_base_report()
3431 };
3432
3433 let event = sole_event(convert(report)).expect("TRADE event should produce Some");
3434 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3435 match &event.kind {
3436 AccountEventKind::Trade(trade) => {
3437 assert_eq!(trade.side, Side::Buy);
3438 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3439 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3440 assert_eq!(trade.id.0.as_str(), "9999");
3441 assert_eq!(trade.order_id.0.as_str(), "12345");
3442 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3443 }
3444 other => panic!("TRADE should yield Trade, got {other:?}"),
3445 }
3446 }
3447
3448 fn trade_events(
3450 events: Vec<UnindexedAccountEvent>,
3451 ) -> (
3452 Trade<AssetNameExchange, InstrumentNameExchange>,
3453 crate::order::Order<
3454 ExchangeId,
3455 InstrumentNameExchange,
3456 OrderState<AssetNameExchange, InstrumentNameExchange>,
3457 >,
3458 ) {
3459 let [first, second]: [UnindexedAccountEvent; 2] = events
3460 .try_into()
3461 .expect("a fill report must produce an execution and an order snapshot");
3462 let AccountEventKind::Trade(trade) = first.kind else {
3463 panic!("first event must be the Trade, got {:?}", first.kind);
3464 };
3465 let AccountEventKind::OrderSnapshot(snap) = second.kind else {
3466 panic!(
3467 "second event must be the OrderSnapshot, got {:?}",
3468 second.kind
3469 );
3470 };
3471 (trade, snap.0)
3472 }
3473
3474 #[test]
3478 fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
3479 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3480 x: Some("TRADE".to_string()),
3481 x_uppercase: Some("PARTIALLY_FILLED".to_string()),
3482 s_uppercase: Some("BUY".to_string()),
3483 o: Some("LIMIT".to_string()),
3484 p: Some("50000.00".to_string()),
3485 q: Some("0.01".to_string()),
3486 f: Some("GTC".to_string()),
3487 t: Some(9999),
3488 l_uppercase: Some("50000.00".to_string()),
3489 l: Some("0.004".to_string()),
3490 z: Some("0.004".to_string()),
3491 ..make_base_report()
3492 };
3493
3494 let (trade, order) = trade_events(convert(report));
3495 assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
3496
3497 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3498 else {
3499 panic!("expected an Open snapshot, got {:?}", order.state);
3500 };
3501 assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
3502 assert_eq!(
3503 open.quantity_remaining(order.quantity),
3504 Decimal::from_str("0.006").unwrap()
3505 );
3506 }
3507
3508 #[test]
3511 fn a_filled_report_reports_nothing_left_to_fill() {
3512 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3513 x: Some("TRADE".to_string()),
3514 x_uppercase: Some("FILLED".to_string()),
3515 s_uppercase: Some("BUY".to_string()),
3516 o: Some("LIMIT".to_string()),
3517 p: Some("50000.00".to_string()),
3518 q: Some("0.01".to_string()),
3519 f: Some("GTC".to_string()),
3520 t: Some(10_000),
3521 l_uppercase: Some("50000.00".to_string()),
3522 l: Some("0.006".to_string()),
3523 z: Some("0.01".to_string()),
3524 ..make_base_report()
3525 };
3526
3527 let (_trade, order) = trade_events(convert(report));
3528 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3529 else {
3530 panic!("expected an Open snapshot, got {:?}", order.state);
3531 };
3532 assert!(
3533 open.quantity_remaining(order.quantity).is_zero(),
3534 "a completed order must report nothing left to fill so the engine retires it"
3535 );
3536 }
3537
3538 #[test]
3542 fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
3543 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
3544 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3545 x: Some("TRADE".to_string()),
3546 x_uppercase: Some(status.to_string()),
3547 s_uppercase: Some("BUY".to_string()),
3548 o: Some("LIMIT".to_string()),
3549 p: Some("50000.00".to_string()),
3550 q: Some("0.01".to_string()),
3551 f: Some("GTC".to_string()),
3552 t: Some(9999),
3553 l_uppercase: Some("50000.00".to_string()),
3554 l: Some("0.004".to_string()),
3555 z: Some("0.004".to_string()),
3556 ..make_base_report()
3557 };
3558 let events = convert(report);
3559 assert_eq!(
3560 events.len(),
3561 1,
3562 "status {status} must produce the execution alone, got: {events:?}"
3563 );
3564 assert!(
3565 matches!(events[0].kind, AccountEventKind::Trade(_)),
3566 "the execution must still be reported for status {status}"
3567 );
3568 }
3569 }
3570
3571 #[test]
3572 fn test_convert_execution_report_trade_missing_last_price() {
3573 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3575 x: Some("TRADE".to_string()),
3576 s_uppercase: Some("BUY".to_string()),
3577 t: Some(9999),
3578 l_uppercase: None, l: Some("0.01".to_string()),
3580 ..make_base_report()
3581 };
3582 assert!(
3583 sole_event(convert(report)).is_none(),
3584 "missing last price (L) should return None"
3585 );
3586 }
3587
3588 #[test]
3589 fn test_convert_execution_report_trade_missing_last_qty() {
3590 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3592 x: Some("TRADE".to_string()),
3593 s_uppercase: Some("BUY".to_string()),
3594 t: Some(9999),
3595 l_uppercase: Some("50000.00".to_string()),
3596 l: None, ..make_base_report()
3598 };
3599 assert!(
3600 sole_event(convert(report)).is_none(),
3601 "missing last qty (l) should return None"
3602 );
3603 }
3604
3605 #[test]
3606 fn test_convert_execution_report_canceled() {
3607 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3608 x: Some("CANCELED".to_string()),
3609 ..make_base_report()
3610 };
3611
3612 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3613 assert!(
3614 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3615 "CANCELED should yield OrderCancelled with Ok state"
3616 );
3617 }
3618
3619 #[test]
3621 fn a_cancel_report_carries_its_fill_or_none() {
3622 let cancelled = |z: Option<&str>| {
3623 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3624 x: Some("CANCELED".to_string()),
3625 z: z.map(str::to_string),
3626 ..make_base_report()
3627 };
3628 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3629 let AccountEventKind::OrderCancelled(response) = event.kind else {
3630 panic!("expected OrderCancelled, got {:?}", event.kind);
3631 };
3632 let Ok(cancelled) = response.state else {
3633 panic!("expected Ok, got {:?}", response.state);
3634 };
3635 cancelled.filled_quantity
3636 };
3637
3638 assert_eq!(cancelled(Some("0.5")), Some(Decimal::new(5, 1)));
3639 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
3640 assert_eq!(cancelled(None), None);
3641 assert_eq!(cancelled(Some("not a number")), None);
3642 }
3643
3644 #[test]
3645 fn test_convert_execution_report_expired() {
3646 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3647 x: Some("EXPIRED".to_string()),
3648 ..make_base_report()
3649 };
3650 let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
3651 assert!(
3652 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3653 "EXPIRED should yield OrderCancelled with Ok state"
3654 );
3655 }
3656
3657 #[test]
3658 fn test_convert_execution_report_expired_in_match() {
3659 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3660 x: Some("EXPIRED_IN_MATCH".to_string()),
3661 ..make_base_report()
3662 };
3663 let event = sole_event(convert(report)).expect("EXPIRED_IN_MATCH should produce Some");
3664 assert!(
3665 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3666 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3667 );
3668 }
3669
3670 #[test]
3671 fn test_convert_execution_report_rejected() {
3672 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3673 x: Some("REJECTED".to_string()),
3674 r: Some("INSUFFICIENT_FUNDS".to_string()),
3675 ..make_base_report()
3676 };
3677
3678 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
3679 assert!(
3680 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_err()),
3681 "REJECTED should yield OrderCancelled with Err state"
3682 );
3683 }
3684
3685 #[test]
3686 fn test_convert_execution_report_missing_exec_type() {
3687 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3689 s: Some("BTCUSDT".to_string()),
3690 ..Default::default()
3691 };
3692 assert!(
3693 sole_event(convert(report)).is_none(),
3694 "missing execution type should return None"
3695 );
3696 }
3697
3698 #[test]
3699 fn test_convert_execution_report_missing_symbol() {
3700 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3702 x: Some("NEW".to_string()),
3703 ..Default::default()
3704 };
3705 assert!(
3706 sole_event(convert(report)).is_none(),
3707 "missing symbol should return None"
3708 );
3709 }
3710
3711 #[test]
3712 fn test_convert_execution_report_missing_order_id() {
3713 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3715 x: Some("NEW".to_string()),
3716 s: Some("BTCUSDT".to_string()),
3717 i: None,
3718 ..Default::default()
3719 };
3720 assert!(
3721 sole_event(convert(report)).is_none(),
3722 "missing orderId should return None"
3723 );
3724 }
3725
3726 #[test]
3727 fn test_convert_execution_report_trade_missing_trade_id() {
3728 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3730 x: Some("TRADE".to_string()),
3731 t: None,
3732 ..make_base_report()
3733 };
3734 assert!(
3735 sole_event(convert(report)).is_none(),
3736 "TRADE event missing tradeId should return None"
3737 );
3738 }
3739
3740 #[test]
3741 fn test_convert_execution_report_replace_yields_cancelled() {
3742 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3743 x: Some("REPLACE".to_string()),
3744 ..make_base_report()
3745 };
3746 let event = sole_event(convert(report)).expect("REPLACE should produce Some");
3749 assert!(
3750 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3751 "REPLACE should yield OrderCancelled with Ok state"
3752 );
3753 }
3754
3755 fn make_balance(
3760 asset: &str,
3761 free: &str,
3762 locked: &str,
3763 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3764 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3765 asset: Some(asset.to_string()),
3766 free: Some(free.to_string()),
3767 locked: Some(locked.to_string()),
3768 }
3769 }
3770
3771 #[test]
3772 fn test_filter_balances_empty_assets_returns_all() {
3773 let balances = vec![
3774 make_balance("BTC", "1.0", "0.0"),
3775 make_balance("USDT", "500.0", "50.0"),
3776 ];
3777 let result = filter_and_convert_balances(balances, &[]);
3778 assert_eq!(result.len(), 2, "empty filter should return all balances");
3779 }
3780
3781 #[test]
3782 fn test_filter_balances_matching_asset_returned() {
3783 let balances = vec![
3784 make_balance("BTC", "1.5", "0.5"),
3785 make_balance("ETH", "10.0", "0.0"),
3786 ];
3787 let assets = vec![AssetNameExchange::new("BTC")];
3788 let result = filter_and_convert_balances(balances, &assets);
3789 assert_eq!(result.len(), 1);
3790 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
3791 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
3793 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
3794 }
3795
3796 #[test]
3797 fn test_filter_balances_non_matching_asset_filtered_out() {
3798 let balances = vec![
3799 make_balance("BTC", "1.0", "0.0"),
3800 make_balance("ETH", "2.0", "0.0"),
3801 ];
3802 let assets = vec![AssetNameExchange::new("USDT")];
3803 let result = filter_and_convert_balances(balances, &assets);
3804 assert!(
3805 result.is_empty(),
3806 "non-matching asset should be filtered out"
3807 );
3808 }
3809
3810 #[test]
3811 fn test_filter_balances_missing_asset_field_skipped() {
3812 let balances = vec![
3813 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3814 asset: None,
3815 free: Some("1.0".to_string()),
3816 locked: Some("0.0".to_string()),
3817 },
3818 make_balance("USDT", "100.0", "0.0"),
3819 ];
3820 let result = filter_and_convert_balances(balances, &[]);
3821 assert_eq!(
3822 result.len(),
3823 1,
3824 "entry with missing asset should be skipped"
3825 );
3826 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
3827 }
3828
3829 #[test]
3830 fn test_filter_balances_unparseable_free_skipped() {
3831 let balances = vec![
3832 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3833 asset: Some("BTC".to_string()),
3834 free: Some("not-a-number".to_string()),
3835 locked: Some("0.0".to_string()),
3836 },
3837 ];
3838 let result = filter_and_convert_balances(balances, &[]);
3839 assert!(
3840 result.is_empty(),
3841 "unparseable free field should be skipped"
3842 );
3843 }
3844
3845 #[test]
3846 fn test_filter_balances_zero_balance_included() {
3847 let balances = vec![
3850 make_balance("BTC", "0.00000000", "0.00000000"),
3851 make_balance("USDT", "100.0", "0.0"),
3852 ];
3853 let result = filter_and_convert_balances(balances, &[]);
3854 assert_eq!(result.len(), 2, "zero-balance entries must be included");
3855 let btc = result
3856 .iter()
3857 .find(|b| b.asset == AssetNameExchange::new("BTC"))
3858 .unwrap();
3859 assert_eq!(btc.balance.total, Decimal::ZERO);
3860 assert_eq!(btc.balance.free, Decimal::ZERO);
3861 }
3862
3863 #[test]
3864 fn test_filter_balances_duplicate_assets_in_response() {
3865 let balances = vec![
3869 make_balance("BTC", "1.0", "0.0"),
3870 make_balance("BTC", "2.0", "0.0"),
3871 ];
3872 let result = filter_and_convert_balances(balances, &[]);
3873 assert_eq!(
3874 result.len(),
3875 2,
3876 "duplicate asset entries produce two AssetBalance entries"
3877 );
3878 }
3879
3880 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
3885 binance_sdk::spot::rest_api::MyTradesResponseInner {
3886 id: Some(9001),
3887 order_id: Some(4242),
3888 price: Some("50000.00".to_string()),
3889 qty: Some("0.01".to_string()),
3890 commission: Some("0.05".to_string()),
3891 time: Some(1_700_000_000_000),
3892 is_buyer: Some(true),
3893 ..Default::default()
3894 }
3895 }
3896
3897 #[test]
3898 fn test_convert_my_trade_happy_path() {
3899 let instrument = InstrumentNameExchange::new("BTCUSDT");
3900 let trade =
3901 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
3902 assert_eq!(trade.instrument, instrument);
3903 assert_eq!(trade.side, Side::Buy);
3904 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3905 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3906 }
3907
3908 #[test]
3909 fn test_convert_my_trade_sell_side() {
3910 let instrument = InstrumentNameExchange::new("BTCUSDT");
3911 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3912 is_buyer: Some(false),
3913 ..make_base_trade()
3914 };
3915 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
3916 assert_eq!(trade.side, Side::Sell);
3917 }
3918
3919 #[test]
3920 fn test_convert_my_trade_missing_id_returns_none() {
3921 let instrument = InstrumentNameExchange::new("BTCUSDT");
3922 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3923 id: None,
3924 ..make_base_trade()
3925 };
3926 assert!(
3927 convert_my_trade(&t, &instrument).is_none(),
3928 "missing id should return None"
3929 );
3930 }
3931
3932 #[test]
3933 fn test_convert_my_trade_missing_order_id_returns_none() {
3934 let instrument = InstrumentNameExchange::new("BTCUSDT");
3935 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3936 order_id: None,
3937 ..make_base_trade()
3938 };
3939 assert!(
3940 convert_my_trade(&t, &instrument).is_none(),
3941 "missing orderId should return None"
3942 );
3943 }
3944
3945 #[test]
3946 fn test_convert_my_trade_missing_is_buyer_returns_none() {
3947 let instrument = InstrumentNameExchange::new("BTCUSDT");
3948 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3949 is_buyer: None,
3950 ..make_base_trade()
3951 };
3952 assert!(
3953 convert_my_trade(&t, &instrument).is_none(),
3954 "missing isBuyer should return None"
3955 );
3956 }
3957
3958 #[test]
3959 fn test_convert_my_trade_commission_none_defaults_to_zero() {
3960 let instrument = InstrumentNameExchange::new("BTCUSDT");
3961 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3962 commission: None,
3963 ..make_base_trade()
3964 };
3965 let trade =
3966 convert_my_trade(&t, &instrument).expect("None commission should still convert");
3967 assert_eq!(trade.fees.fees, Decimal::ZERO);
3968 }
3969
3970 fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3975 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3976 order_id: Some(12345),
3977 client_order_id: Some("cid-abc".to_string()),
3978 side: Some("BUY".to_string()),
3979 r#type: Some("LIMIT".to_string()),
3980 price: Some("50000.00".to_string()),
3981 orig_qty: Some("0.01".to_string()),
3982 executed_qty: Some("0.0".to_string()),
3983 time_in_force: Some("GTC".to_string()),
3984 time: Some(1_700_000_000_000),
3985 status: Some("NEW".to_string()),
3986 ..Default::default()
3987 }
3988 }
3989
3990 #[test]
3991 fn test_convert_open_order_all_orders_response_converts_identically() {
3992 let instrument = InstrumentNameExchange::new("BTCUSDT");
3999 let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4000 order_id: Some(12345),
4001 client_order_id: Some("cid-abc".to_string()),
4002 side: Some("BUY".to_string()),
4003 r#type: Some("LIMIT".to_string()),
4004 price: Some("50000.00".to_string()),
4005 orig_qty: Some("0.01".to_string()),
4006 executed_qty: Some("0.0".to_string()),
4007 time_in_force: Some("GTC".to_string()),
4008 time: Some(1_700_000_000_000),
4009 status: Some("NEW".to_string()),
4010 ..Default::default()
4011 };
4012 let from_all_orders = convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument)
4013 .expect("valid order should convert");
4014 let from_open_orders = convert_open_order(
4015 &make_base_open_order(),
4016 ExchangeId::BinanceSpot,
4017 &instrument,
4018 )
4019 .expect("valid order should convert");
4020 assert_eq!(from_all_orders, from_open_orders);
4021 }
4022
4023 #[test]
4029 fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
4030 let instrument = InstrumentNameExchange::new("BTCUSDT");
4031 for status in [
4032 "CANCELED",
4033 "FILLED",
4034 "EXPIRED",
4035 "EXPIRED_IN_MATCH",
4036 "REJECTED",
4037 "PENDING_CANCEL",
4038 "SOME_FUTURE_STATUS",
4039 ] {
4040 let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4041 order_id: Some(12345),
4042 client_order_id: Some("cid-abc".to_string()),
4043 side: Some("BUY".to_string()),
4044 r#type: Some("LIMIT".to_string()),
4045 price: Some("50000.00".to_string()),
4046 orig_qty: Some("0.01".to_string()),
4047 executed_qty: Some("0.004".to_string()),
4048 time_in_force: Some("GTC".to_string()),
4049 time: Some(1_700_000_000_000),
4050 status: Some(status.to_string()),
4051 ..Default::default()
4052 };
4053 assert_eq!(
4054 convert_open_order(&row, ExchangeId::BinanceSpot, &instrument),
4055 None,
4056 "{status}"
4057 );
4058 }
4059 }
4060
4061 #[test]
4062 fn test_convert_open_order_admits_every_live_status() {
4063 let instrument = InstrumentNameExchange::new("BTCUSDT");
4064 for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
4065 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4066 status: Some(status.to_string()),
4067 ..make_base_open_order()
4068 };
4069 assert!(
4070 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_some(),
4071 "{status}"
4072 );
4073 }
4074 }
4075
4076 #[test]
4081 fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
4082 let instrument = InstrumentNameExchange::new("BTCUSDT");
4083 let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4084 order_id: Some(67890),
4085 client_order_id: Some("cid-def".to_string()),
4086 ..make_base_open_order()
4087 };
4088
4089 let listing = convert_open_order_listing(
4090 &[make_base_open_order(), second()],
4091 ExchangeId::BinanceSpot,
4092 &instrument,
4093 );
4094 assert_eq!(listing.orders.len(), 2);
4095 assert!(listing.complete);
4096
4097 let empty = convert_open_order_listing::<
4098 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
4099 >(&[], ExchangeId::BinanceSpot, &instrument);
4100 assert!(empty.orders.is_empty());
4101 assert!(empty.complete, "no open orders is a complete answer");
4102
4103 let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4104 side: None,
4105 ..second()
4106 };
4107 let listing = convert_open_order_listing(
4108 &[make_base_open_order(), dropped],
4109 ExchangeId::BinanceSpot,
4110 &instrument,
4111 );
4112 assert_eq!(listing.orders.len(), 1);
4113 assert!(!listing.complete, "a dropped row may be a live order");
4114
4115 let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4116 client_order_id: None,
4117 ..second()
4118 };
4119 let listing = convert_open_order_listing(
4120 &[make_base_open_order(), without_cid],
4121 ExchangeId::BinanceSpot,
4122 &instrument,
4123 );
4124 assert_eq!(listing.orders.len(), 2, "the row is still converted");
4125 assert!(
4126 !listing.complete,
4127 "an order kept under its orderId cannot be found under the cid it was placed with"
4128 );
4129 }
4130
4131 #[test]
4132 fn test_convert_open_order_missing_status_returns_none() {
4133 let instrument = InstrumentNameExchange::new("BTCUSDT");
4134 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4135 status: None,
4136 ..make_base_open_order()
4137 };
4138 assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none());
4139 }
4140
4141 #[test]
4147 fn test_convert_open_order_prefers_update_time_over_creation_time() {
4148 let instrument = InstrumentNameExchange::new("BTCUSDT");
4149
4150 let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4151 update_time: Some(1_700_000_005_000),
4152 ..make_base_open_order()
4153 };
4154 let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument)
4155 .expect("valid order should convert");
4156 assert_eq!(
4157 order.state.time_exchange,
4158 Utc.timestamp_millis_opt(1_700_000_005_000)
4159 .single()
4160 .unwrap(),
4161 "update_time must win over time"
4162 );
4163
4164 let order = convert_open_order(
4167 &make_base_open_order(),
4168 ExchangeId::BinanceSpot,
4169 &instrument,
4170 )
4171 .expect("valid order should convert");
4172 assert_eq!(
4173 order.state.time_exchange,
4174 Utc.timestamp_millis_opt(1_700_000_000_000)
4175 .single()
4176 .unwrap(),
4177 "absent update_time falls back to time"
4178 );
4179 }
4180
4181 #[test]
4182 fn test_convert_open_order_happy_path() {
4183 let instrument = InstrumentNameExchange::new("BTCUSDT");
4184 let order = convert_open_order(
4185 &make_base_open_order(),
4186 ExchangeId::BinanceSpot,
4187 &instrument,
4188 )
4189 .expect("valid order should convert");
4190 assert_eq!(order.key.instrument, instrument);
4191 assert_eq!(order.side, Side::Buy);
4192 assert_eq!(order.kind, OrderKind::Limit);
4193 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
4194 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
4195 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
4196 }
4197
4198 #[test]
4199 fn test_convert_open_order_owned_symbol_recovers_instrument() {
4200 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4202 symbol: Some("ETHUSDT".to_string()),
4203 ..make_base_open_order()
4204 };
4205 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot)
4206 .expect("valid order should convert");
4207 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4208 assert_eq!(order.side, Side::Buy);
4209 }
4210
4211 #[test]
4212 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
4213 let o = make_base_open_order();
4215 assert!(o.symbol.is_none());
4216 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot).is_none());
4217 }
4218
4219 #[test]
4220 fn test_convert_open_order_missing_order_id_returns_none() {
4221 let instrument = InstrumentNameExchange::new("BTCUSDT");
4222 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4223 order_id: None,
4224 ..make_base_open_order()
4225 };
4226 assert!(
4227 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4228 "missing orderId should return None"
4229 );
4230 }
4231
4232 #[test]
4233 fn test_convert_open_order_missing_side_returns_none() {
4234 let instrument = InstrumentNameExchange::new("BTCUSDT");
4235 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4236 side: None,
4237 ..make_base_open_order()
4238 };
4239 assert!(
4240 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4241 "missing side should return None"
4242 );
4243 }
4244
4245 #[test]
4246 fn test_convert_open_order_missing_type_returns_none() {
4247 let instrument = InstrumentNameExchange::new("BTCUSDT");
4248 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4249 r#type: None,
4250 ..make_base_open_order()
4251 };
4252 assert!(
4253 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4254 "missing type should return None"
4255 );
4256 }
4257
4258 #[test]
4259 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
4260 let instrument = InstrumentNameExchange::new("BTCUSDT");
4261 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4262 executed_qty: None,
4263 ..make_base_open_order()
4264 };
4265 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4266 .expect("None executedQty should still convert");
4267 assert_eq!(
4268 order.state.filled_quantity,
4269 Decimal::ZERO,
4270 "None executedQty should default to zero"
4271 );
4272 }
4273
4274 #[test]
4275 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
4276 let instrument = InstrumentNameExchange::new("BTCUSDT");
4277 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4278 executed_qty: Some("bad-value".to_string()),
4279 ..make_base_open_order()
4280 };
4281 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4282 .expect("unparseable executedQty should still convert");
4283 assert_eq!(
4284 order.state.filled_quantity,
4285 Decimal::ZERO,
4286 "unparseable executedQty should default to zero"
4287 );
4288 }
4289
4290 #[test]
4295 fn test_dedup_key_from_event_non_open_states_return_none() {
4296 use crate::order::state::{
4297 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
4298 };
4299 use rustrade_integration::collection::snapshot::Snapshot;
4300
4301 let key = OrderKey::new(
4302 ExchangeId::BinanceSpot,
4303 InstrumentNameExchange::new("BTCUSDT"),
4304 StrategyId::unknown(),
4305 ClientOrderId::new("cid1"),
4306 );
4307
4308 let event = UnindexedAccountEvent::new(
4310 ExchangeId::BinanceSpot,
4311 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4312 key.clone(),
4313 Side::Buy,
4314 None, Decimal::ZERO,
4316 OrderKind::Market,
4317 TimeInForce::ImmediateOrCancel,
4318 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4319 ActiveOrderState::OpenInFlight(OpenInFlight::new(Utc::now())),
4320 ),
4321 ))),
4322 );
4323 assert!(
4324 dedup_key_from_event(&event).is_none(),
4325 "OpenInFlight should return None — dedup not meaningful before exchange ack"
4326 );
4327
4328 let event = UnindexedAccountEvent::new(
4330 ExchangeId::BinanceSpot,
4331 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4332 key.clone(),
4333 Side::Buy,
4334 None, Decimal::ZERO,
4336 OrderKind::Market,
4337 TimeInForce::ImmediateOrCancel,
4338 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4339 ActiveOrderState::CancelInFlight(CancelInFlight::new(None, Utc::now())),
4340 ),
4341 ))),
4342 );
4343 assert!(
4344 dedup_key_from_event(&event).is_none(),
4345 "CancelInFlight should return None"
4346 );
4347
4348 let event = UnindexedAccountEvent::new(
4350 ExchangeId::BinanceSpot,
4351 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4352 key,
4353 Side::Buy,
4354 None, Decimal::ONE,
4356 OrderKind::Market,
4357 TimeInForce::ImmediateOrCancel,
4358 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
4359 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
4360 OrderId::new("123"),
4361 Utc::now(),
4362 Decimal::ONE, None,
4364 )),
4365 ),
4366 ))),
4367 );
4368 assert!(
4369 dedup_key_from_event(&event).is_none(),
4370 "Inactive state should return None"
4371 );
4372 }
4373
4374 #[test]
4375 fn test_dedup_key_from_event_cancelled_error_returns_none() {
4376 let report = binance_sdk::spot::websocket_api::ExecutionReport {
4379 x: Some("REJECTED".to_string()),
4380 r: Some("INSUFFICIENT_FUNDS".to_string()),
4381 ..make_base_report()
4382 };
4383 let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
4384 assert!(
4385 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
4386 "prerequisite: event is OrderCancelled with Err"
4387 );
4388 assert!(
4389 dedup_key_from_event(&event).is_none(),
4390 "OrderCancelled with Err state should return None"
4391 );
4392 }
4393
4394 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
4404 let mut value = serde_json::to_value(event).expect("event serializes to Value");
4405 value
4406 .as_object_mut()
4407 .expect("event serializes to a JSON object")
4408 .insert(
4409 "e".to_string(),
4410 serde_json::Value::String(event_type.to_string()),
4411 );
4412 serde_json::json!({ "subscriptionId": 0, "event": value }).to_string()
4413 }
4414
4415 #[test]
4416 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
4417 let report = binance_sdk::spot::websocket_api::ExecutionReport {
4418 x: Some("NEW".to_string()),
4419 s_uppercase: Some("BUY".to_string()),
4420 o: Some("LIMIT".to_string()),
4421 p: Some("50000.00".to_string()),
4422 q: Some("0.01".to_string()),
4423 f: Some("GTC".to_string()),
4424 z: Some("0".to_string()),
4425 ..make_base_report()
4426 };
4427 let frame = user_data_frame("executionReport", &report);
4428 let mut buf = Vec::new();
4429 let terminated = convert_user_data_events(&frame, &mut buf);
4430 assert!(
4431 !terminated,
4432 "ExecutionReport should not signal stream termination"
4433 );
4434 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
4435 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
4436 }
4437
4438 #[test]
4439 fn test_convert_user_data_events_account_position_pushes_to_buf() {
4440 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4441 u: Some(1_700_000_000_000),
4442 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
4443 ..Default::default()
4444 };
4445 let frame = user_data_frame("outboundAccountPosition", &position);
4446 let mut buf = Vec::new();
4447 let terminated = convert_user_data_events(&frame, &mut buf);
4448 assert!(
4449 !terminated,
4450 "OutboundAccountPosition should not signal stream termination"
4451 );
4452 assert_eq!(
4453 buf.len(),
4454 1,
4455 "OutboundAccountPosition should push one balance event"
4456 );
4457 }
4458
4459 #[test]
4460 fn test_convert_user_data_events_balance_update_ignored() {
4461 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4462 ..Default::default()
4463 };
4464 let frame = user_data_frame("balanceUpdate", &update);
4465 let mut buf = Vec::new();
4466 let terminated = convert_user_data_events(&frame, &mut buf);
4467 assert!(
4468 !terminated,
4469 "BalanceUpdate should not signal stream termination"
4470 );
4471 assert!(buf.is_empty(), "BalanceUpdate should push no events");
4472 }
4473
4474 #[test]
4475 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4476 let frame =
4478 r#"{"subscriptionId":0,"event":{"e":"eventStreamTerminated","E":1700000000000}}"#;
4479 let mut buf = Vec::new();
4480 let terminated = convert_user_data_events(frame, &mut buf);
4481 assert!(
4482 terminated,
4483 "eventStreamTerminated must signal stream termination"
4484 );
4485 assert!(
4486 buf.is_empty(),
4487 "eventStreamTerminated should push no events"
4488 );
4489 }
4490
4491 #[test]
4492 fn test_convert_user_data_events_unknown_event_ignored() {
4493 let frame =
4496 r#"{"subscriptionId":0,"event":{"e":"listStatus","E":1700000000000,"s":"BTCUSDT"}}"#;
4497 let mut buf = Vec::new();
4498 let terminated = convert_user_data_events(frame, &mut buf);
4499 assert!(!terminated, "unknown event must not signal termination");
4500 assert!(buf.is_empty(), "unknown event should push no events");
4501 }
4502
4503 #[test]
4508 fn test_convert_user_data_events_reads_the_ws_api_envelope() {
4509 let fill = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000001000,
4510 "s":"BTCUSDT","c":"client-1","S":"SELL","o":"MARKET","f":"GTC","q":"0.5","p":"0",
4511 "x":"TRADE","X":"FILLED","i":777,"l":"0.5","z":"0.5","L":"40000","n":"0.02",
4512 "N":"USDT","T":1700000001000,"t":888}}"#;
4513 let mut buf = Vec::new();
4514 assert!(!convert_user_data_events(fill, &mut buf));
4515 assert!(
4516 buf.iter()
4517 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
4518 "the enveloped fill must produce its trade, got: {buf:?}"
4519 );
4520
4521 let bare = r#"{"e":"executionReport","s":"BTCUSDT","i":777,"x":"NEW","X":"NEW"}"#;
4523 buf.clear();
4524 assert!(!convert_user_data_events(bare, &mut buf));
4525 assert!(buf.is_empty());
4526 }
4527
4528 #[test]
4529 fn test_convert_user_data_events_non_user_data_frame_ignored() {
4530 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4532 let mut buf = Vec::new();
4533 let terminated = convert_user_data_events(frame, &mut buf);
4534 assert!(
4535 !terminated,
4536 "non-user-data frame must not signal termination"
4537 );
4538 assert!(buf.is_empty(), "non-user-data frame should push no events");
4539 }
4540
4541 #[tokio::test]
4546 async fn test_rate_limit_tracker_not_blocked_initially() {
4547 tokio::time::pause();
4548 let tracker = RateLimitTracker::new(WeightPool::Spot);
4549 tokio::time::timeout(
4551 std::time::Duration::from_millis(1),
4552 tracker.wait_if_blocked(RequestKind::Order),
4553 )
4554 .await
4555 .expect("wait_if_blocked should return immediately when not blocked");
4556 }
4557
4558 #[tokio::test]
4559 async fn test_rate_limit_tracker_blocks_until_deadline() {
4560 tokio::time::pause();
4561 let tracker = RateLimitTracker::new(WeightPool::Spot);
4562 let delay = Duration::from_secs(5);
4563 tracker.on_rate_limited(Some(delay));
4564
4565 assert!(
4567 tokio::time::timeout(
4568 Duration::from_millis(1),
4569 tracker.wait_if_blocked(RequestKind::Order)
4570 )
4571 .await
4572 .is_err(),
4573 "wait_if_blocked should block while cooldown is active"
4574 );
4575
4576 tokio::time::advance(delay + Duration::from_millis(1)).await;
4578 tokio::time::timeout(
4579 Duration::from_millis(1),
4580 tracker.wait_if_blocked(RequestKind::Order),
4581 )
4582 .await
4583 .expect("wait_if_blocked should return after cooldown expires");
4584 }
4585
4586 #[tokio::test]
4587 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
4588 tokio::time::pause();
4589 let tracker = RateLimitTracker::new(WeightPool::Spot);
4590
4591 tracker.on_rate_limited(Some(Duration::from_secs(5)));
4593 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4595
4596 tokio::time::advance(Duration::from_secs(6)).await;
4598 assert!(
4599 tokio::time::timeout(
4600 Duration::from_millis(1),
4601 tracker.wait_if_blocked(RequestKind::Order)
4602 )
4603 .await
4604 .is_err(),
4605 "cooldown should have been extended to 10s"
4606 );
4607
4608 tokio::time::advance(Duration::from_secs(5)).await;
4610 tokio::time::timeout(
4611 Duration::from_millis(1),
4612 tracker.wait_if_blocked(RequestKind::Order),
4613 )
4614 .await
4615 .expect("wait_if_blocked should return after extended cooldown expires");
4616 }
4617
4618 #[tokio::test]
4619 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
4620 tokio::time::pause();
4621 let tracker = RateLimitTracker::new(WeightPool::Spot);
4622
4623 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4625 tracker.on_rate_limited(Some(Duration::from_secs(2)));
4626
4627 tokio::time::advance(Duration::from_secs(3)).await;
4629 assert!(
4630 tokio::time::timeout(
4631 Duration::from_millis(1),
4632 tracker.wait_if_blocked(RequestKind::Order)
4633 )
4634 .await
4635 .is_err(),
4636 "shorter on_rate_limited must not shorten existing cooldown"
4637 );
4638 }
4639
4640 #[test]
4645 fn test_classify_ws_order_error_with_wrapped_error_chain() {
4646 let instrument = InstrumentNameExchange::new("BTCUSDT");
4651 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
4652 code: -2010,
4653 message: "insufficient balance".into(),
4654 });
4655 assert!(
4657 classify_ws_order_error(&raw, &instrument).is_some(),
4658 "unwrapped ResponseError at root must be detected"
4659 );
4660 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
4662 assert!(
4663 classify_ws_order_error(&wrapped, &instrument).is_some(),
4664 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
4665 );
4666 }
4667
4668 #[tokio::test]
4671 async fn rest_used_weight_near_the_limit_pauses_queries() {
4672 for (used, pauses) in [(5_399, false), (5_400, true)] {
4673 let server = wiremock::MockServer::start().await;
4674 wiremock::Mock::given(wiremock::matchers::method("GET"))
4675 .and(wiremock::matchers::path("/api/v3/openOrders"))
4676 .respond_with(
4677 wiremock::ResponseTemplate::new(200)
4678 .insert_header("x-mbx-used-weight-1m", used.to_string())
4679 .set_body_json(serde_json::json!([])),
4680 )
4681 .mount(&server)
4682 .await;
4683 let rest = Arc::new(SpotRestApi::from_config(
4684 ConfigurationRestApi::builder()
4685 .api_key("key")
4686 .api_secret("secret")
4687 .base_path(server.uri())
4688 .build()
4689 .unwrap(),
4690 ));
4691 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4692
4693 fetch_all_open_orders(rest, Arc::clone(&tracker))
4694 .await
4695 .unwrap();
4696
4697 let query_waits = tokio::time::timeout(
4698 Duration::from_millis(50),
4699 tracker.wait_if_blocked(RequestKind::Query),
4700 )
4701 .await
4702 .is_err();
4703 assert_eq!(query_waits, pauses, "used weight {used}");
4704 tokio::time::timeout(
4705 Duration::from_millis(50),
4706 tracker.wait_if_blocked(RequestKind::Order),
4707 )
4708 .await
4709 .expect("orders never wait on the pause");
4710 }
4711 }
4712
4713 fn client_with_ws_api(ws_url: String) -> BinanceSpot {
4715 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4716 "key".into(),
4717 "secret".into(),
4718 ));
4719 client.ws_handle = SpotWsApi::from_config(
4720 ConfigurationWebsocketApi::builder()
4721 .api_key("key")
4722 .api_secret("secret")
4723 .ws_url(ws_url)
4724 .build()
4725 .unwrap(),
4726 );
4727 client
4728 }
4729
4730 fn cancel_request(
4732 instrument: &InstrumentNameExchange,
4733 ) -> OrderRequestCancel<ExchangeId, &InstrumentNameExchange> {
4734 crate::order::OrderEvent {
4735 key: OrderKey {
4736 exchange: ExchangeId::BinanceSpot,
4737 instrument,
4738 strategy: StrategyId::new("strategy"),
4739 cid: ClientOrderId::new("cid"),
4740 },
4741 state: crate::order::request::RequestCancel { id: None },
4742 }
4743 }
4744
4745 async fn ws_server<F, Fut>(serve: F) -> String
4747 where
4748 F: Fn(tokio::net::TcpStream) -> Fut + Send + 'static,
4749 Fut: std::future::Future<Output = ()> + Send + 'static,
4750 {
4751 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4752 let url = format!("ws://{}", listener.local_addr().unwrap());
4753 tokio::spawn(async move {
4754 while let Ok((stream, _)) = listener.accept().await {
4755 tokio::spawn(serve(stream));
4756 }
4757 });
4758 url
4759 }
4760
4761 #[tokio::test]
4764 async fn cancel_without_a_session_during_a_cooldown_is_rejected_unsent() {
4765 let client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4766 "key".into(),
4767 "secret".into(),
4768 ));
4769 client
4770 .rate_limiter
4771 .on_rate_limited(Some(Duration::from_secs(60)));
4772 let instrument = InstrumentNameExchange::new("BTCUSDT");
4773 let response = client.cancel_order(cancel_request(&instrument)).await;
4774
4775 assert!(
4776 matches!(
4777 response.state,
4778 Err(UnindexedOrderError::Rejected(ApiError::RateLimit))
4779 ),
4780 "{:?}",
4781 response.state
4782 );
4783 assert!(
4784 client.ws_api.read().await.is_none(),
4785 "no session was opened"
4786 );
4787 }
4788
4789 #[tokio::test]
4792 async fn a_handshake_refused_with_429_starts_a_cooldown() {
4793 use tokio_tungstenite::tungstenite::http;
4794
4795 let url = ws_server(|stream| async move {
4796 #[allow(clippy::result_large_err)]
4798 let refuse = |_: &http::Request<()>, _| {
4799 Err(http::Response::builder()
4800 .status(http::StatusCode::TOO_MANY_REQUESTS)
4801 .body(None)
4802 .unwrap())
4803 };
4804 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
4805 })
4806 .await;
4807 let client = client_with_ws_api(url);
4808 let instrument = InstrumentNameExchange::new("BTCUSDT");
4809
4810 let response = client.cancel_order(cancel_request(&instrument)).await;
4811
4812 assert!(
4813 matches!(
4814 response.state,
4815 Err(UnindexedOrderError::Rejected(ApiError::RateLimit))
4816 ),
4817 "{:?}",
4818 response.state
4819 );
4820 assert!(client.rate_limiter.is_blocked());
4821 }
4822
4823 #[tokio::test]
4826 async fn a_ws_api_response_near_the_weight_limit_pauses_queries() {
4827 use futures::{SinkExt as _, StreamExt as _};
4828 use tokio_tungstenite::tungstenite::Message;
4829
4830 let url = ws_server(|stream| async move {
4831 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
4832 return;
4833 };
4834 while let Some(Ok(message)) = ws.next().await {
4835 let Message::Text(text) = message else {
4836 continue;
4837 };
4838 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
4839 let response = serde_json::json!({
4840 "id": request["id"],
4841 "status": 200,
4842 "result": {
4843 "symbol": "BTCUSDT",
4844 "origClientOrderId": "cid",
4845 "orderId": 7,
4846 "transactTime": 1_700_000_000_000_i64,
4847 "executedQty": "0",
4848 },
4849 "rateLimits": [{
4850 "rateLimitType": "REQUEST_WEIGHT",
4851 "interval": "MINUTE",
4852 "intervalNum": 1,
4853 "limit": 1_000,
4854 "count": 950,
4855 }],
4856 });
4857 if ws.send(Message::text(response.to_string())).await.is_err() {
4858 return;
4859 }
4860 }
4861 })
4862 .await;
4863 let client = client_with_ws_api(url);
4864 let instrument = InstrumentNameExchange::new("BTCUSDT");
4865
4866 let response = client.cancel_order(cancel_request(&instrument)).await;
4867
4868 assert!(response.state.is_ok(), "{:?}", response.state);
4869 assert_eq!(client.rate_limiter.weight_limit(), 1_000);
4870 assert!(
4871 tokio::time::timeout(
4872 Duration::from_millis(50),
4873 client.rate_limiter.wait_if_blocked(RequestKind::Query),
4874 )
4875 .await
4876 .is_err(),
4877 "queries pause"
4878 );
4879 tokio::time::timeout(
4880 Duration::from_millis(50),
4881 client.rate_limiter.wait_if_blocked(RequestKind::Order),
4882 )
4883 .await
4884 .expect("orders never wait on the pause");
4885 }
4886
4887 #[tokio::test]
4890 async fn fill_recovery_does_not_wait_for_the_weight_pause() {
4891 let server = wiremock::MockServer::start().await;
4892 wiremock::Mock::given(wiremock::matchers::method("GET"))
4893 .and(wiremock::matchers::path("/api/v3/myTrades"))
4894 .respond_with(
4896 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
4897 "symbol": "BTCUSDT",
4898 "id": 1,
4899 "orderId": 7,
4900 "price": "100",
4901 "qty": "1",
4902 "commission": "0",
4903 "commissionAsset": "USDT",
4904 "time": Utc::now().timestamp_millis(),
4905 "isBuyer": true,
4906 "isMaker": false,
4907 }])),
4908 )
4909 .expect(2)
4910 .mount(&server)
4911 .await;
4912 let rest = Arc::new(SpotRestApi::from_config(
4913 ConfigurationRestApi::builder()
4914 .api_key("key")
4915 .api_secret("secret")
4916 .base_path(server.uri())
4917 .build()
4918 .unwrap(),
4919 ));
4920 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4921 tracker.throttle(Duration::from_secs(60));
4922 let (tx, _rx) = mpsc::unbounded_channel();
4923 let mut unrecovered = UnrecoveredFills::default();
4924 unrecovered.open(
4925 &[InstrumentNameExchange::new("BTCUSDT")],
4926 Utc::now() - chrono::Duration::minutes(1),
4927 Utc::now(),
4928 );
4929
4930 tokio::time::timeout(
4932 Duration::from_millis(800),
4933 recover_fills(
4934 &rest,
4935 &tracker,
4936 &mut unrecovered,
4937 &tx,
4938 &new_dedup_cache(),
4939 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
4940 ),
4941 )
4942 .await
4943 .expect("recovery does not wait for the pause");
4944 }
4945
4946 #[tokio::test]
4948 async fn a_gap_whose_every_read_fails_is_reported_when_given_up() {
4949 let disconnect = Utc::now() - chrono::Duration::minutes(10);
4950 let reconnect = Utc::now() - chrono::Duration::minutes(5);
4951 let server = wiremock::MockServer::start().await;
4952 wiremock::Mock::given(wiremock::matchers::method("GET"))
4953 .and(wiremock::matchers::path("/api/v3/myTrades"))
4954 .respond_with(
4955 wiremock::ResponseTemplate::new(400)
4956 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
4957 )
4958 .mount(&server)
4959 .await;
4960 let rest = Arc::new(SpotRestApi::from_config(
4961 ConfigurationRestApi::builder()
4962 .api_key("key")
4963 .api_secret("secret")
4964 .base_path(server.uri())
4965 .build()
4966 .expect("valid config"),
4967 ));
4968 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4969 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4970 let dedup = new_dedup_cache();
4971 let btc = InstrumentNameExchange::new("BTCUSDT");
4972 let mut unrecovered = UnrecoveredFills::default();
4973 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
4974
4975 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
4976 recover_fills(
4977 &rest,
4978 &tracker,
4979 &mut unrecovered,
4980 &tx,
4981 &dedup,
4982 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
4983 )
4984 .await;
4985 unrecovered.make_due();
4986 }
4987
4988 assert!(unrecovered.is_empty(), "the gap is given up");
4989 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
4990 let [event] = forwarded.as_slice() else {
4991 panic!("only the give-up is sent: {forwarded:?}");
4992 };
4993 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
4994 panic!("expected FillRecoveryGaveUp, got {event:?}");
4995 };
4996 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
4997 assert_eq!(
4998 gave_up.scope,
4999 crate::FillRecoveryScope::Instruments(vec![btc])
5000 );
5001 assert_eq!(
5002 gave_up.start.timestamp_millis(),
5003 disconnect.timestamp_millis()
5004 );
5005 assert_eq!(
5006 gave_up.attempts,
5007 crate::client::order_recovery::MAX_GAP_RETRIES + 1
5008 );
5009 assert!(
5010 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
5011 "the last read's error: {:?}",
5012 gave_up.reason
5013 );
5014 }
5015
5016 #[tokio::test]
5019 async fn a_failed_recovery_keeps_its_closed_gap_for_the_retry() {
5020 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5021 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5022 let trade = |id: i64, time: DateTime<Utc>| {
5023 serde_json::json!({
5024 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5025 "commission": "0", "commissionAsset": "USDT", "time": time.timestamp_millis(),
5026 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5027 })
5028 };
5029 let server = wiremock::MockServer::start().await;
5030 wiremock::Mock::given(wiremock::matchers::method("GET"))
5031 .and(wiremock::matchers::path("/api/v3/myTrades"))
5032 .respond_with(
5033 wiremock::ResponseTemplate::new(400)
5034 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5035 )
5036 .up_to_n_times(1)
5037 .with_priority(1)
5038 .mount(&server)
5039 .await;
5040 wiremock::Mock::given(wiremock::matchers::method("GET"))
5041 .and(wiremock::matchers::path("/api/v3/myTrades"))
5042 .respond_with(
5043 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
5044 trade(1, disconnect + chrono::Duration::minutes(1)),
5045 trade(2, Utc::now() - chrono::Duration::minutes(1)),
5046 ])),
5047 )
5048 .mount(&server)
5049 .await;
5050 let rest = Arc::new(SpotRestApi::from_config(
5051 ConfigurationRestApi::builder()
5052 .api_key("key")
5053 .api_secret("secret")
5054 .base_path(server.uri())
5055 .build()
5056 .unwrap(),
5057 ));
5058 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5059 let (tx, mut rx) = mpsc::unbounded_channel();
5060 let dedup = new_dedup_cache();
5061 let mut unrecovered = UnrecoveredFills::default();
5062 unrecovered.open(
5063 &[InstrumentNameExchange::new("BTCUSDT")],
5064 disconnect,
5065 reconnect,
5066 );
5067
5068 recover_fills(
5069 &rest,
5070 &tracker,
5071 &mut unrecovered,
5072 &tx,
5073 &dedup,
5074 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5075 )
5076 .await;
5077 assert!(!unrecovered.is_empty(), "the failed gap is kept");
5078 assert!(rx.try_recv().is_err(), "nothing forwarded");
5079 assert!(
5080 unrecovered.due(tokio::time::Instant::now()).is_empty(),
5081 "and waits for its retry"
5082 );
5083
5084 unrecovered.make_due();
5085 recover_fills(
5086 &rest,
5087 &tracker,
5088 &mut unrecovered,
5089 &tx,
5090 &dedup,
5091 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5092 )
5093 .await;
5094 assert!(unrecovered.is_empty(), "the retry recovered it");
5095 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5096 assert_eq!(
5097 forwarded.len(),
5098 1,
5099 "only the fill inside the gap: {forwarded:?}"
5100 );
5101
5102 let start_times: Vec<String> = server
5103 .received_requests()
5104 .await
5105 .unwrap()
5106 .iter()
5107 .filter_map(|request| {
5108 request
5109 .url
5110 .query_pairs()
5111 .find(|(key, _)| key == "startTime")
5112 .map(|(_, value)| value.into_owned())
5113 })
5114 .collect();
5115 let start = disconnect.timestamp_millis().to_string();
5116 assert_eq!(start_times, [start.clone(), start]);
5117 }
5118
5119 #[tokio::test]
5122 async fn a_span_walk_pages_by_id_and_stops_past_the_end() {
5123 let start = Utc::now().timestamp_millis() - 600_000;
5124 let end = start + 10_000;
5125 let trade = |id: i64, time: i64| {
5126 serde_json::json!({
5127 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5128 "commission": "0", "commissionAsset": "USDT", "time": time,
5129 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5130 })
5131 };
5132 let server = wiremock::MockServer::start().await;
5133 wiremock::Mock::given(wiremock::matchers::method("GET"))
5134 .and(wiremock::matchers::path("/api/v3/myTrades"))
5135 .and(wiremock::matchers::query_param("fromId", "1001"))
5136 .respond_with(
5138 wiremock::ResponseTemplate::new(200).set_body_json(
5139 (1_001..=2_000)
5140 .map(|id| trade(id, if id == 1_001 { end } else { end + 1 }))
5141 .collect::<Vec<_>>(),
5142 ),
5143 )
5144 .with_priority(1)
5145 .mount(&server)
5146 .await;
5147 let full_page: Vec<_> = (1..=1_000).map(|id| trade(id, start + id)).collect();
5148 wiremock::Mock::given(wiremock::matchers::method("GET"))
5149 .and(wiremock::matchers::path("/api/v3/myTrades"))
5150 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(full_page))
5151 .mount(&server)
5152 .await;
5153 let rest = Arc::new(SpotRestApi::from_config(
5154 ConfigurationRestApi::builder()
5155 .api_key("key")
5156 .api_secret("secret")
5157 .base_path(server.uri())
5158 .build()
5159 .unwrap(),
5160 ));
5161
5162 let read = paginate_my_trades(
5163 &rest,
5164 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5165 &InstrumentNameExchange::new("BTCUSDT"),
5166 MyTradesFrom::Span { start, end },
5167 RequestKind::Query,
5168 )
5169 .await
5170 .unwrap();
5171
5172 let ids: Vec<_> = read.iter().filter_map(|t| t.id).collect();
5173 assert_eq!(ids, (1..=1_001).collect::<Vec<_>>());
5174 assert_eq!(server.received_requests().await.unwrap().len(), 2);
5175 }
5176
5177 #[tokio::test]
5180 async fn fetch_trades_returns_only_the_span() {
5181 let start = DateTime::from_timestamp_millis(
5183 (Utc::now() - chrono::Duration::minutes(10)).timestamp_millis(),
5184 )
5185 .unwrap()
5186 + chrono::Duration::microseconds(500);
5187 let end = start + chrono::Duration::seconds(10);
5188 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
5189 let trade = |id: i64, time: i64| {
5190 serde_json::json!({
5191 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5192 "commission": "0", "commissionAsset": "USDT", "time": time,
5193 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5194 })
5195 };
5196 let server = wiremock::MockServer::start().await;
5197 wiremock::Mock::given(wiremock::matchers::method("GET"))
5198 .and(wiremock::matchers::path("/api/v3/myTrades"))
5199 .and(wiremock::matchers::query_param(
5200 "startTime",
5201 start_ms.to_string(),
5202 ))
5203 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(vec![
5204 trade(1, start_ms),
5205 trade(2, start_ms + 1),
5206 trade(3, end_ms),
5207 trade(4, end_ms + 1),
5208 ]))
5209 .mount(&server)
5210 .await;
5211 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5212 "key".into(),
5213 "secret".into(),
5214 ));
5215 client.rest = Arc::new(SpotRestApi::from_config(
5216 ConfigurationRestApi::builder()
5217 .api_key("key")
5218 .api_secret("secret")
5219 .base_path(server.uri())
5220 .build()
5221 .unwrap(),
5222 ));
5223 let btc = [InstrumentNameExchange::new("BTCUSDT")];
5224
5225 let read = client.fetch_trades(start, end, &btc).await.unwrap();
5226 let ids: Vec<_> = read.trades.iter().map(|t| t.id.0.as_str()).collect();
5227 assert_eq!(ids, ["2", "3"]);
5228 assert_eq!(read.resume, None, "spot reads a span whole");
5229
5230 let empty = client.fetch_trades(end, start, &btc).await.unwrap();
5231 assert!(empty.trades.is_empty() && empty.resume.is_none());
5232 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5233 }
5234
5235 #[tokio::test]
5237 async fn a_gap_whose_every_read_times_out_is_reported_when_given_up() {
5238 let server = wiremock::MockServer::start().await;
5239 wiremock::Mock::given(wiremock::matchers::method("GET"))
5240 .and(wiremock::matchers::path("/api/v3/myTrades"))
5241 .respond_with(
5242 wiremock::ResponseTemplate::new(200)
5243 .set_body_json(serde_json::json!([]))
5244 .set_delay(Duration::from_secs(3_600)),
5245 )
5246 .mount(&server)
5247 .await;
5248 let rest = Arc::new(SpotRestApi::from_config(
5249 ConfigurationRestApi::builder()
5250 .api_key("key")
5251 .api_secret("secret")
5252 .base_path(server.uri())
5253 .timeout(3_600_000_u64)
5254 .retries(0_u32)
5255 .build()
5256 .expect("valid config"),
5257 ));
5258 let btc = InstrumentNameExchange::new("BTCUSDT");
5259 let mut unrecovered = UnrecoveredFills::default();
5260 unrecovered.open(
5261 std::slice::from_ref(&btc),
5262 Utc::now() - chrono::Duration::minutes(10),
5263 Utc::now(),
5264 );
5265 let (tx, mut rx) = mpsc::unbounded_channel();
5266 tokio::time::pause();
5268
5269 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
5270 recover_fills(
5271 &rest,
5272 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5273 &mut unrecovered,
5274 &tx,
5275 &new_dedup_cache(),
5276 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5277 )
5278 .await;
5279 unrecovered.make_due();
5280 }
5281
5282 assert!(unrecovered.is_empty(), "the gap is given up");
5283 let Ok(event) = rx.try_recv() else {
5284 panic!("the given-up gap is reported");
5285 };
5286 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
5287 panic!("expected FillRecoveryGaveUp, got {event:?}");
5288 };
5289 assert_eq!(
5290 gave_up.scope,
5291 crate::FillRecoveryScope::Instruments(vec![btc])
5292 );
5293 assert_eq!(
5294 gave_up.reason,
5295 FillRecoveryFailure::TimedOut {
5296 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS
5297 }
5298 );
5299 assert!(rx.try_recv().is_err(), "reported once");
5300 }
5301
5302 #[tokio::test]
5305 async fn a_timed_out_recovery_charges_only_the_gaps_it_started() {
5306 let server = wiremock::MockServer::start().await;
5307 wiremock::Mock::given(wiremock::matchers::method("GET"))
5308 .and(wiremock::matchers::path("/api/v3/myTrades"))
5309 .respond_with(
5310 wiremock::ResponseTemplate::new(200)
5311 .set_body_json(serde_json::json!([]))
5312 .set_delay(Duration::from_secs(3_600)),
5313 )
5314 .mount(&server)
5315 .await;
5316 let rest = Arc::new(SpotRestApi::from_config(
5317 ConfigurationRestApi::builder()
5318 .api_key("key")
5319 .api_secret("secret")
5320 .base_path(server.uri())
5321 .timeout(3_600_000_u64)
5322 .retries(0_u32)
5323 .build()
5324 .unwrap(),
5325 ));
5326 let instruments: Vec<_> = (0..9)
5327 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
5328 .collect();
5329 let mut unrecovered = UnrecoveredFills::default();
5330 unrecovered.open(
5331 &instruments,
5332 Utc::now() - chrono::Duration::minutes(10),
5333 Utc::now(),
5334 );
5335 let (tx, _rx) = mpsc::unbounded_channel();
5336 tokio::time::pause();
5338
5339 recover_fills(
5340 &rest,
5341 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5342 &mut unrecovered,
5343 &tx,
5344 &new_dedup_cache(),
5345 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5346 )
5347 .await;
5348
5349 let now = tokio::time::Instant::now();
5350 assert_eq!(
5351 unrecovered.due(now).len(),
5352 1,
5353 "the gap never started stays due"
5354 );
5355 assert_eq!(
5356 unrecovered
5357 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
5358 .len(),
5359 9,
5360 "the eight started wait for their first retry"
5361 );
5362 }
5363
5364 fn order_row(cid: &str, status: &str) -> serde_json::Value {
5370 serde_json::json!({
5371 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
5372 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
5373 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "time": 1_700_000_000_000_i64,
5374 "updateTime": 1_700_000_060_000_i64,
5375 })
5376 }
5377
5378 fn get_order_row(row: serde_json::Value) -> binance_sdk::spot::rest_api::GetOrderResponse {
5379 serde_json::from_value(row).unwrap()
5380 }
5381
5382 fn spot_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
5383 OrderKey::new(
5384 ExchangeId::BinanceSpot,
5385 InstrumentNameExchange::new(instrument),
5386 StrategyId::new("strategy"),
5387 ClientOrderId::new(cid),
5388 )
5389 }
5390
5391 fn ended_state(
5392 key: &UnindexedOrderKey,
5393 row: serde_json::Value,
5394 ) -> Option<crate::order::state::UnindexedInactiveOrderState> {
5395 let order = convert_ended_order(&get_order_row(row), ExchangeId::BinanceSpot, key)?;
5396 assert_eq!(&order.key, key, "reported under the key asked");
5397 assert_eq!(order.quantity, Decimal::TWO);
5398 Some(order.state)
5399 }
5400
5401 #[test]
5404 fn an_ended_order_row_without_a_fill_reports_it_unknown() {
5405 use crate::order::state::{Expired, Filled, InactiveOrderState};
5406 let key = spot_key("BTCUSDT", "a");
5407 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
5408 let id = OrderId::new("7");
5409 let row = |status: &str, executed_qty: Option<&str>| {
5410 let mut row = order_row("a", status);
5411 row["executedQty"] = executed_qty.map_or(serde_json::Value::Null, Into::into);
5412 row
5413 };
5414
5415 for executed_qty in [None, Some("not a number")] {
5416 assert_eq!(
5417 ended_state(&key, row("CANCELED", executed_qty)),
5418 Some(InactiveOrderState::Cancelled(Cancelled::new(
5419 id.clone(),
5420 ended,
5421 None
5422 ))),
5423 "{executed_qty:?}"
5424 );
5425 assert_eq!(
5426 ended_state(&key, row("EXPIRED", executed_qty)),
5427 Some(InactiveOrderState::Expired(Expired::new(
5428 id.clone(),
5429 ended,
5430 None
5431 ))),
5432 "{executed_qty:?}"
5433 );
5434 assert_eq!(
5435 ended_state(&key, row("FILLED", executed_qty)),
5436 Some(InactiveOrderState::FullyFilled(Filled::new(
5437 id.clone(),
5438 ended,
5439 Decimal::TWO,
5440 Some(Decimal::new(525, 1)),
5441 ))),
5442 "the whole quantity, at the quote over it: {executed_qty:?}"
5443 );
5444 }
5445 }
5446
5447 #[test]
5448 fn an_ended_order_row_says_how_the_order_ended() {
5449 use crate::order::state::{Expired, Filled, InactiveOrderState};
5450 let key = spot_key("BTCUSDT", "a");
5451 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
5452 let id = OrderId::new("7");
5453
5454 let mut filled = order_row("a", "FILLED");
5455 filled["executedQty"] = "2".into();
5456 filled["cummulativeQuoteQty"] = "210".into();
5457 assert_eq!(
5458 ended_state(&key, filled),
5459 Some(InactiveOrderState::FullyFilled(Filled::new(
5460 id.clone(),
5461 ended,
5462 Decimal::TWO,
5463 Some(Decimal::from(105)),
5464 )))
5465 );
5466 assert_eq!(
5467 ended_state(&key, order_row("a", "CANCELED")),
5468 Some(InactiveOrderState::Cancelled(Cancelled::new(
5469 id.clone(),
5470 ended,
5471 Some(Decimal::ONE)
5472 )))
5473 );
5474 for status in ["EXPIRED", "EXPIRED_IN_MATCH"] {
5475 assert_eq!(
5476 ended_state(&key, order_row("a", status)),
5477 Some(InactiveOrderState::Expired(Expired::new(
5478 id.clone(),
5479 ended,
5480 Some(Decimal::ONE)
5481 ))),
5482 "{status}"
5483 );
5484 }
5485 assert!(matches!(
5486 ended_state(&key, order_row("a", "REJECTED")),
5487 Some(InactiveOrderState::OpenFailed(OrderError::Rejected(
5488 ApiError::OrderRejected(_)
5489 )))
5490 ));
5491 }
5492
5493 #[test]
5494 fn a_live_or_unreadable_order_row_is_not_ended() {
5495 let key = spot_key("BTCUSDT", "a");
5496 for status in [
5497 "NEW",
5498 "PARTIALLY_FILLED",
5499 "PENDING_NEW",
5500 "PENDING_CANCEL",
5501 "SOMETHING_NEW",
5502 ] {
5503 assert_eq!(ended_state(&key, order_row("a", status)), None, "{status}");
5504 }
5505 let mut no_status = order_row("a", "FILLED");
5506 no_status["status"] = serde_json::Value::Null;
5507 assert_eq!(ended_state(&key, no_status), None);
5508 let mut no_side = order_row("a", "FILLED");
5509 no_side["side"] = serde_json::Value::Null;
5510 assert_eq!(ended_state(&key, no_side), None);
5511 }
5512
5513 #[test]
5514 fn an_unknown_order_or_symbol_is_recognised_by_its_code() {
5515 use binance_sdk::common::errors::ConnectorError;
5516 let bad = |code| {
5517 anyhow::Error::new(ConnectorError::BadRequestError {
5518 msg: "no".into(),
5519 code: Some(code),
5520 })
5521 };
5522 assert!(is_unknown_order(&bad(-2013)));
5523 assert!(is_unknown_order(&bad(-1121)));
5524 assert!(!is_unknown_order(&bad(-1100)));
5525 assert!(is_unknown_order(&anyhow::Error::new(
5526 ConnectorError::NotFoundError {
5527 msg: "no".into(),
5528 code: Some(-2013),
5529 }
5530 )));
5531 assert!(!is_unknown_order(&anyhow::anyhow!("-2013 in text only")));
5532 }
5533
5534 fn rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
5535 Arc::new(SpotRestApi::from_config(
5536 ConfigurationRestApi::builder()
5537 .api_key("key")
5538 .api_secret("secret")
5539 .base_path(server.uri())
5540 .build()
5541 .unwrap(),
5542 ))
5543 }
5544
5545 async fn mount_order(
5547 server: &wiremock::MockServer,
5548 cid: &str,
5549 response: wiremock::ResponseTemplate,
5550 ) {
5551 wiremock::Mock::given(wiremock::matchers::method("GET"))
5552 .and(wiremock::matchers::path("/api/v3/order"))
5553 .and(wiremock::matchers::query_param("origClientOrderId", cid))
5554 .respond_with(response)
5555 .mount(server)
5556 .await;
5557 }
5558
5559 fn venue_error(code: i64) -> wiremock::ResponseTemplate {
5560 wiremock::ResponseTemplate::new(400)
5561 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
5562 }
5563
5564 fn row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
5565 wiremock::ResponseTemplate::new(200).set_body_json(order_row(cid, status))
5566 }
5567
5568 async fn looked_up(server: &wiremock::MockServer) -> Vec<String> {
5570 server
5571 .received_requests()
5572 .await
5573 .unwrap()
5574 .iter()
5575 .filter(|request| request.url.path() == "/api/v3/order")
5576 .filter_map(|request| {
5577 request
5578 .url
5579 .query_pairs()
5580 .find(|(key, _)| key == "origClientOrderId")
5581 .map(|(_, value)| value.into_owned())
5582 })
5583 .collect()
5584 }
5585
5586 #[tokio::test]
5587 async fn fetch_ended_orders_reports_each_ended_order_under_its_key_and_omits_the_rest() {
5588 let server = wiremock::MockServer::start().await;
5589 mount_order(&server, "filled", row_response("filled", "FILLED")).await;
5590 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
5591 mount_order(&server, "live", row_response("live", "PARTIALLY_FILLED")).await;
5592 mount_order(&server, "unknown", venue_error(-2013)).await;
5593 mount_order(&server, "no-symbol", venue_error(-1121)).await;
5594 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5595 "key".into(),
5596 "secret".into(),
5597 ));
5598 client.rest = rest_at(&server);
5599 let keys = ["filled", "cancelled", "live", "unknown"].map(|cid| spot_key("BTCUSDT", cid));
5600
5601 let mut ended = client
5602 .fetch_ended_orders(&[keys.to_vec(), vec![spot_key("NOSUCH", "no-symbol")]].concat())
5603 .await
5604 .unwrap();
5605
5606 ended.sort_by(|a, b| a.key.cid.cmp(&b.key.cid));
5607 let reported: Vec<_> = ended.iter().map(|order| &order.key).collect();
5608 assert_eq!(
5609 reported,
5610 [&keys[1], &keys[0]],
5611 "the strategy asked survives"
5612 );
5613 assert!(matches!(
5614 ended[0].state,
5615 crate::order::state::InactiveOrderState::Cancelled(_)
5616 ));
5617 assert!(matches!(
5618 ended[1].state,
5619 crate::order::state::InactiveOrderState::FullyFilled(_)
5620 ));
5621 assert!(client.fetch_ended_orders(&[]).await.unwrap().is_empty());
5622 }
5623
5624 #[tokio::test]
5625 async fn fetch_ended_orders_fails_when_any_lookup_fails() {
5626 let server = wiremock::MockServer::start().await;
5627 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
5628 mount_order(&server, "refused", venue_error(-1100)).await;
5629 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5630 "key".into(),
5631 "secret".into(),
5632 ));
5633 client.rest = rest_at(&server);
5634
5635 let result = client
5636 .fetch_ended_orders(&[
5637 spot_key("BTCUSDT", "cancelled"),
5638 spot_key("BTCUSDT", "refused"),
5639 ])
5640 .await;
5641
5642 assert!(
5643 matches!(
5644 result,
5645 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
5646 ),
5647 "{result:?}"
5648 );
5649 }
5650
5651 async fn venue_after_a_disconnect(delay: Duration) -> wiremock::MockServer {
5654 let server = wiremock::MockServer::start().await;
5655 wiremock::Mock::given(wiremock::matchers::method("GET"))
5656 .and(wiremock::matchers::path("/api/v3/openOrders"))
5657 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
5658 .respond_with(
5659 wiremock::ResponseTemplate::new(200)
5660 .set_body_json(serde_json::json!([order_row("live", "NEW")])),
5661 )
5662 .mount(&server)
5663 .await;
5664 mount_order(
5665 &server,
5666 "gone",
5667 row_response("gone", "CANCELED").set_delay(delay),
5668 )
5669 .await;
5670 mount_order(&server, "unknown", venue_error(-2013)).await;
5671 server
5672 }
5673
5674 fn held_orders() -> SharedKnownLiveOrders {
5676 let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
5677 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
5678 known.lock().live(
5679 &spot_key("BTCUSDT", cid),
5680 Decimal::TWO,
5681 &Open::new(
5682 VenueOrderId::Assigned(OrderId::new(n.to_string())),
5683 Utc::now(),
5684 Decimal::ZERO,
5685 ),
5686 );
5687 }
5688 known
5689 }
5690
5691 fn btcusdt_unchecked() -> UncheckedOrders {
5692 let mut unchecked = UncheckedOrders::default();
5693 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
5694 unchecked
5695 }
5696
5697 #[tokio::test]
5698 async fn a_reconnect_reports_how_an_unlisted_order_ended_and_forgets_an_unknown_one() {
5699 let server = venue_after_a_disconnect(Duration::ZERO).await;
5700 let known = held_orders();
5701 let mut unchecked = btcusdt_unchecked();
5702 let (tx, mut rx) = mpsc::unbounded_channel();
5703
5704 recover_spot_ended_orders(
5705 &rest_at(&server),
5706 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5707 &known,
5708 &mut unchecked,
5709 &UnrecoveredFills::default(),
5710 &tx,
5711 )
5712 .await;
5713
5714 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5715 let [event] = sent.as_slice() else {
5716 panic!("one order ended: {sent:?}");
5717 };
5718 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
5719 panic!("an order snapshot: {event:?}");
5720 };
5721 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
5722 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
5723 assert!(matches!(
5724 snapshot.0.state,
5725 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
5726 ));
5727 let mut asked = looked_up(&server).await;
5728 asked.sort();
5729 assert_eq!(
5730 asked,
5731 ["gone", "unknown"],
5732 "a listed order is not looked up"
5733 );
5734 let known = known.lock();
5735 assert!(known.contains(&ClientOrderId::new("live")));
5736 assert!(!known.contains(&ClientOrderId::new("gone")));
5737 assert!(
5738 !known.contains(&ClientOrderId::new("unknown")),
5739 "asking again cannot help"
5740 );
5741 assert!(unchecked.is_empty());
5742 }
5743
5744 #[tokio::test]
5745 async fn an_order_the_stream_reports_ending_during_the_check_is_not_reported_again() {
5746 let server = venue_after_a_disconnect(Duration::from_millis(300)).await;
5747 let known = held_orders();
5748 let mut unchecked = btcusdt_unchecked();
5749 let (tx, mut rx) = mpsc::unbounded_channel();
5750 let (rest, tracker) = (
5751 rest_at(&server),
5752 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5753 );
5754 let unrecovered = UnrecoveredFills::default();
5755
5756 tokio::join!(
5757 recover_spot_ended_orders(&rest, &tracker, &known, &mut unchecked, &unrecovered, &tx,),
5758 async {
5759 tokio::time::sleep(Duration::from_millis(100)).await;
5760 known.lock().ended(&ClientOrderId::new("gone"));
5761 }
5762 );
5763
5764 assert!(rx.try_recv().is_err(), "the stream already reported it");
5765 assert!(unchecked.is_empty());
5766 }
5767
5768 #[tokio::test]
5769 async fn an_instrument_with_unrecovered_fills_is_not_checked_yet() {
5770 let server = venue_after_a_disconnect(Duration::ZERO).await;
5771 let known = held_orders();
5772 let mut unchecked = btcusdt_unchecked();
5773 let mut unrecovered = UnrecoveredFills::default();
5774 unrecovered.open(
5775 &[InstrumentNameExchange::new("BTCUSDT")],
5776 Utc::now() - chrono::Duration::minutes(1),
5777 Utc::now(),
5778 );
5779 let (tx, mut rx) = mpsc::unbounded_channel();
5780
5781 recover_spot_ended_orders(
5782 &rest_at(&server),
5783 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5784 &known,
5785 &mut unchecked,
5786 &unrecovered,
5787 &tx,
5788 )
5789 .await;
5790
5791 assert!(rx.try_recv().is_err());
5792 assert!(server.received_requests().await.unwrap().is_empty());
5793 assert!(!unchecked.is_empty(), "it waits for its fills");
5794 assert_eq!(unchecked.next_due(&unrecovered), None, "woken by the gap");
5795 }
5796
5797 #[tokio::test]
5798 async fn a_failed_check_sends_nothing_and_waits_for_its_retry() {
5799 let server = wiremock::MockServer::start().await;
5800 wiremock::Mock::given(wiremock::matchers::method("GET"))
5801 .and(wiremock::matchers::path("/api/v3/openOrders"))
5802 .respond_with(venue_error(-1100))
5803 .mount(&server)
5804 .await;
5805 let server_rest = rest_at(&server);
5806 let known = held_orders();
5807 let mut unchecked = btcusdt_unchecked();
5808 let (tx, mut rx) = mpsc::unbounded_channel();
5809
5810 recover_spot_ended_orders(
5811 &server_rest,
5812 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5813 &known,
5814 &mut unchecked,
5815 &UnrecoveredFills::default(),
5816 &tx,
5817 )
5818 .await;
5819
5820 assert!(rx.try_recv().is_err());
5821 assert!(!unchecked.is_empty());
5822 assert!(
5823 unchecked
5824 .next_due(&UnrecoveredFills::default())
5825 .is_some_and(|due| due > tokio::time::Instant::now()),
5826 "retried after a backoff"
5827 );
5828 assert!(
5829 known.lock().contains(&ClientOrderId::new("gone")),
5830 "still held"
5831 );
5832 }
5833
5834 #[tokio::test]
5835 async fn an_unlisted_order_the_lookup_finds_live_stays_held() {
5836 let server = wiremock::MockServer::start().await;
5837 wiremock::Mock::given(wiremock::matchers::method("GET"))
5838 .and(wiremock::matchers::path("/api/v3/openOrders"))
5839 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
5840 .mount(&server)
5841 .await;
5842 for (cid, status) in [
5843 ("live", "NEW"),
5844 ("gone", "PENDING_CANCEL"),
5845 ("unknown", "WHAT"),
5846 ] {
5847 mount_order(&server, cid, row_response(cid, status)).await;
5848 }
5849 let known = held_orders();
5850 let mut unchecked = btcusdt_unchecked();
5851 let (tx, mut rx) = mpsc::unbounded_channel();
5852
5853 recover_spot_ended_orders(
5854 &rest_at(&server),
5855 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5856 &known,
5857 &mut unchecked,
5858 &UnrecoveredFills::default(),
5859 &tx,
5860 )
5861 .await;
5862
5863 assert!(rx.try_recv().is_err(), "none has ended");
5864 let known = known.lock();
5865 for cid in ["live", "gone", "unknown"] {
5866 assert!(
5867 known.contains(&ClientOrderId::new(cid)),
5868 "{cid}: only an order Binance does not know is dropped"
5869 );
5870 }
5871 assert!(unchecked.is_empty());
5872 }
5873
5874 #[tokio::test]
5875 async fn one_instrument_failing_does_not_hold_up_another() {
5876 let server = venue_after_a_disconnect(Duration::ZERO).await;
5877 wiremock::Mock::given(wiremock::matchers::method("GET"))
5878 .and(wiremock::matchers::path("/api/v3/openOrders"))
5879 .and(wiremock::matchers::query_param("symbol", "ETHUSDT"))
5880 .respond_with(venue_error(-1100))
5881 .mount(&server)
5882 .await;
5883 let known = held_orders();
5884 let eth = spot_key("ETHUSDT", "eth");
5885 known.lock().live(
5886 ð,
5887 Decimal::TWO,
5888 &Open::new(
5889 VenueOrderId::Assigned(OrderId::new("9")),
5890 Utc::now(),
5891 Decimal::ZERO,
5892 ),
5893 );
5894 let mut unchecked = btcusdt_unchecked();
5895 unchecked.open([eth.instrument.clone()]);
5896 let (tx, mut rx) = mpsc::unbounded_channel();
5897
5898 recover_spot_ended_orders(
5899 &rest_at(&server),
5900 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5901 &known,
5902 &mut unchecked,
5903 &UnrecoveredFills::default(),
5904 &tx,
5905 )
5906 .await;
5907
5908 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5909 assert_eq!(sent.len(), 1, "BTCUSDT's ended order: {sent:?}");
5910 assert!(!unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
5911 assert!(
5912 unchecked.contains(ð.instrument),
5913 "ETHUSDT waits for its retry"
5914 );
5915 assert!(known.lock().contains(ð.cid));
5916 }
5917
5918 #[tokio::test]
5919 async fn a_check_dropped_part_way_changes_nothing() {
5920 let server = venue_after_a_disconnect(Duration::from_millis(500)).await;
5921 let known = held_orders();
5922 let mut unchecked = btcusdt_unchecked();
5923 let (tx, mut rx) = mpsc::unbounded_channel();
5924
5925 let dropped = tokio::time::timeout(
5926 Duration::from_millis(100),
5927 recover_spot_ended_orders(
5928 &rest_at(&server),
5929 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5930 &known,
5931 &mut unchecked,
5932 &UnrecoveredFills::default(),
5933 &tx,
5934 ),
5935 )
5936 .await;
5937
5938 assert!(dropped.is_err(), "the lookup of `gone` was still waiting");
5939 assert!(rx.try_recv().is_err());
5940 assert!(known.lock().contains(&ClientOrderId::new("gone")));
5941 assert!(unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
5942 assert!(
5943 unchecked
5944 .next_due(&UnrecoveredFills::default())
5945 .is_some_and(|due| due <= tokio::time::Instant::now()),
5946 "still due, not charged a failure"
5947 );
5948 }
5949}