1use super::shared::{
37 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
38 CONNECT_TIMEOUT_SECS, ExecutionReport, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS,
39 HEARTBEAT_TIMEOUT_SECS, MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing,
40 PlacementResponse, RateLimitTracker, RequestKind, SIGNAL_RECOVERY_LOOKBACK_MS,
41 SharedDedupCache, UnrecoveredFills, UserDataFrame, WeightPool, binance_filled_qty,
42 classify_order_kind_tif, classify_rest_query_error, classify_ws_order_error,
43 convert_ended_order, convert_execution_report, convert_open_order_listing,
44 convert_open_order_owned_symbol, dedup_key_from_event, drop_after, frame_excerpt, gap_failed,
45 gap_time, is_duplicate, is_handshake_rate_limit, is_unknown_order, log_unrecognised_frame,
46 new_dedup_cache, parse_user_data_frame, placed_order_state, recovered_order_totals,
47 response_decode_error, rest_call_with_retry, trailing_delta_basis_points, unix_ms,
48};
49use crate::{
50 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
51 UnindexedAccountEvent, UnindexedAccountSnapshot,
52 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
53 client::{
54 ExecutionClient, OrderStatusClient,
55 order_recovery::{
56 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
57 fetch_ended_by_key, recover_ended_orders,
58 },
59 },
60 emit_stream_terminated,
61 error::{
62 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
63 UnindexedOrderError,
64 },
65 order::{
66 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, 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.kind, &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(
553 convert_ended_order(&row, ExchangeId::BinanceSpot, &key, None)
554 .map_or(OrderLookup::NotEnded, |order| {
555 OrderLookup::Ended(Box::new(order))
556 }),
557 )
558}
559
560async fn listed_open_cids(
563 rest: Arc<RestApi>,
564 rate_limiter: Arc<RateLimitTracker>,
565 instruments: Vec<InstrumentNameExchange>,
566) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
567 let mut listed = FnvHashSet::default();
568 for instrument in instruments {
569 let (_, listing) = fetch_open_orders_for_instrument(
570 rest.clone(),
571 rate_limiter.clone(),
572 instrument,
573 RequestKind::Essential,
574 )
575 .await?;
576 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
577 }
578 Ok(listed)
579}
580
581async fn recover_spot_ended_orders(
584 rest: &Arc<RestApi>,
585 rate_limiter: &Arc<RateLimitTracker>,
586 known: &SharedKnownLiveOrders,
587 unchecked: &mut UncheckedOrders,
588 unrecovered: &UnrecoveredFills,
589 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
590) {
591 recover_ended_orders(
592 ExchangeId::BinanceSpot,
593 known,
594 unchecked,
595 unrecovered,
596 tx,
597 OpenListing::PerInstrument,
598 |instruments| listed_open_cids(rest.clone(), rate_limiter.clone(), instruments),
599 |key| {
600 fetch_order_lookup(
601 rest.clone(),
602 rate_limiter.clone(),
603 key,
604 RequestKind::Essential,
605 )
606 },
607 )
608 .await;
609}
610
611async fn paginate_my_trades(
622 rest: &Arc<RestApi>,
623 rate_limiter: &Arc<RateLimitTracker>,
624 instrument: &InstrumentNameExchange,
625 from: MyTradesFrom,
626 kind: RequestKind,
627) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
628 let symbol_str = instrument.name().to_string();
630 let mut all_pages = Vec::new();
635 let mut cursor: Option<i64> = None;
636 loop {
637 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
639 let sym = symbol_str.clone();
640 Box::pin(async move {
641 #[allow(clippy::cast_possible_truncation)]
643 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
644 let params = match (from, fid) {
645 (MyTradesFrom::Span { start, .. }, None) => builder.start_time(start),
646 (MyTradesFrom::Span { .. }, Some(id)) => builder.from_id(id),
647 (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
648 (MyTradesFrom::Order(order_id), Some(id)) => {
649 builder.order_id(order_id).from_id(id)
650 }
651 }
652 .build()?;
653 rest.my_trades(params).await
654 })
655 })
656 .await
657 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
658
659 let mut page = response.data().await.map_err(response_decode_error)?;
660
661 let page_len = page.len();
662 let last_id = page.last().and_then(|t| t.id);
663 let past_end = match from {
665 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
666 MyTradesFrom::Order(_) => false,
667 };
668 all_pages.extend(page);
669
670 if past_end || page_len < BINANCE_MAX_TRADES {
671 break;
672 }
673 match last_id {
674 Some(id) => {
675 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
676 match id.checked_add(1) {
677 Some(next) => cursor = Some(next),
678 None => break, }
680 }
681 None => {
682 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
683 break;
684 }
685 }
686 }
687 Ok(all_pages)
688}
689
690impl ExecutionClient for BinanceSpot {
695 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
696
697 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
700 &[InstrumentKindDiscriminant::Spot];
701 type Config = BinanceSpotConfig;
702 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
703
704 fn new(config: Self::Config) -> Self {
709 let rest = Self::build_rest(&config);
710 let ws_handle = Self::build_ws_handle(&config);
711 Self {
712 config: Arc::new(config),
713 rest,
714 ws_handle,
715 ws_api: Arc::new(RwLock::new(None)),
716 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Spot)),
717 known_live: KnownLiveOrders::shared(ExchangeId::BinanceSpot),
718 }
719 }
720
721 async fn account_snapshot(
722 &self,
723 assets: &[AssetNameExchange],
724 instruments: &[InstrumentNameExchange],
725 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
726 let response =
728 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
729 Box::pin(async move {
730 let params = GetAccountParams::builder().build()?;
731 rest.get_account(params).await
732 })
733 })
734 .await
735 .map_err(|e| classify_rest_query_error(&e, None))?;
736
737 let account = response.data().await.map_err(response_decode_error)?;
738
739 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
741
742 use futures::{StreamExt as _, TryStreamExt};
748 let instrument_snapshots: Vec<_> =
749 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
750 fetch_open_orders_for_instrument(
751 self.rest.clone(),
752 self.rate_limiter.clone(),
753 instrument,
754 RequestKind::Query,
755 )
756 }))
757 .buffer_unordered(8)
758 .map(|result| {
759 let (inst, listing) = result?;
760 self.remember_live(&listing.orders);
761 let wrapped = listing
762 .orders
763 .into_iter()
764 .map(|o| Order {
765 key: o.key,
766 side: o.side,
767 price: o.price,
768 quantity: o.quantity,
769 kind: o.kind,
770 time_in_force: o.time_in_force,
771 state: OrderState::active(o.state),
772 })
773 .collect();
774 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
775 inst,
776 wrapped,
777 listing.complete,
778 PositionReport::Unreported,
779 None,
780 ))
781 })
782 .try_collect()
783 .await?;
784
785 Ok(AccountSnapshot::new(
786 ExchangeId::BinanceSpot,
787 balances,
788 instrument_snapshots,
789 ))
790 }
791
792 async fn account_stream(
866 &self,
867 _assets: &[AssetNameExchange],
871 instruments: &[InstrumentNameExchange],
872 ) -> Result<Self::AccountStream, UnindexedClientError> {
873 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
888 let dedup = new_dedup_cache();
889 let ws_handle = self.ws_handle.clone();
890 let rest = self.rest.clone();
891 let rate_limiter = self.rate_limiter.clone();
892 let known_live = self.known_live.clone();
893 let instruments = instruments.to_vec();
894 debug_assert!(
898 instruments.iter().all(|i| i.name().len() <= 23),
899 "instrument name exceeds SmolStr inline capacity: {:?}",
900 instruments.iter().find(|i| i.name().len() > 23)
901 );
902
903 let initial_ws = ws_handle.connect().await.map_err(|e| {
907 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
908 })?;
909
910 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
912 .build()
913 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
914
915 match initial_ws
916 .user_data_stream_subscribe_signature(params)
917 .await
918 {
919 Ok(_) => {}
920 Err(e) => {
921 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
926 Ok(Err(de)) => {
927 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
928 }
929 Err(_) => {
930 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
931 }
932 Ok(Ok(())) => {}
933 }
934 return Err(UnindexedClientError::Internal(e.to_string()));
935 }
936 }
937
938 let cm_handle = tokio::spawn(connection_manager(
950 tx,
951 dedup,
952 ws_handle,
953 rest,
954 rate_limiter,
955 known_live,
956 instruments,
957 Some(initial_ws),
958 ));
959
960 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
964 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
965 Ok(futures::StreamExt::boxed(guarded_stream))
966 }
967
968 async fn cancel_order(
969 &self,
970 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
971 ) -> UnindexedOrderResponseCancel {
972 let instrument = request.key.instrument.clone();
973 let key = OrderKey {
974 exchange: request.key.exchange,
975 instrument: instrument.clone(),
976 strategy: request.key.strategy.clone(),
977 cid: request.key.cid.clone(),
978 };
979
980 let ws = match self.get_ws_api().await {
981 Ok(ws) => ws,
982 Err(unavailable) => {
983 return UnindexedOrderResponseCancel {
984 key,
985 state: Err(unavailable.into_order_error()),
986 };
987 }
988 };
989
990 let mut params_builder =
993 OrderCancelParams::builder(request.key.instrument.name().to_string());
994
995 if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
997 if let Ok(id) = order_id.0.parse::<i64>() {
998 params_builder = params_builder.order_id(id);
999 } else {
1000 error!(
1005 order_id = %order_id.0,
1006 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
1007 );
1008 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1009 }
1010 } else {
1011 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1012 }
1013
1014 let params = match params_builder.build() {
1015 Ok(p) => p,
1016 Err(e) => {
1017 error!(%e, "BinanceSpot failed to build cancel order params");
1018 return UnindexedOrderResponseCancel {
1019 key,
1020 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1021 e.to_string(),
1022 ))),
1023 };
1024 }
1025 };
1026
1027 let sent_ms = unix_ms();
1028 let result = ws.order_cancel(params).await;
1029 if let Ok(response) = &result {
1031 self.rate_limiter
1032 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1033 }
1034 match result {
1035 Ok(response) => match response.data() {
1036 Ok(data) => {
1037 let time_exchange = data
1038 .transact_time
1039 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1040 .unwrap_or_else(Utc::now);
1041
1042 let exchange_order_id = match data.order_id {
1043 Some(id) => OrderId(format_smolstr!("{id}")),
1044 None => {
1045 error!("BinanceSpot cancel response missing orderId");
1046 return UnindexedOrderResponseCancel {
1047 key,
1048 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1049 "cancel response missing orderId".into(),
1050 ))),
1051 };
1052 }
1053 };
1054
1055 let filled_qty = binance_filled_qty(
1056 ExchangeId::BinanceSpot,
1057 &exchange_order_id,
1058 data.executed_qty.as_deref(),
1059 );
1060
1061 self.known_live.lock().ended(&key.cid);
1062 UnindexedOrderResponseCancel {
1063 key,
1064 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1065 }
1066 }
1067 Err(e) => {
1068 UnindexedOrderResponseCancel {
1070 key,
1071 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1072 e.to_string(),
1073 ))),
1074 }
1075 }
1076 },
1077 Err(e) => {
1078 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1086 if matches!(order_err, OrderError::Rejected(ApiError::RateLimit)) {
1090 self.rate_limiter.on_rate_limited(None);
1091 }
1092 UnindexedOrderResponseCancel {
1093 key,
1094 state: Err(order_err),
1095 }
1096 } else {
1097 self.clear_ws_api().await;
1100 UnindexedOrderResponseCancel {
1101 key,
1102 state: Err(UnindexedOrderError::Connectivity(
1103 ConnectivityError::Socket(format!("{e:#}")),
1104 )),
1105 }
1106 }
1107 }
1108 }
1109 }
1110
1111 async fn open_order(
1112 &self,
1113 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1114 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1115 let instrument = request.key.instrument.clone();
1116 let side = request.state.side;
1117 let price = request.state.price;
1118 let quantity = request.state.quantity;
1119 let kind = request.state.kind;
1120 let time_in_force = request.state.time_in_force;
1121 let cid = request.key.cid.clone();
1122
1123 let order_key = OrderKey::new(
1124 ExchangeId::BinanceSpot,
1125 instrument.clone(),
1126 request.key.strategy.clone(),
1127 cid.clone(),
1128 );
1129
1130 let ws = match self.get_ws_api().await {
1131 Ok(ws) => ws,
1132 Err(unavailable) => {
1133 return Order {
1134 key: order_key,
1135 side,
1136 price,
1137 quantity,
1138 kind,
1139 time_in_force,
1140 state: OrderState::inactive(unavailable.into_order_error()),
1141 };
1142 }
1143 };
1144
1145 let binance_side = match side {
1146 Side::Buy => OrderPlaceSideEnum::Buy,
1147 Side::Sell => OrderPlaceSideEnum::Sell,
1148 };
1149
1150 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
1151 Some(converted) => converted,
1152 None => {
1153 return Order {
1154 key: order_key,
1155 side,
1156 price,
1157 quantity,
1158 kind,
1159 time_in_force,
1160 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1161 "Binance Spot does not yet support OrderKind::{kind:?}"
1162 ))),
1163 };
1164 }
1165 };
1166
1167 let mut params_builder =
1174 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
1175 .quantity(quantity)
1176 .new_client_order_id(cid.0.to_string());
1177
1178 match kind {
1181 OrderKind::Limit => {
1182 params_builder = params_builder.price(price);
1183 }
1184 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
1185 params_builder = params_builder.stop_price(trigger_price);
1186 }
1187 OrderKind::StopLimit { trigger_price }
1188 | OrderKind::TakeProfitLimit { trigger_price } => {
1189 params_builder = params_builder.price(price).stop_price(trigger_price);
1190 }
1191 OrderKind::TrailingStop {
1192 offset,
1193 offset_type,
1194 } => match trailing_delta_basis_points(offset, offset_type) {
1195 Ok(basis_points) => params_builder = params_builder.trailing_delta(basis_points),
1196 Err(refused) => {
1197 return Order {
1198 key: order_key,
1199 side,
1200 price,
1201 quantity,
1202 kind,
1203 time_in_force,
1204 state: OrderState::inactive(refused.into_order_error()),
1205 };
1206 }
1207 },
1208 _ => {}
1212 }
1213
1214 if let Some(tif) = binance_tif {
1215 params_builder = params_builder.time_in_force(tif);
1216 }
1217
1218 let params = match params_builder.build() {
1219 Ok(p) => p,
1220 Err(e) => {
1221 error!(%e, "BinanceSpot failed to build new order params");
1222 return Order {
1223 key: order_key,
1224 side,
1225 price,
1226 quantity,
1227 kind,
1228 time_in_force,
1229 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1230 e.to_string(),
1231 ))),
1232 };
1233 }
1234 };
1235
1236 let sent_ms = unix_ms();
1237 let result = ws.order_place(params).await;
1238 if let Ok(response) = &result {
1240 self.rate_limiter
1241 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1242 }
1243 match result {
1244 Ok(response) => match response.data() {
1245 Ok(data) => {
1246 let time_exchange = data
1247 .transact_time
1248 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1249 .unwrap_or_else(Utc::now);
1250
1251 let exchange_order_id = match data.order_id {
1252 Some(id) => OrderId(format_smolstr!("{id}")),
1253 None => {
1254 error!("BinanceSpot open_order response missing orderId");
1255 return Order {
1256 key: order_key,
1257 side,
1258 price,
1259 quantity,
1260 kind,
1261 time_in_force,
1262 state: OrderState::inactive(OrderError::Rejected(
1263 ApiError::OrderRejected(
1264 "open_order response missing orderId".into(),
1265 ),
1266 )),
1267 };
1268 }
1269 };
1270
1271 let state = placed_order_state(
1276 ExchangeId::BinanceSpot,
1277 &instrument,
1278 quantity,
1279 PlacementResponse {
1280 status: data.status.as_deref(),
1281 order_id: exchange_order_id,
1282 time_exchange,
1283 executed_qty: data.executed_qty.as_deref(),
1284 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
1285 },
1286 );
1287 self.known_live
1288 .lock()
1289 .placed(&order_key, quantity, kind, &state);
1290
1291 Order {
1292 key: order_key,
1293 side,
1294 price,
1295 quantity,
1296 kind,
1297 time_in_force,
1298 state,
1299 }
1300 }
1301 Err(e) => {
1302 Order {
1304 key: order_key,
1305 side,
1306 price,
1307 quantity,
1308 kind,
1309 time_in_force,
1310 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1311 e.to_string(),
1312 ))),
1313 }
1314 }
1315 },
1316 Err(e) => {
1317 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1325 if matches!(order_err, OrderError::Rejected(ApiError::RateLimit)) {
1329 self.rate_limiter.on_rate_limited(None);
1330 }
1331 Order {
1332 key: order_key,
1333 side,
1334 price,
1335 quantity,
1336 kind,
1337 time_in_force,
1338 state: OrderState::inactive(order_err),
1339 }
1340 } else {
1341 self.clear_ws_api().await;
1344 Order {
1345 key: order_key,
1346 side,
1347 price,
1348 quantity,
1349 kind,
1350 time_in_force,
1351 state: OrderState::inactive(OrderError::Connectivity(
1352 ConnectivityError::Socket(format!("{e:#}")),
1353 )),
1354 }
1355 }
1356 }
1357 }
1358 }
1359
1360 async fn fetch_balances(
1361 &self,
1362 assets: &[AssetNameExchange],
1363 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1364 let response =
1365 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1366 Box::pin(async move {
1367 let params = GetAccountParams::builder().build()?;
1368 rest.get_account(params).await
1369 })
1370 })
1371 .await
1372 .map_err(|e| classify_rest_query_error(&e, None))?;
1373
1374 let account = response.data().await.map_err(response_decode_error)?;
1375
1376 Ok(filter_and_convert_balances(
1377 account.balances.unwrap_or_default(),
1378 assets,
1379 ))
1380 }
1381
1382 async fn fetch_open_orders(
1387 &self,
1388 instruments: &[InstrumentNameExchange],
1389 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1390 if instruments.is_empty() {
1393 let orders =
1394 fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await?;
1395 self.remember_live(&orders);
1396 return Ok(orders);
1397 }
1398 use futures::{StreamExt as _, TryStreamExt as _};
1403 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1404 fetch_open_orders_for_instrument(
1405 self.rest.clone(),
1406 self.rate_limiter.clone(),
1407 instrument,
1408 RequestKind::Query,
1409 )
1410 }))
1411 .buffer_unordered(8)
1412 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1413 acc.extend(listing.orders);
1414 Ok(acc)
1415 })
1416 .await
1417 .inspect(|orders| self.remember_live(orders))
1418 }
1419
1420 #[allow(clippy::redundant_iter_cloned)]
1431 async fn fetch_trades(
1432 &self,
1433 start: DateTime<Utc>,
1434 end: DateTime<Utc>,
1435 instruments: &[InstrumentNameExchange],
1436 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1437 use futures::StreamExt;
1438
1439 if instruments.is_empty() {
1440 debug!(
1441 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1442 );
1443 return Ok(TradesRead::complete(Vec::new()));
1444 }
1445 if start > end {
1446 return Ok(TradesRead::complete(Vec::new()));
1447 }
1448 let span = MyTradesFrom::Span {
1450 start: start.timestamp_millis(),
1451 end: end.timestamp_millis(),
1452 };
1453 let mut all_trades = Vec::new();
1456
1457 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1461 let rest = self.rest.clone();
1462 let rate_limiter = self.rate_limiter.clone();
1463 async move {
1464 let pages =
1465 paginate_my_trades(&rest, &rate_limiter, &inst, span, RequestKind::Query)
1466 .await?;
1467 Ok::<_, UnindexedClientError>((inst, pages))
1468 }
1469 }))
1470 .buffer_unordered(8);
1471 while let Some(result) = stream.next().await {
1472 let (instrument, trades_data) = result?;
1473 for t in trades_data {
1474 if let Some(trade) = convert_my_trade(&t, &instrument)
1475 && (start..=end).contains(&trade.time_exchange)
1476 {
1477 all_trades.push(trade);
1478 }
1479 }
1480 }
1481
1482 Ok(TradesRead::complete(all_trades))
1483 }
1484}
1485
1486impl OrderStatusClient for BinanceSpot {
1501 async fn fetch_ended_orders(
1502 &self,
1503 orders: &[UnindexedOrderKey],
1504 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1505 fetch_ended_by_key(orders, |key| {
1506 fetch_order_lookup(
1507 self.rest.clone(),
1508 self.rate_limiter.clone(),
1509 key,
1510 RequestKind::Query,
1511 )
1512 })
1513 .await
1514 }
1515}
1516
1517async fn connect_and_subscribe(ws_handle: &WebsocketApiHandle) -> anyhow::Result<WebsocketApi> {
1520 let ws = ws_handle.connect().await?;
1521 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1523 .build()
1524 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1525 match ws.user_data_stream_subscribe_signature(params).await {
1526 Ok(_) => Ok(ws),
1527 Err(e) => {
1528 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1529 let ws_cleanup = ws;
1530 tokio::spawn(async move {
1540 match tokio::time::timeout(Duration::from_secs(5), ws_cleanup.disconnect()).await {
1541 Ok(Err(dc_err)) => warn!(%dc_err, "BinanceSpot WS cleanup disconnect failed"),
1542 Err(_) => warn!("BinanceSpot WS cleanup disconnect timed out (5s)"),
1543 Ok(Ok(())) => {}
1544 }
1545 });
1546 Err(e)
1547 }
1548 }
1549}
1550
1551#[allow(clippy::cognitive_complexity)]
1573async fn connection_manager(
1574 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1575 dedup: SharedDedupCache,
1576 ws_handle: WebsocketApiHandle,
1577 rest: Arc<RestApi>,
1578 rate_limiter: Arc<RateLimitTracker>,
1579 known: SharedKnownLiveOrders,
1580 instruments: Vec<InstrumentNameExchange>,
1581 initial_ws: Option<WebsocketApi>,
1582) {
1583 let mut backoff = ExponentialBackoff::new();
1584 let mut disconnect_time: Option<DateTime<Utc>> = None;
1585 let mut unrecovered = UnrecoveredFills::default();
1587 let mut unchecked = UncheckedOrders::default();
1589 let mut current_ws = initial_ws;
1590
1591 loop {
1592 let ws = match current_ws.take() {
1594 Some(ws) => ws,
1595 None => match connect_and_subscribe(&ws_handle).await {
1596 Ok(ws) => ws,
1602 Err(e) => {
1603 error!(%e, "BinanceSpot WS connect/subscribe failed");
1604 if !backoff.wait().await {
1605 error!("BinanceSpot max reconnect attempts exhausted");
1606 emit_stream_terminated(
1608 &tx,
1609 ExchangeId::BinanceSpot,
1610 StreamTerminationReason::ReconnectBudgetExhausted {
1611 attempts: backoff.attempts(),
1612 last_error: e.to_string(),
1613 },
1614 );
1615 break;
1616 }
1617 continue;
1618 }
1619 },
1620 };
1621 info!("BinanceSpot account_stream connected and subscribed");
1622
1623 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1629 let mut signal_tx_opt = Some(signal_tx);
1630 let heartbeat_flag = Arc::new(AtomicBool::new(true));
1634 let hb_callback = heartbeat_flag.clone();
1635 let dedup_callback = dedup.clone();
1636 let known_callback = known.clone();
1637 let mut event_tx = Some(tx.clone());
1641 let mut event_buf = Vec::with_capacity(32);
1645
1646 let subscription = ws.subscribe_on_ws_events(move |event| {
1653 let Some(ref sender) = event_tx else { return };
1654 match event {
1655 WebsocketEvent::Message(json_str) => {
1656 hb_callback.store(true, Ordering::Release);
1659 let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1663 for ev in event_buf.drain(..) {
1664 if let Some(key) = dedup_key_from_event(&ev)
1666 && is_duplicate(&dedup_callback, key)
1667 {
1668 trace!("BinanceSpot dedup: skipping duplicate event");
1669 continue;
1670 }
1671 let known = KnownLiveOrders::observes(&ev.kind).then(|| {
1675 let mut known = known_callback.lock();
1676 known.observe(&ev);
1677 known
1678 });
1679 if sender.send(ev).is_err() {
1680 drop(known);
1681 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1682 event_tx.take();
1683 if let Some(s) = signal_tx_opt.take() {
1684 let _ = s.send(());
1685 }
1686 return;
1687 }
1688 }
1689 if stream_terminated {
1692 event_tx.take();
1693 if let Some(s) = signal_tx_opt.take() {
1694 let _ = s.send(());
1695 }
1696 }
1697 }
1698 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1699 hb_callback.store(true, Ordering::Release);
1702 }
1703 WebsocketEvent::Error(e) => {
1704 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1708 event_tx.take();
1709 if let Some(s) = signal_tx_opt.take() {
1710 let _ = s.send(());
1711 }
1712 }
1713 WebsocketEvent::Close(code, reason) => {
1714 warn!(code, %reason, "BinanceSpot WebSocket closed");
1715 event_tx.take();
1716 if let Some(s) = signal_tx_opt.take() {
1717 let _ = s.send(());
1718 }
1719 }
1720 _ => {}
1721 }
1722 });
1723
1724 if let Some(dt) = disconnect_time.take() {
1731 unrecovered.open(&instruments, dt, Utc::now());
1732 let held = known.lock().instruments_among(&instruments);
1734 unchecked.open(held);
1735 }
1736 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known).await;
1737 recover_spot_ended_orders(
1738 &rest,
1739 &rate_limiter,
1740 &known,
1741 &mut unchecked,
1742 &unrecovered,
1743 &tx,
1744 )
1745 .await;
1746
1747 enum DisconnectReason {
1749 Signal,
1750 HeartbeatTimeout,
1751 ConsumerDropped,
1752 }
1753 let reason = {
1754 let mut signal_rx = signal_rx;
1755 let retry_gaps = async {
1761 loop {
1762 let next_check = unchecked.next_due(&unrecovered);
1763 match unrecovered.next_due().into_iter().chain(next_check).min() {
1764 Some(due) => tokio::time::sleep_until(due).await,
1765 None => std::future::pending::<()>().await,
1766 }
1767 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known)
1768 .await;
1769 recover_spot_ended_orders(
1770 &rest,
1771 &rate_limiter,
1772 &known,
1773 &mut unchecked,
1774 &unrecovered,
1775 &tx,
1776 )
1777 .await;
1778 }
1779 };
1780 tokio::pin!(retry_gaps);
1781 loop {
1782 tokio::select! {
1783 biased;
1785 _ = tx.closed() => {
1786 debug!("BinanceSpot account_stream consumer dropped, terminating");
1787 break DisconnectReason::ConsumerDropped;
1788 }
1789 _ = &mut signal_rx => {
1790 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1791 break DisconnectReason::Signal;
1792 }
1793 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1794 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1801 backoff.reset();
1805 continue;
1806 }
1807 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1808 break DisconnectReason::HeartbeatTimeout;
1809 }
1810 () = &mut retry_gaps => {}
1812 }
1813 }
1814 };
1815 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1816
1817 if should_reconnect {
1824 disconnect_time = Some(match reason {
1825 DisconnectReason::HeartbeatTimeout => {
1826 Utc::now()
1827 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1828 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1829 }
1830 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1831 });
1832 }
1833
1834 subscription.unsubscribe();
1842 if let Err(e) = ws.disconnect().await {
1845 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1846 }
1847
1848 if !should_reconnect || tx.is_closed() {
1849 debug!("BinanceSpot connection manager exiting");
1852 break;
1853 }
1854 if !backoff.wait().await {
1855 error!("BinanceSpot max reconnect attempts exhausted, stream terminating");
1856 let last_error = match reason {
1859 DisconnectReason::HeartbeatTimeout => {
1860 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
1861 }
1862 _ => "WebSocket disconnected (server close/error)".to_string(),
1863 };
1864 emit_stream_terminated(
1865 &tx,
1866 ExchangeId::BinanceSpot,
1867 StreamTerminationReason::ReconnectBudgetExhausted {
1868 attempts: backoff.attempts(),
1869 last_error,
1870 },
1871 );
1872 break;
1873 }
1874 }
1875}
1876
1877async fn recover_fills(
1894 rest: &Arc<RestApi>,
1895 rate_limiter: &Arc<RateLimitTracker>,
1896 unrecovered: &mut UnrecoveredFills,
1897 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1898 dedup: &SharedDedupCache,
1899 known: &SharedKnownLiveOrders,
1900) {
1901 use futures::StreamExt;
1902
1903 let due = unrecovered.due(tokio::time::Instant::now());
1904 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
1905 return;
1906 };
1907 info!(
1908 gaps = due.len(),
1909 oldest = %gap_time(oldest),
1910 "BinanceSpot recovering fills missed while disconnected"
1911 );
1912
1913 let mut settled = vec![false; due.len()];
1916 let started = AtomicUsize::new(0);
1917 let recovery = async {
1918 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
1919 let mut recovered = 0u32;
1920 let mut duplicates = 0u32;
1921
1922 let mut stream =
1928 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
1929 started.store(index + 1, Ordering::Relaxed);
1930 let rest = rest.clone();
1931 let rl = rate_limiter.clone();
1932 async move {
1933 let span = MyTradesFrom::Span {
1934 start: gap.start_ms,
1935 end: gap.end_ms,
1936 };
1937 let raw =
1938 match paginate_my_trades(&rest, &rl, &inst, span, RequestKind::Essential)
1939 .await
1940 {
1941 Ok(pages) => pages,
1942 Err(e) => return (index, Err(e)),
1943 };
1944 let totals = recovered_order_totals(
1948 ExchangeId::BinanceSpot,
1949 &inst,
1950 &raw,
1951 order_executions_deadline,
1952 |order_id| {
1953 paginate_my_trades(
1954 &rest,
1955 &rl,
1956 &inst,
1957 MyTradesFrom::Order(order_id),
1958 RequestKind::Essential,
1959 )
1960 },
1961 )
1962 .await;
1963 let trades: Vec<_> = raw
1964 .iter()
1965 .filter_map(|t| {
1966 let mut trade = convert_my_trade(t, &inst)?;
1967 trade.order_filled_quantity =
1968 t.id.and_then(|id| totals.get(&id).copied());
1969 Some(trade)
1970 })
1971 .collect();
1972 (index, Ok(trades))
1973 }
1974 }))
1975 .buffer_unordered(8);
1976 while let Some((index, result)) = stream.next().await {
1977 let (inst, gap) = &due[index];
1978 settled[index] = true;
1979 let trades = match result {
1980 Ok(trades) => trades,
1981 Err(e) => {
1982 gap_failed(
1983 unrecovered,
1984 tx,
1985 ExchangeId::BinanceSpot,
1986 inst,
1987 gap,
1988 FillRecoveryFailure::Request(e.to_string()),
1989 );
1990 continue;
1991 }
1992 };
1993 for trade in trades {
1994 let event = UnindexedAccountEvent::new(
1997 ExchangeId::BinanceSpot,
1998 AccountEventKind::Trade(trade),
1999 );
2000 if let Some(key) = dedup_key_from_event(&event)
2003 && is_duplicate(dedup, key)
2004 {
2005 duplicates += 1;
2006 continue;
2007 }
2008 let sent = {
2011 let mut known = known.lock();
2012 known.observe(&event);
2013 tx.send(event)
2014 };
2015 if sent.is_err() {
2016 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
2019 return;
2020 }
2021 recovered += 1;
2022 }
2023 unrecovered.recovered(inst, gap);
2024 }
2025 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
2026 };
2027 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2031 .await
2032 .is_err()
2033 {
2034 let started = started.load(Ordering::Relaxed);
2035 for ((inst, gap), _) in due[..started]
2036 .iter()
2037 .zip(&settled)
2038 .filter(|(_, settled)| !**settled)
2039 {
2040 gap_failed(
2041 unrecovered,
2042 tx,
2043 ExchangeId::BinanceSpot,
2044 inst,
2045 gap,
2046 FillRecoveryFailure::TimedOut {
2047 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2048 },
2049 );
2050 }
2051 }
2052}
2053
2054fn convert_balance_entry(
2068 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
2069 now: chrono::DateTime<Utc>,
2070) -> Option<AssetBalance<AssetNameExchange>> {
2071 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2072 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2073 Some(v) => v,
2074 None => {
2075 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
2076 return None;
2077 }
2078 };
2079 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2080 Some(v) => v,
2081 None => {
2082 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
2083 return None;
2084 }
2085 };
2086 Some(AssetBalance::new(
2087 asset_name,
2088 Balance::new(free + locked, free),
2089 now,
2090 ))
2091}
2092
2093fn filter_and_convert_balances(
2094 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
2095 assets: &[AssetNameExchange],
2096) -> Vec<AssetBalance<AssetNameExchange>> {
2097 let now = Utc::now();
2098 if assets.is_empty() {
2100 return balances
2101 .into_iter()
2102 .filter_map(|b| convert_balance_entry(b, now))
2103 .collect();
2104 }
2105 if assets.len() <= 16 {
2108 return balances
2109 .into_iter()
2110 .filter_map(|b| {
2111 let asset_name_str = b.asset.as_deref()?;
2112 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
2113 return None;
2114 }
2115 convert_balance_entry(b, now)
2116 })
2117 .collect();
2118 }
2119 use std::collections::HashSet;
2120 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
2121 balances
2122 .into_iter()
2123 .filter_map(|b| {
2124 let asset_name_str = b.asset.as_deref()?;
2125 if !asset_set.contains(asset_name_str) {
2126 return None;
2127 }
2128 convert_balance_entry(b, now)
2129 })
2130 .collect()
2131}
2132
2133fn convert_my_trade(
2135 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
2136 instrument: &InstrumentNameExchange,
2137) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
2138 let trade_id_raw = match t.id {
2139 Some(id) => id,
2140 None => {
2141 warn!(%instrument, "BinanceSpot trade missing id");
2142 return None;
2143 }
2144 };
2145 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
2146 let order_id = match t.order_id {
2147 Some(id) => OrderId(format_smolstr!("{}", id)),
2148 None => {
2149 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
2150 return None;
2151 }
2152 };
2153 let side = match t.is_buyer {
2154 Some(is_buyer) => {
2155 if is_buyer {
2156 Side::Buy
2157 } else {
2158 Side::Sell
2159 }
2160 }
2161 None => {
2162 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
2163 return None;
2164 }
2165 };
2166 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2167 Some(v) => v,
2168 None => {
2169 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
2170 return None;
2171 }
2172 };
2173 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2174 Some(v) => v,
2175 None => {
2176 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2177 return None;
2178 }
2179 };
2180 let commission = t
2181 .commission
2182 .as_deref()
2183 .and_then(|s| Decimal::from_str(s).ok())
2184 .unwrap_or(Decimal::ZERO);
2185 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
2186 Some(ts) => ts,
2187 None => {
2188 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2189 Utc::now()
2190 }
2191 };
2192
2193 let fee_asset = t
2198 .commission_asset
2199 .as_deref()
2200 .map(AssetNameExchange::from)
2201 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2202
2203 Some(Trade::new(
2204 trade_id,
2205 order_id,
2206 instrument.clone(),
2207 StrategyId::unknown(), time_exchange,
2209 side,
2210 price,
2211 quantity,
2212 None,
2214 AssetFees::new(fee_asset, commission, None),
2215 ))
2216}
2217
2218fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2243 let (event_type, event) = match parse_user_data_frame(frame) {
2244 UserDataFrame::Response => return false,
2245 UserDataFrame::Event {
2246 event_type, event, ..
2247 } => (event_type, event),
2248 UserDataFrame::Unrecognised => {
2249 static SEEN: AtomicU64 = AtomicU64::new(0);
2250 log_unrecognised_frame("BinanceSpot", &SEEN, frame);
2251 return false;
2252 }
2253 };
2254 match event_type {
2256 "executionReport" => {
2257 match serde_json::from_str::<ExecutionReport>(event) {
2260 Ok(report) => {
2261 convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
2262 }
2263 Err(e) => {
2264 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable executionReport, dropping")
2265 }
2266 }
2267 false
2268 }
2269 "outboundAccountPosition" => {
2270 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2271 event,
2272 ) {
2273 Ok(position) => convert_account_position(position, buf),
2274 Err(e) => {
2275 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2276 }
2277 }
2278 false
2279 }
2280 "balanceUpdate" => {
2281 false
2288 }
2289 "eventStreamTerminated" => {
2290 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2294 true
2295 }
2296 _ => {
2299 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2300 false
2301 }
2302 }
2303}
2304
2305fn convert_account_position(
2310 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2311 buf: &mut Vec<UnindexedAccountEvent>,
2312) {
2313 let time_exchange = position
2316 .u
2317 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2318 .unwrap_or_else(Utc::now);
2319
2320 for b in position.b_uppercase.unwrap_or_default() {
2321 let asset = match b.a {
2322 Some(a) => AssetNameExchange::new(a),
2323 None => {
2324 warn!("BinanceSpot account position entry missing asset name");
2325 continue;
2326 }
2327 };
2328 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2329 Some(v) => v,
2330 None => {
2331 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2332 continue;
2333 }
2334 };
2335 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2336 Some(v) => v,
2337 None => {
2338 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2339 continue;
2340 }
2341 };
2342 let update =
2347 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2348 buf.push(UnindexedAccountEvent::new(
2349 ExchangeId::BinanceSpot,
2350 AccountEventKind::BalanceStreamUpdate(
2351 rustrade_integration::collection::snapshot::Snapshot::new(update),
2352 ),
2353 ));
2354 }
2355}
2356
2357fn convert_order_kind_tif(
2387 kind: OrderKind,
2388 tif: TimeInForce,
2389) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2390 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2394 let type_enum = match binance_type {
2395 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2396 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2397 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2398 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2399 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2400 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2401 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2402 };
2403 let tif_enum = binance_tif.map(|t| match t {
2404 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2405 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2406 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2407 });
2408 Some((type_enum, tif_enum))
2409}
2410
2411#[cfg(test)]
2412#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2414 use super::*;
2415 use crate::client::binance::shared::*;
2416 use crate::client::dedup::{DedupEventKind, DedupKey};
2417 use crate::order::{TrailingOffsetType, id::ClientOrderId};
2418 use binance_sdk::common::errors::WebsocketError;
2419 use smol_str::SmolStr;
2420
2421 #[test]
2422 fn test_binance_spot_config_new_uses_testnet_by_default() {
2423 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2424 assert!(cfg.testnet);
2425 assert_eq!(cfg.api_key(), "my_key");
2426 }
2427
2428 #[test]
2429 fn test_binance_spot_config_production_is_explicit() {
2430 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2431 assert!(!cfg.testnet);
2432 }
2433
2434 #[test]
2435 fn test_binance_spot_config_debug_redacts_credentials() {
2436 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2437 let debug = format!("{cfg:?}");
2438
2439 assert!(!debug.contains("my_key"), "api_key should be redacted");
2440 assert!(
2441 !debug.contains("my_secret"),
2442 "secret_key should be redacted"
2443 );
2444 assert!(debug.contains("testnet: true"));
2445 }
2446
2447 #[test]
2448 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2449 let cfg: BinanceSpotConfig = serde_json::from_str(
2450 r#"{
2451 "api_key": "my_key",
2452 "secret_key": "my_secret"
2453 }"#,
2454 )
2455 .unwrap();
2456
2457 assert!(cfg.testnet);
2458 assert_eq!(cfg.api_key(), "my_key");
2459 }
2460
2461 #[test]
2462 fn test_binance_spot_config_deserialize_testnet_true() {
2463 let cfg: BinanceSpotConfig = serde_json::from_str(
2464 r#"{
2465 "api_key": "my_key",
2466 "secret_key": "my_secret",
2467 "testnet": true
2468 }"#,
2469 )
2470 .unwrap();
2471
2472 assert!(cfg.testnet);
2473 }
2474
2475 #[test]
2476 fn test_binance_spot_config_deserialize_testnet_false() {
2477 let cfg: BinanceSpotConfig = serde_json::from_str(
2478 r#"{
2479 "api_key": "my_key",
2480 "secret_key": "my_secret",
2481 "testnet": false
2482 }"#,
2483 )
2484 .unwrap();
2485
2486 assert!(!cfg.testnet);
2487 }
2488
2489 #[test]
2490 #[serial_test::serial]
2491 fn test_binance_spot_config_from_env_defaults_to_testnet() {
2492 temp_env::with_vars(
2493 [
2494 ("BINANCE_API_KEY", Some("my_key")),
2495 ("BINANCE_SECRET_KEY", Some("my_secret")),
2496 ("BINANCE_TESTNET", None),
2497 ],
2498 || {
2499 let cfg = BinanceSpotConfig::from_env().unwrap();
2500 assert!(cfg.testnet);
2501 assert_eq!(cfg.api_key(), "my_key");
2502 },
2503 );
2504 }
2505
2506 #[test]
2507 #[serial_test::serial]
2508 fn test_binance_spot_config_from_env_accepts_explicit_production() {
2509 temp_env::with_vars(
2510 [
2511 ("BINANCE_API_KEY", Some("my_key")),
2512 ("BINANCE_SECRET_KEY", Some("my_secret")),
2513 ("BINANCE_TESTNET", Some("false")),
2514 ],
2515 || {
2516 let cfg = BinanceSpotConfig::from_env().unwrap();
2517 assert!(!cfg.testnet);
2518 },
2519 );
2520 }
2521
2522 #[test]
2523 #[serial_test::serial]
2524 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2525 temp_env::with_vars(
2526 [
2527 ("BINANCE_API_KEY", Some("my_key")),
2528 ("BINANCE_SECRET_KEY", Some("my_secret")),
2529 ("BINANCE_TESTNET", Some("maybe")),
2530 ],
2531 || {
2532 let err = BinanceSpotConfig::from_env().unwrap_err();
2533 assert!(
2534 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2535 );
2536 },
2537 );
2538 }
2539
2540 #[test]
2541 #[serial_test::serial]
2542 fn test_binance_spot_config_from_env_requires_credentials() {
2543 temp_env::with_vars(
2544 [
2545 ("BINANCE_API_KEY", None),
2546 ("BINANCE_SECRET_KEY", Some("my_secret")),
2547 ("BINANCE_TESTNET", None),
2548 ],
2549 || {
2550 let err = BinanceSpotConfig::from_env().unwrap_err();
2551 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2552 },
2553 );
2554 }
2555
2556 #[test]
2557 fn test_parse_side() {
2558 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2559 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2560 assert_eq!(parse_side("buy"), None);
2561 assert_eq!(parse_side("UNKNOWN"), None);
2562 }
2563
2564 #[test]
2565 fn test_parse_time_in_force() {
2566 assert_eq!(
2567 parse_time_in_force("GTC"),
2568 TimeInForce::GoodUntilCancelled { post_only: false }
2569 );
2570 assert_eq!(
2571 parse_time_in_force("GTX"),
2572 TimeInForce::GoodUntilCancelled { post_only: true }
2573 );
2574 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2575 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2576 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2577 assert_eq!(
2579 parse_time_in_force("UNKNOWN"),
2580 TimeInForce::GoodUntilCancelled { post_only: false }
2581 );
2582 }
2583
2584 #[test]
2585 fn test_convert_order_kind_tif() {
2586 use rust_decimal::Decimal;
2587
2588 assert!(matches!(
2590 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2591 Some((OrderPlaceTypeEnum::Market, None))
2592 ));
2593 assert!(matches!(
2594 convert_order_kind_tif(
2595 OrderKind::Limit,
2596 TimeInForce::GoodUntilCancelled { post_only: false }
2597 ),
2598 Some((
2599 OrderPlaceTypeEnum::Limit,
2600 Some(OrderPlaceTimeInForceEnum::Gtc)
2601 ))
2602 ));
2603 assert!(matches!(
2604 convert_order_kind_tif(
2605 OrderKind::Limit,
2606 TimeInForce::GoodUntilCancelled { post_only: true }
2607 ),
2608 Some((OrderPlaceTypeEnum::LimitMaker, None))
2609 ));
2610 assert!(matches!(
2611 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2612 Some((
2613 OrderPlaceTypeEnum::Limit,
2614 Some(OrderPlaceTimeInForceEnum::Fok)
2615 ))
2616 ));
2617 assert!(matches!(
2618 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2619 Some((
2620 OrderPlaceTypeEnum::Limit,
2621 Some(OrderPlaceTimeInForceEnum::Ioc)
2622 ))
2623 ));
2624 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2627
2628 assert!(matches!(
2630 convert_order_kind_tif(
2631 OrderKind::Stop {
2632 trigger_price: Decimal::from(100)
2633 },
2634 TimeInForce::GoodUntilCancelled { post_only: false }
2635 ),
2636 Some((OrderPlaceTypeEnum::StopLoss, None))
2637 ));
2638 assert!(matches!(
2639 convert_order_kind_tif(
2640 OrderKind::StopLimit {
2641 trigger_price: Decimal::from(100)
2642 },
2643 TimeInForce::GoodUntilCancelled { post_only: false }
2644 ),
2645 Some((
2646 OrderPlaceTypeEnum::StopLossLimit,
2647 Some(OrderPlaceTimeInForceEnum::Gtc)
2648 ))
2649 ));
2650 assert!(matches!(
2651 convert_order_kind_tif(
2652 OrderKind::TakeProfit {
2653 trigger_price: Decimal::from(150)
2654 },
2655 TimeInForce::ImmediateOrCancel
2656 ),
2657 Some((OrderPlaceTypeEnum::TakeProfit, None))
2658 ));
2659 assert!(matches!(
2660 convert_order_kind_tif(
2661 OrderKind::TakeProfitLimit {
2662 trigger_price: Decimal::from(150)
2663 },
2664 TimeInForce::FillOrKill
2665 ),
2666 Some((
2667 OrderPlaceTypeEnum::TakeProfitLimit,
2668 Some(OrderPlaceTimeInForceEnum::Fok)
2669 ))
2670 ));
2671
2672 assert!(matches!(
2674 convert_order_kind_tif(
2675 OrderKind::TrailingStop {
2676 offset: Decimal::from(100),
2677 offset_type: TrailingOffsetType::BasisPoints,
2678 },
2679 TimeInForce::GoodUntilCancelled { post_only: false }
2680 ),
2681 Some((OrderPlaceTypeEnum::StopLoss, None))
2682 ));
2683 assert!(matches!(
2684 convert_order_kind_tif(
2685 OrderKind::TrailingStop {
2686 offset: Decimal::from(5),
2687 offset_type: TrailingOffsetType::Percentage,
2688 },
2689 TimeInForce::GoodUntilCancelled { post_only: false }
2690 ),
2691 Some((OrderPlaceTypeEnum::StopLoss, None))
2692 ));
2693
2694 assert!(
2696 convert_order_kind_tif(
2697 OrderKind::TrailingStop {
2698 offset: Decimal::from(10),
2699 offset_type: TrailingOffsetType::Absolute,
2700 },
2701 TimeInForce::GoodUntilCancelled { post_only: false }
2702 )
2703 .is_none()
2704 );
2705
2706 assert!(
2708 convert_order_kind_tif(
2709 OrderKind::TrailingStopLimit {
2710 offset: Decimal::from(100),
2711 offset_type: TrailingOffsetType::BasisPoints,
2712 stop_price: Decimal::from(95),
2713 limit_offset: Decimal::from(10),
2714 },
2715 TimeInForce::GoodUntilCancelled { post_only: false }
2716 )
2717 .is_none()
2718 );
2719 }
2720
2721 #[test]
2722 fn test_parse_binance_api_error() {
2723 let instrument = InstrumentNameExchange::new("BTCUSDT");
2724
2725 assert!(matches!(
2726 parse_binance_api_error("Insufficient balance".into(), &instrument),
2727 ApiError::BalanceInsufficient(None, _)
2728 ));
2729 assert!(matches!(
2730 parse_binance_api_error("Not enough funds".into(), &instrument),
2731 ApiError::BalanceInsufficient(None, _)
2732 ));
2733 assert_eq!(
2734 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2735 ApiError::RateLimit
2736 );
2737 assert_eq!(
2738 parse_binance_api_error("Error code -1015".into(), &instrument),
2739 ApiError::RateLimit
2740 );
2741 assert_eq!(
2743 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2744 ApiError::RateLimit
2745 );
2746 assert_eq!(
2748 parse_binance_api_error("Error code -2011".into(), &instrument),
2749 ApiError::OrderAlreadyCancelled
2750 );
2751 assert!(matches!(
2753 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2754 ApiError::OrderAlreadyCancelled
2755 ));
2756 assert!(matches!(
2758 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2759 ApiError::OrderAlreadyCancelled
2760 ));
2761 assert!(matches!(
2763 parse_binance_api_error("Unknown order encountered".into(), &instrument),
2764 ApiError::OrderRejected(_)
2765 ));
2766 assert!(matches!(
2767 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2768 ApiError::InstrumentInvalid(_, _)
2769 ));
2770 assert!(matches!(
2772 parse_binance_api_error(
2773 "Server-side response error (code -2010): Account has insufficient balance".into(),
2774 &instrument
2775 ),
2776 ApiError::BalanceInsufficient(None, _)
2777 ));
2778 assert!(matches!(
2779 parse_binance_api_error(
2780 "Server-side response error (code -2010): Order would trigger immediately.".into(),
2781 &instrument
2782 ),
2783 ApiError::OrderRejected(_)
2784 ));
2785 assert!(matches!(
2786 parse_binance_api_error("Some other error".into(), &instrument),
2787 ApiError::OrderRejected(_)
2788 ));
2789 }
2790
2791 #[test]
2792 fn test_contains_error_code_suffix_guard() {
2793 assert!(
2795 !contains_error_code("-20130", "-2013"),
2796 "-20130 should not match -2013"
2797 );
2798 assert!(
2799 !contains_error_code("-20131", "-2013"),
2800 "-20131 should not match -2013"
2801 );
2802 assert!(contains_error_code("-2013", "-2013"), "exact match");
2804 assert!(
2805 contains_error_code("Error -2013: text", "-2013"),
2806 "match with trailing text"
2807 );
2808 }
2809
2810 #[test]
2811 fn test_contains_error_code_prefix_guard() {
2812 assert!(
2815 !contains_error_code("1-2013", "-2013"),
2816 "1-2013 should not match -2013"
2817 );
2818 assert!(
2819 !contains_error_code("error 1-2013 text", "-2013"),
2820 "embedded 1-2013 should not match"
2821 );
2822 assert!(
2824 contains_error_code("code=-2013,", "-2013"),
2825 "=-2013 prefix should match"
2826 );
2827 assert!(
2828 contains_error_code(" -2013 ", "-2013"),
2829 "space prefix should match"
2830 );
2831 }
2832
2833 #[test]
2834 fn test_contains_error_code_second_occurrence_valid() {
2835 assert!(
2838 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
2839 "second valid occurrence must match when first fails digit guard"
2840 );
2841 assert!(
2843 contains_error_code("-2013 or -20130", "-2013"),
2844 "first valid occurrence must match when second is a longer code"
2845 );
2846 }
2847
2848 #[test]
2849 fn test_dedup_cache() {
2850 let cache = new_dedup_cache();
2851 let key = DedupKey {
2852 instrument: SmolStr::from("BTCUSDT"),
2853 id: SmolStr::from("12345"),
2854 kind: DedupEventKind::Trade,
2855 };
2856
2857 assert!(!is_duplicate(&cache, key));
2859 let key = DedupKey {
2861 instrument: SmolStr::from("BTCUSDT"),
2862 id: SmolStr::from("12345"),
2863 kind: DedupEventKind::Trade,
2864 };
2865 assert!(is_duplicate(&cache, key));
2866
2867 let key2 = DedupKey {
2869 instrument: SmolStr::from("BTCUSDT"),
2870 id: SmolStr::from("12345"),
2871 kind: DedupEventKind::OrderState {
2872 filled_quantity: Decimal::ZERO,
2873 },
2874 };
2875 assert!(!is_duplicate(&cache, key2));
2876 }
2877
2878 #[test]
2882 fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
2883 let cache = new_dedup_cache();
2884 let state = |filled: &str| DedupKey {
2885 instrument: SmolStr::from("BTCUSDT"),
2886 id: SmolStr::from("12345"),
2887 kind: DedupEventKind::OrderState {
2888 filled_quantity: Decimal::from_str(filled).unwrap(),
2889 },
2890 };
2891
2892 assert!(!is_duplicate(&cache, state("0")), "ack is new");
2894 assert!(
2895 !is_duplicate(&cache, state("1")),
2896 "first fill is not the ack"
2897 );
2898 assert!(
2899 !is_duplicate(&cache, state("2")),
2900 "second fill is not the first"
2901 );
2902
2903 assert!(
2906 is_duplicate(&cache, state("0")),
2907 "replayed ack is a duplicate"
2908 );
2909 assert!(
2910 is_duplicate(&cache, state("2")),
2911 "replayed fill is a duplicate"
2912 );
2913
2914 assert!(
2916 is_duplicate(&cache, state("2.00")),
2917 "2.00 must be the same key as 2"
2918 );
2919 }
2920
2921 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
2928 use crate::client::dedup::dedup_key_from_event;
2929
2930 let cache = new_dedup_cache();
2931 let mut delivered = Vec::new();
2932 let mut buf = Vec::new();
2933 for frame in frames {
2934 let _ = convert_user_data_events(frame, &mut buf);
2935 for ev in buf.drain(..) {
2936 if let Some(key) = dedup_key_from_event(&ev)
2937 && is_duplicate(&cache, key)
2938 {
2939 continue;
2940 }
2941 delivered.push(ev);
2942 }
2943 }
2944 delivered
2945 }
2946
2947 const WS_NEW: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport","s":"BTCUSDT",
2949 "i":12345,"c":"client-1","x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2",
2950 "p":"100","z":"0","T":1700000000000}}"#;
2951
2952 const WS_PARTIAL_FILL: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport",
2953 "s":"BTCUSDT","i":12345,"c":"client-1","x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY",
2954 "o":"LIMIT","f":"GTC","q":"2","p":"100","z":"1","l":"1","L":"100","t":555,"n":"0.1",
2955 "N":"USDT","T":1700000001000}}"#;
2956
2957 #[test]
2965 fn ws_fill_snapshot_survives_the_dedup_gate() {
2966 use crate::order::state::ActiveOrderState;
2967
2968 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
2969
2970 assert_eq!(
2971 delivered.len(),
2972 3,
2973 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
2974 );
2975
2976 let snapshots: Vec<Decimal> = delivered
2977 .iter()
2978 .filter_map(|ev| match &ev.kind {
2979 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
2980 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
2981 _ => None,
2982 },
2983 _ => None,
2984 })
2985 .collect();
2986
2987 assert_eq!(
2988 snapshots,
2989 vec![Decimal::ZERO, Decimal::ONE],
2990 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
2991 );
2992 }
2993
2994 #[test]
2997 fn ws_replayed_frame_is_still_deduplicated() {
2998 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
2999
3000 assert_eq!(
3001 delivered.len(),
3002 3,
3003 "re-delivering both frames must add nothing, got: {delivered:?}"
3004 );
3005 }
3006
3007 #[test]
3008 fn test_is_rate_limit_error() {
3009 assert!(is_rate_limit_error(&anyhow::anyhow!(
3011 "Too many requests. You are being rate-limited. Please slow down."
3012 )));
3013 assert!(is_rate_limit_error(&anyhow::anyhow!(
3015 "The IP address has been banned for exceeding rate limits. Contact support."
3016 )));
3017 assert!(is_rate_limit_error(&anyhow::anyhow!(
3019 "Error -1015: too many new orders"
3020 )));
3021 assert!(is_rate_limit_error(&anyhow::anyhow!(
3022 "Error -1003: too many requests"
3023 )));
3024 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
3026 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
3027 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
3028 assert!(
3030 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
3031 "-10150 should not match -1015"
3032 );
3033 assert!(
3034 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
3035 "-10030 should not match -1003"
3036 );
3037 }
3038
3039 #[test]
3040 fn test_classify_ws_order_error_recognises_venue_responses_only() {
3041 let instrument = InstrumentNameExchange::new("BTCUSDT");
3042
3043 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
3045 code: -2010,
3046 message: "Account has insufficient balance for requested action.".into(),
3047 });
3048 assert!(
3049 matches!(
3050 classify_ws_order_error(&rejection, &instrument),
3051 Some(OrderError::Rejected(ApiError::BalanceInsufficient(None, _)))
3052 ),
3053 "ResponseError should be classified as a venue response"
3054 );
3055
3056 let transport = anyhow::anyhow!("connection reset by peer");
3058 assert!(
3059 classify_ws_order_error(&transport, &instrument).is_none(),
3060 "plain transport error should not be classified as a venue response"
3061 );
3062
3063 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
3065 assert!(
3066 classify_ws_order_error(&rate_limit, &instrument).is_none(),
3067 "rate-limit string error should not be classified as a venue response"
3068 );
3069 }
3070
3071 #[test]
3072 fn test_dedup_key_from_event_trade_includes_instrument() {
3073 use crate::order::id::{OrderId, StrategyId};
3074 use crate::trade::{AssetFees, Trade, TradeId};
3075 use chrono::Utc;
3076 use rust_decimal::Decimal;
3077 use rustrade_instrument::Side;
3078
3079 let instrument = InstrumentNameExchange::new("BTCUSDT");
3080 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3081 TradeId::new("9001"),
3082 OrderId::new("4242"),
3083 instrument.clone(),
3084 StrategyId::unknown(),
3085 Utc::now(),
3086 Side::Buy,
3087 Decimal::ZERO,
3088 Decimal::ZERO,
3089 None,
3090 AssetFees::new(
3091 AssetNameExchange::from("USDT"),
3092 Decimal::ZERO,
3093 Some(Decimal::ZERO),
3094 ),
3095 );
3096 let event =
3097 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
3098
3099 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
3100 assert_eq!(key.kind, DedupEventKind::Trade);
3101 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
3102 assert_eq!(
3103 key.instrument.as_str(),
3104 "BTCUSDT",
3105 "key.instrument should be the symbol"
3106 );
3107
3108 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
3110 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3111 TradeId::new("9001"),
3112 OrderId::new("7777"),
3113 instrument2,
3114 StrategyId::unknown(),
3115 Utc::now(),
3116 Side::Buy,
3117 Decimal::ZERO,
3118 Decimal::ZERO,
3119 None,
3120 AssetFees::new(
3121 AssetNameExchange::from("USDT"),
3122 Decimal::ZERO,
3123 Some(Decimal::ZERO),
3124 ),
3125 );
3126 let event2 =
3127 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
3128 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
3129 assert_ne!(
3130 key, key2,
3131 "same trade ID on different symbols must produce distinct keys"
3132 );
3133 }
3134
3135 fn make_balance_inner(
3140 asset: &str,
3141 free: &str,
3142 locked: &str,
3143 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3144 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3145 a: Some(asset.to_string()),
3146 f: Some(free.to_string()),
3147 l: Some(locked.to_string()),
3148 }
3149 }
3150
3151 #[test]
3152 fn test_convert_account_position_happy_path() {
3153 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3154 u: Some(1_700_000_000_000),
3155 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
3156 ..Default::default()
3157 };
3158 let mut buf = Vec::new();
3159 convert_account_position(position, &mut buf);
3160
3161 assert_eq!(buf.len(), 1);
3162 match &buf[0].kind {
3163 AccountEventKind::BalanceStreamUpdate(snap) => {
3164 let update = &snap.0;
3165 assert_eq!(update.asset.as_ref(), "BTC");
3166 let expected_free = Decimal::from_str("1.5").unwrap();
3168 let expected_locked = Decimal::from_str("0.5").unwrap();
3169 assert_eq!(update.update.free, expected_free);
3170 assert_eq!(update.update.locked, expected_locked);
3171 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3172 }
3173 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3174 }
3175 }
3176
3177 #[test]
3178 fn test_convert_account_position_u_field_none_uses_now() {
3179 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3181 u: None,
3182 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3183 ..Default::default()
3184 };
3185 let mut buf = Vec::new();
3186 convert_account_position(position, &mut buf);
3187 assert_eq!(buf.len(), 1);
3188 }
3189
3190 #[test]
3191 fn test_convert_account_position_missing_asset_name_skipped() {
3192 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3193 u: Some(1_700_000_000_000),
3194 b_uppercase: Some(vec![
3195 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3196 a: None, f: Some("1.0".to_string()),
3198 l: Some("0.0".to_string()),
3199 },
3200 make_balance_inner("USDT", "100.0", "0.0"), ]),
3202 ..Default::default()
3203 };
3204 let mut buf = Vec::new();
3205 convert_account_position(position, &mut buf);
3206 assert_eq!(buf.len(), 1);
3208 match &buf[0].kind {
3209 AccountEventKind::BalanceStreamUpdate(snap) => {
3210 assert_eq!(snap.0.asset.as_ref(), "USDT");
3211 }
3212 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3213 }
3214 }
3215
3216 #[test]
3217 fn test_convert_account_position_unparseable_free_skipped() {
3218 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3219 u: Some(1_700_000_000_000),
3220 b_uppercase: Some(vec![
3221 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3222 a: Some("BTC".to_string()),
3223 f: Some("not-a-number".to_string()),
3224 l: Some("0.0".to_string()),
3225 },
3226 make_balance_inner("ETH", "1.0", "0.0"), ]),
3228 ..Default::default()
3229 };
3230 let mut buf = Vec::new();
3231 convert_account_position(position, &mut buf);
3232 assert_eq!(buf.len(), 1);
3234 match &buf[0].kind {
3235 AccountEventKind::BalanceStreamUpdate(snap) => {
3236 assert_eq!(snap.0.asset.as_ref(), "ETH");
3237 }
3238 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3239 }
3240 }
3241
3242 #[test]
3243 fn test_convert_account_position_empty_balances() {
3244 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3245 u: Some(1_700_000_000_000),
3246 b_uppercase: Some(vec![]),
3247 ..Default::default()
3248 };
3249 let mut buf = Vec::new();
3250 convert_account_position(position, &mut buf);
3251 assert!(
3252 buf.is_empty(),
3253 "empty balance list should produce no events"
3254 );
3255 }
3256
3257 #[test]
3258 fn test_convert_account_position_b_field_none() {
3259 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3260 u: Some(1_700_000_000_000),
3261 b_uppercase: None, ..Default::default()
3263 };
3264 let mut buf = Vec::new();
3265 convert_account_position(position, &mut buf);
3266 assert!(buf.is_empty(), "None B field should produce no events");
3267 }
3268
3269 #[tokio::test]
3271 async fn test_exponential_backoff_exhaustion() {
3272 tokio::time::pause(); let mut backoff = ExponentialBackoff::new();
3274
3275 for i in 0..MAX_RECONNECT_ATTEMPTS {
3277 assert!(
3278 backoff.wait().await,
3279 "expected true on attempt {i} (before exhaustion)"
3280 );
3281 }
3282
3283 assert!(!backoff.wait().await, "expected false after max attempts");
3285 }
3286
3287 #[tokio::test]
3288 async fn test_exponential_backoff_reset() {
3289 tokio::time::pause();
3290 let mut backoff = ExponentialBackoff::new();
3291
3292 for _ in 0..MAX_RECONNECT_ATTEMPTS {
3293 backoff.wait().await;
3294 }
3295 assert!(!backoff.wait().await, "should be exhausted");
3296
3297 backoff.reset();
3298 assert!(backoff.wait().await, "should succeed again after reset");
3299 }
3300
3301 fn make_base_report() -> ExecutionReport {
3305 ExecutionReport {
3306 symbol: Some("BTCUSDT".to_string()),
3307 order_id: Some(12345),
3308 client_order_id: Some("client-1".to_string()),
3309 transaction_time: Some(1_700_000_000_000),
3310 ..Default::default()
3311 }
3312 }
3313
3314 fn convert(report: ExecutionReport) -> Vec<UnindexedAccountEvent> {
3316 let mut buf = Vec::new();
3317 convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
3318 buf
3319 }
3320
3321 fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
3326 assert!(
3327 events.len() <= 1,
3328 "expected at most a single event, got: {events:?}"
3329 );
3330 events.pop()
3331 }
3332
3333 #[test]
3334 fn test_convert_execution_report_new() {
3335 let report = ExecutionReport {
3336 execution_type: Some("NEW".to_string()),
3337 side: Some("BUY".to_string()),
3338 order_type: Some("LIMIT".to_string()),
3339 price: Some("50000.00".to_string()),
3340 order_quantity: Some("0.01".to_string()),
3341 time_in_force: Some("GTC".to_string()),
3342 cumulative_filled_quantity: Some("0".to_string()),
3343 ..make_base_report()
3344 };
3345
3346 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3347 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3348 match &event.kind {
3349 AccountEventKind::OrderSnapshot(snap) => {
3350 let order = &snap.0;
3351 assert_eq!(order.side, Side::Buy);
3352 assert_eq!(order.kind, OrderKind::Limit);
3353 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3354 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3355 assert_eq!(order.key.cid.0.as_str(), "client-1");
3356 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3357 }
3358 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3359 }
3360 }
3361
3362 #[test]
3366 fn a_report_without_an_order_type_has_no_order_snapshot() {
3367 let new = ExecutionReport {
3368 execution_type: Some("NEW".to_string()),
3369 side: Some("BUY".to_string()),
3370 price: Some("50000.00".to_string()),
3371 order_quantity: Some("0.01".to_string()),
3372 ..make_base_report()
3373 };
3374 assert!(convert(new).is_empty());
3375
3376 let fill = ExecutionReport {
3377 execution_type: Some("TRADE".to_string()),
3378 order_status: Some("PARTIALLY_FILLED".to_string()),
3379 side: Some("BUY".to_string()),
3380 trade_id: Some(9999),
3381 last_executed_price: Some("50000.00".to_string()),
3382 last_executed_quantity: Some("0.005".to_string()),
3383 commission_amount: Some("0".to_string()),
3384 cumulative_filled_quantity: Some("0.005".to_string()),
3385 order_type: Some("LIMIT".to_string()),
3386 price: Some("50000.00".to_string()),
3387 order_quantity: Some("0.01".to_string()),
3388 ..make_base_report()
3389 };
3390 let kinds = |report| {
3391 convert(report)
3392 .into_iter()
3393 .map(|event| match event.kind {
3394 AccountEventKind::Trade(_) => "trade",
3395 AccountEventKind::OrderSnapshot(_) => "snapshot",
3396 _ => "other",
3397 })
3398 .collect::<Vec<_>>()
3399 };
3400 assert_eq!(kinds(fill.clone()), ["trade", "snapshot"]);
3401 let untyped = ExecutionReport {
3402 order_type: None,
3403 ..fill
3404 };
3405 assert_eq!(kinds(untyped), ["trade"]);
3406 }
3407
3408 #[test]
3409 fn test_convert_execution_report_trade() {
3410 let report = ExecutionReport {
3413 execution_type: Some("TRADE".to_string()),
3414 side: Some("BUY".to_string()),
3415 trade_id: Some(9999),
3416 last_executed_price: Some("50000.00".to_string()),
3417 last_executed_quantity: Some("0.01".to_string()),
3418 commission_amount: Some("0.000001".to_string()),
3419 ..make_base_report()
3420 };
3421
3422 let event = sole_event(convert(report)).expect("TRADE event should produce Some");
3423 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3424 match &event.kind {
3425 AccountEventKind::Trade(trade) => {
3426 assert_eq!(trade.side, Side::Buy);
3427 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3428 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3429 assert_eq!(trade.id.0.as_str(), "9999");
3430 assert_eq!(trade.order_id.0.as_str(), "12345");
3431 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3432 }
3433 other => panic!("TRADE should yield Trade, got {other:?}"),
3434 }
3435 }
3436
3437 fn trade_events(
3439 events: Vec<UnindexedAccountEvent>,
3440 ) -> (
3441 Trade<AssetNameExchange, InstrumentNameExchange>,
3442 crate::order::Order<
3443 ExchangeId,
3444 InstrumentNameExchange,
3445 OrderState<AssetNameExchange, InstrumentNameExchange>,
3446 >,
3447 ) {
3448 let [first, second]: [UnindexedAccountEvent; 2] = events
3449 .try_into()
3450 .expect("a fill report must produce an execution and an order snapshot");
3451 let AccountEventKind::Trade(trade) = first.kind else {
3452 panic!("first event must be the Trade, got {:?}", first.kind);
3453 };
3454 let AccountEventKind::OrderSnapshot(snap) = second.kind else {
3455 panic!(
3456 "second event must be the OrderSnapshot, got {:?}",
3457 second.kind
3458 );
3459 };
3460 (trade, snap.0)
3461 }
3462
3463 #[test]
3467 fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
3468 let report = ExecutionReport {
3469 execution_type: Some("TRADE".to_string()),
3470 order_status: Some("PARTIALLY_FILLED".to_string()),
3471 side: Some("BUY".to_string()),
3472 order_type: Some("LIMIT".to_string()),
3473 price: Some("50000.00".to_string()),
3474 order_quantity: Some("0.01".to_string()),
3475 time_in_force: Some("GTC".to_string()),
3476 trade_id: Some(9999),
3477 last_executed_price: Some("50000.00".to_string()),
3478 last_executed_quantity: Some("0.004".to_string()),
3479 cumulative_filled_quantity: Some("0.004".to_string()),
3480 ..make_base_report()
3481 };
3482
3483 let (trade, order) = trade_events(convert(report));
3484 assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
3485
3486 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3487 else {
3488 panic!("expected an Open snapshot, got {:?}", order.state);
3489 };
3490 assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
3491 assert_eq!(
3492 open.quantity_remaining(order.quantity),
3493 Decimal::from_str("0.006").unwrap()
3494 );
3495 }
3496
3497 #[test]
3500 fn a_filled_report_reports_nothing_left_to_fill() {
3501 let report = ExecutionReport {
3502 execution_type: Some("TRADE".to_string()),
3503 order_status: Some("FILLED".to_string()),
3504 side: Some("BUY".to_string()),
3505 order_type: Some("LIMIT".to_string()),
3506 price: Some("50000.00".to_string()),
3507 order_quantity: Some("0.01".to_string()),
3508 time_in_force: Some("GTC".to_string()),
3509 trade_id: Some(10_000),
3510 last_executed_price: Some("50000.00".to_string()),
3511 last_executed_quantity: Some("0.006".to_string()),
3512 cumulative_filled_quantity: Some("0.01".to_string()),
3513 ..make_base_report()
3514 };
3515
3516 let (_trade, order) = trade_events(convert(report));
3517 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3518 else {
3519 panic!("expected an Open snapshot, got {:?}", order.state);
3520 };
3521 assert!(
3522 open.quantity_remaining(order.quantity).is_zero(),
3523 "a completed order must report nothing left to fill so the engine retires it"
3524 );
3525 }
3526
3527 #[test]
3531 fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
3532 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
3533 let report = ExecutionReport {
3534 execution_type: Some("TRADE".to_string()),
3535 order_status: Some(status.to_string()),
3536 side: Some("BUY".to_string()),
3537 order_type: Some("LIMIT".to_string()),
3538 price: Some("50000.00".to_string()),
3539 order_quantity: Some("0.01".to_string()),
3540 time_in_force: Some("GTC".to_string()),
3541 trade_id: Some(9999),
3542 last_executed_price: Some("50000.00".to_string()),
3543 last_executed_quantity: Some("0.004".to_string()),
3544 cumulative_filled_quantity: Some("0.004".to_string()),
3545 ..make_base_report()
3546 };
3547 let events = convert(report);
3548 assert_eq!(
3549 events.len(),
3550 1,
3551 "status {status} must produce the execution alone, got: {events:?}"
3552 );
3553 assert!(
3554 matches!(events[0].kind, AccountEventKind::Trade(_)),
3555 "the execution must still be reported for status {status}"
3556 );
3557 }
3558 }
3559
3560 #[test]
3561 fn test_convert_execution_report_trade_missing_last_price() {
3562 let report = ExecutionReport {
3564 execution_type: Some("TRADE".to_string()),
3565 side: Some("BUY".to_string()),
3566 trade_id: Some(9999),
3567 last_executed_price: None, last_executed_quantity: Some("0.01".to_string()),
3569 ..make_base_report()
3570 };
3571 assert!(
3572 sole_event(convert(report)).is_none(),
3573 "missing last price (L) should return None"
3574 );
3575 }
3576
3577 #[test]
3578 fn test_convert_execution_report_trade_missing_last_qty() {
3579 let report = ExecutionReport {
3581 execution_type: Some("TRADE".to_string()),
3582 side: Some("BUY".to_string()),
3583 trade_id: Some(9999),
3584 last_executed_price: Some("50000.00".to_string()),
3585 last_executed_quantity: None, ..make_base_report()
3587 };
3588 assert!(
3589 sole_event(convert(report)).is_none(),
3590 "missing last qty (l) should return None"
3591 );
3592 }
3593
3594 #[test]
3595 fn test_convert_execution_report_canceled() {
3596 let report = ExecutionReport {
3597 execution_type: Some("CANCELED".to_string()),
3598 ..make_base_report()
3599 };
3600
3601 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3602 assert!(
3603 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3604 "CANCELED should yield OrderCancelled with Ok state"
3605 );
3606 }
3607
3608 #[test]
3610 fn a_cancel_report_carries_its_fill_or_none() {
3611 let cancelled = |z: Option<&str>| {
3612 let report = ExecutionReport {
3613 execution_type: Some("CANCELED".to_string()),
3614 cumulative_filled_quantity: z.map(str::to_string),
3615 ..make_base_report()
3616 };
3617 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3618 let AccountEventKind::OrderCancelled(response) = event.kind else {
3619 panic!("expected OrderCancelled, got {:?}", event.kind);
3620 };
3621 let Ok(cancelled) = response.state else {
3622 panic!("expected Ok, got {:?}", response.state);
3623 };
3624 cancelled.filled_quantity
3625 };
3626
3627 assert_eq!(cancelled(Some("0.5")), Some(Decimal::new(5, 1)));
3628 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
3629 assert_eq!(cancelled(None), None);
3630 assert_eq!(cancelled(Some("not a number")), None);
3631 }
3632
3633 #[test]
3634 fn test_convert_execution_report_expired() {
3635 let report = ExecutionReport {
3636 execution_type: Some("EXPIRED".to_string()),
3637 ..make_base_report()
3638 };
3639 let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
3640 assert!(
3641 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3642 "EXPIRED should yield OrderCancelled with Ok state"
3643 );
3644 }
3645
3646 #[test]
3647 fn test_convert_execution_report_expired_in_match() {
3648 let report = ExecutionReport {
3649 execution_type: Some("EXPIRED_IN_MATCH".to_string()),
3650 ..make_base_report()
3651 };
3652 let event = sole_event(convert(report)).expect("EXPIRED_IN_MATCH should produce Some");
3653 assert!(
3654 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3655 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3656 );
3657 }
3658
3659 #[test]
3660 fn test_convert_execution_report_rejected() {
3661 let report = ExecutionReport {
3662 execution_type: Some("REJECTED".to_string()),
3663 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
3664 ..make_base_report()
3665 };
3666
3667 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
3668 assert!(
3669 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_err()),
3670 "REJECTED should yield OrderCancelled with Err state"
3671 );
3672 }
3673
3674 #[test]
3675 fn test_convert_execution_report_missing_exec_type() {
3676 let report = ExecutionReport {
3678 symbol: Some("BTCUSDT".to_string()),
3679 ..Default::default()
3680 };
3681 assert!(
3682 sole_event(convert(report)).is_none(),
3683 "missing execution type should return None"
3684 );
3685 }
3686
3687 #[test]
3688 fn test_convert_execution_report_missing_symbol() {
3689 let report = ExecutionReport {
3691 execution_type: Some("NEW".to_string()),
3692 ..Default::default()
3693 };
3694 assert!(
3695 sole_event(convert(report)).is_none(),
3696 "missing symbol should return None"
3697 );
3698 }
3699
3700 #[test]
3701 fn test_convert_execution_report_missing_order_id() {
3702 let report = ExecutionReport {
3704 execution_type: Some("NEW".to_string()),
3705 symbol: Some("BTCUSDT".to_string()),
3706 order_id: None,
3707 ..Default::default()
3708 };
3709 assert!(
3710 sole_event(convert(report)).is_none(),
3711 "missing orderId should return None"
3712 );
3713 }
3714
3715 #[test]
3716 fn test_convert_execution_report_trade_missing_trade_id() {
3717 let report = ExecutionReport {
3719 execution_type: Some("TRADE".to_string()),
3720 trade_id: None,
3721 ..make_base_report()
3722 };
3723 assert!(
3724 sole_event(convert(report)).is_none(),
3725 "TRADE event missing tradeId should return None"
3726 );
3727 }
3728
3729 #[test]
3730 fn test_convert_execution_report_replace_yields_cancelled() {
3731 let report = ExecutionReport {
3732 execution_type: Some("REPLACE".to_string()),
3733 ..make_base_report()
3734 };
3735 let event = sole_event(convert(report)).expect("REPLACE should produce Some");
3738 assert!(
3739 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3740 "REPLACE should yield OrderCancelled with Ok state"
3741 );
3742 }
3743
3744 fn make_balance(
3749 asset: &str,
3750 free: &str,
3751 locked: &str,
3752 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3753 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3754 asset: Some(asset.to_string()),
3755 free: Some(free.to_string()),
3756 locked: Some(locked.to_string()),
3757 }
3758 }
3759
3760 #[test]
3761 fn test_filter_balances_empty_assets_returns_all() {
3762 let balances = vec![
3763 make_balance("BTC", "1.0", "0.0"),
3764 make_balance("USDT", "500.0", "50.0"),
3765 ];
3766 let result = filter_and_convert_balances(balances, &[]);
3767 assert_eq!(result.len(), 2, "empty filter should return all balances");
3768 }
3769
3770 #[test]
3771 fn test_filter_balances_matching_asset_returned() {
3772 let balances = vec![
3773 make_balance("BTC", "1.5", "0.5"),
3774 make_balance("ETH", "10.0", "0.0"),
3775 ];
3776 let assets = vec![AssetNameExchange::new("BTC")];
3777 let result = filter_and_convert_balances(balances, &assets);
3778 assert_eq!(result.len(), 1);
3779 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
3780 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
3782 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
3783 }
3784
3785 #[test]
3786 fn test_filter_balances_non_matching_asset_filtered_out() {
3787 let balances = vec![
3788 make_balance("BTC", "1.0", "0.0"),
3789 make_balance("ETH", "2.0", "0.0"),
3790 ];
3791 let assets = vec![AssetNameExchange::new("USDT")];
3792 let result = filter_and_convert_balances(balances, &assets);
3793 assert!(
3794 result.is_empty(),
3795 "non-matching asset should be filtered out"
3796 );
3797 }
3798
3799 #[test]
3800 fn test_filter_balances_missing_asset_field_skipped() {
3801 let balances = vec![
3802 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3803 asset: None,
3804 free: Some("1.0".to_string()),
3805 locked: Some("0.0".to_string()),
3806 },
3807 make_balance("USDT", "100.0", "0.0"),
3808 ];
3809 let result = filter_and_convert_balances(balances, &[]);
3810 assert_eq!(
3811 result.len(),
3812 1,
3813 "entry with missing asset should be skipped"
3814 );
3815 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
3816 }
3817
3818 #[test]
3819 fn test_filter_balances_unparseable_free_skipped() {
3820 let balances = vec![
3821 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3822 asset: Some("BTC".to_string()),
3823 free: Some("not-a-number".to_string()),
3824 locked: Some("0.0".to_string()),
3825 },
3826 ];
3827 let result = filter_and_convert_balances(balances, &[]);
3828 assert!(
3829 result.is_empty(),
3830 "unparseable free field should be skipped"
3831 );
3832 }
3833
3834 #[test]
3835 fn test_filter_balances_zero_balance_included() {
3836 let balances = vec![
3839 make_balance("BTC", "0.00000000", "0.00000000"),
3840 make_balance("USDT", "100.0", "0.0"),
3841 ];
3842 let result = filter_and_convert_balances(balances, &[]);
3843 assert_eq!(result.len(), 2, "zero-balance entries must be included");
3844 let btc = result
3845 .iter()
3846 .find(|b| b.asset == AssetNameExchange::new("BTC"))
3847 .unwrap();
3848 assert_eq!(btc.balance.total, Decimal::ZERO);
3849 assert_eq!(btc.balance.free, Decimal::ZERO);
3850 }
3851
3852 #[test]
3853 fn test_filter_balances_duplicate_assets_in_response() {
3854 let balances = vec![
3858 make_balance("BTC", "1.0", "0.0"),
3859 make_balance("BTC", "2.0", "0.0"),
3860 ];
3861 let result = filter_and_convert_balances(balances, &[]);
3862 assert_eq!(
3863 result.len(),
3864 2,
3865 "duplicate asset entries produce two AssetBalance entries"
3866 );
3867 }
3868
3869 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
3874 binance_sdk::spot::rest_api::MyTradesResponseInner {
3875 id: Some(9001),
3876 order_id: Some(4242),
3877 price: Some("50000.00".to_string()),
3878 qty: Some("0.01".to_string()),
3879 commission: Some("0.05".to_string()),
3880 time: Some(1_700_000_000_000),
3881 is_buyer: Some(true),
3882 ..Default::default()
3883 }
3884 }
3885
3886 #[test]
3887 fn test_convert_my_trade_happy_path() {
3888 let instrument = InstrumentNameExchange::new("BTCUSDT");
3889 let trade =
3890 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
3891 assert_eq!(trade.instrument, instrument);
3892 assert_eq!(trade.side, Side::Buy);
3893 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3894 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3895 }
3896
3897 #[test]
3898 fn test_convert_my_trade_sell_side() {
3899 let instrument = InstrumentNameExchange::new("BTCUSDT");
3900 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3901 is_buyer: Some(false),
3902 ..make_base_trade()
3903 };
3904 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
3905 assert_eq!(trade.side, Side::Sell);
3906 }
3907
3908 #[test]
3909 fn test_convert_my_trade_missing_id_returns_none() {
3910 let instrument = InstrumentNameExchange::new("BTCUSDT");
3911 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3912 id: None,
3913 ..make_base_trade()
3914 };
3915 assert!(
3916 convert_my_trade(&t, &instrument).is_none(),
3917 "missing id should return None"
3918 );
3919 }
3920
3921 #[test]
3922 fn test_convert_my_trade_missing_order_id_returns_none() {
3923 let instrument = InstrumentNameExchange::new("BTCUSDT");
3924 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3925 order_id: None,
3926 ..make_base_trade()
3927 };
3928 assert!(
3929 convert_my_trade(&t, &instrument).is_none(),
3930 "missing orderId should return None"
3931 );
3932 }
3933
3934 #[test]
3935 fn test_convert_my_trade_missing_is_buyer_returns_none() {
3936 let instrument = InstrumentNameExchange::new("BTCUSDT");
3937 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3938 is_buyer: None,
3939 ..make_base_trade()
3940 };
3941 assert!(
3942 convert_my_trade(&t, &instrument).is_none(),
3943 "missing isBuyer should return None"
3944 );
3945 }
3946
3947 #[test]
3948 fn test_convert_my_trade_commission_none_defaults_to_zero() {
3949 let instrument = InstrumentNameExchange::new("BTCUSDT");
3950 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3951 commission: None,
3952 ..make_base_trade()
3953 };
3954 let trade =
3955 convert_my_trade(&t, &instrument).expect("None commission should still convert");
3956 assert_eq!(trade.fees.fees, Decimal::ZERO);
3957 }
3958
3959 fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3964 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3965 order_id: Some(12345),
3966 client_order_id: Some("cid-abc".to_string()),
3967 side: Some("BUY".to_string()),
3968 r#type: Some("LIMIT".to_string()),
3969 price: Some("50000.00".to_string()),
3970 orig_qty: Some("0.01".to_string()),
3971 executed_qty: Some("0.0".to_string()),
3972 time_in_force: Some("GTC".to_string()),
3973 time: Some(1_700_000_000_000),
3974 status: Some("NEW".to_string()),
3975 ..Default::default()
3976 }
3977 }
3978
3979 #[test]
3980 fn test_convert_open_order_all_orders_response_converts_identically() {
3981 let instrument = InstrumentNameExchange::new("BTCUSDT");
3988 let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3989 order_id: Some(12345),
3990 client_order_id: Some("cid-abc".to_string()),
3991 side: Some("BUY".to_string()),
3992 r#type: Some("LIMIT".to_string()),
3993 price: Some("50000.00".to_string()),
3994 orig_qty: Some("0.01".to_string()),
3995 executed_qty: Some("0.0".to_string()),
3996 time_in_force: Some("GTC".to_string()),
3997 time: Some(1_700_000_000_000),
3998 status: Some("NEW".to_string()),
3999 ..Default::default()
4000 };
4001 let from_all_orders = convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument)
4002 .expect("valid order should convert");
4003 let from_open_orders = convert_open_order(
4004 &make_base_open_order(),
4005 ExchangeId::BinanceSpot,
4006 &instrument,
4007 )
4008 .expect("valid order should convert");
4009 assert_eq!(from_all_orders, from_open_orders);
4010 }
4011
4012 #[test]
4018 fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
4019 let instrument = InstrumentNameExchange::new("BTCUSDT");
4020 for status in [
4021 "CANCELED",
4022 "FILLED",
4023 "EXPIRED",
4024 "EXPIRED_IN_MATCH",
4025 "REJECTED",
4026 "PENDING_CANCEL",
4027 "SOME_FUTURE_STATUS",
4028 ] {
4029 let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4030 order_id: Some(12345),
4031 client_order_id: Some("cid-abc".to_string()),
4032 side: Some("BUY".to_string()),
4033 r#type: Some("LIMIT".to_string()),
4034 price: Some("50000.00".to_string()),
4035 orig_qty: Some("0.01".to_string()),
4036 executed_qty: Some("0.004".to_string()),
4037 time_in_force: Some("GTC".to_string()),
4038 time: Some(1_700_000_000_000),
4039 status: Some(status.to_string()),
4040 ..Default::default()
4041 };
4042 assert_eq!(
4043 convert_open_order(&row, ExchangeId::BinanceSpot, &instrument),
4044 None,
4045 "{status}"
4046 );
4047 }
4048 }
4049
4050 #[test]
4051 fn test_convert_open_order_admits_every_live_status() {
4052 let instrument = InstrumentNameExchange::new("BTCUSDT");
4053 for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
4054 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4055 status: Some(status.to_string()),
4056 ..make_base_open_order()
4057 };
4058 assert!(
4059 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_some(),
4060 "{status}"
4061 );
4062 }
4063 }
4064
4065 #[test]
4070 fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
4071 let instrument = InstrumentNameExchange::new("BTCUSDT");
4072 let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4073 order_id: Some(67890),
4074 client_order_id: Some("cid-def".to_string()),
4075 ..make_base_open_order()
4076 };
4077
4078 let listing = convert_open_order_listing(
4079 &[make_base_open_order(), second()],
4080 ExchangeId::BinanceSpot,
4081 &instrument,
4082 );
4083 assert_eq!(listing.orders.len(), 2);
4084 assert!(listing.complete);
4085
4086 let empty = convert_open_order_listing::<
4087 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
4088 >(&[], ExchangeId::BinanceSpot, &instrument);
4089 assert!(empty.orders.is_empty());
4090 assert!(empty.complete, "no open orders is a complete answer");
4091
4092 let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4093 side: None,
4094 ..second()
4095 };
4096 let listing = convert_open_order_listing(
4097 &[make_base_open_order(), dropped],
4098 ExchangeId::BinanceSpot,
4099 &instrument,
4100 );
4101 assert_eq!(listing.orders.len(), 1);
4102 assert!(!listing.complete, "a dropped row may be a live order");
4103
4104 let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4105 client_order_id: None,
4106 ..second()
4107 };
4108 let listing = convert_open_order_listing(
4109 &[make_base_open_order(), without_cid],
4110 ExchangeId::BinanceSpot,
4111 &instrument,
4112 );
4113 assert_eq!(listing.orders.len(), 2, "the row is still converted");
4114 assert!(
4115 !listing.complete,
4116 "an order kept under its orderId cannot be found under the cid it was placed with"
4117 );
4118 }
4119
4120 #[test]
4121 fn test_convert_open_order_missing_status_returns_none() {
4122 let instrument = InstrumentNameExchange::new("BTCUSDT");
4123 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4124 status: None,
4125 ..make_base_open_order()
4126 };
4127 assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none());
4128 }
4129
4130 #[test]
4136 fn test_convert_open_order_prefers_update_time_over_creation_time() {
4137 let instrument = InstrumentNameExchange::new("BTCUSDT");
4138
4139 let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4140 update_time: Some(1_700_000_005_000),
4141 ..make_base_open_order()
4142 };
4143 let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument)
4144 .expect("valid order should convert");
4145 assert_eq!(
4146 order.state.time_exchange,
4147 Utc.timestamp_millis_opt(1_700_000_005_000)
4148 .single()
4149 .unwrap(),
4150 "update_time must win over time"
4151 );
4152
4153 let order = convert_open_order(
4156 &make_base_open_order(),
4157 ExchangeId::BinanceSpot,
4158 &instrument,
4159 )
4160 .expect("valid order should convert");
4161 assert_eq!(
4162 order.state.time_exchange,
4163 Utc.timestamp_millis_opt(1_700_000_000_000)
4164 .single()
4165 .unwrap(),
4166 "absent update_time falls back to time"
4167 );
4168 }
4169
4170 #[test]
4171 fn test_convert_open_order_happy_path() {
4172 let instrument = InstrumentNameExchange::new("BTCUSDT");
4173 let order = convert_open_order(
4174 &make_base_open_order(),
4175 ExchangeId::BinanceSpot,
4176 &instrument,
4177 )
4178 .expect("valid order should convert");
4179 assert_eq!(order.key.instrument, instrument);
4180 assert_eq!(order.side, Side::Buy);
4181 assert_eq!(order.kind, OrderKind::Limit);
4182 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
4183 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
4184 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
4185 }
4186
4187 #[test]
4188 fn test_convert_open_order_owned_symbol_recovers_instrument() {
4189 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4191 symbol: Some("ETHUSDT".to_string()),
4192 ..make_base_open_order()
4193 };
4194 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot)
4195 .expect("valid order should convert");
4196 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4197 assert_eq!(order.side, Side::Buy);
4198 }
4199
4200 #[test]
4201 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
4202 let o = make_base_open_order();
4204 assert!(o.symbol.is_none());
4205 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot).is_none());
4206 }
4207
4208 #[test]
4209 fn test_convert_open_order_missing_order_id_returns_none() {
4210 let instrument = InstrumentNameExchange::new("BTCUSDT");
4211 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4212 order_id: None,
4213 ..make_base_open_order()
4214 };
4215 assert!(
4216 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4217 "missing orderId should return None"
4218 );
4219 }
4220
4221 #[test]
4222 fn test_convert_open_order_missing_side_returns_none() {
4223 let instrument = InstrumentNameExchange::new("BTCUSDT");
4224 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4225 side: None,
4226 ..make_base_open_order()
4227 };
4228 assert!(
4229 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4230 "missing side should return None"
4231 );
4232 }
4233
4234 #[test]
4235 fn test_convert_open_order_missing_type_returns_none() {
4236 let instrument = InstrumentNameExchange::new("BTCUSDT");
4237 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4238 r#type: None,
4239 ..make_base_open_order()
4240 };
4241 assert!(
4242 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4243 "missing type should return None"
4244 );
4245 }
4246
4247 #[test]
4248 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
4249 let instrument = InstrumentNameExchange::new("BTCUSDT");
4250 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4251 executed_qty: None,
4252 ..make_base_open_order()
4253 };
4254 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4255 .expect("None executedQty should still convert");
4256 assert_eq!(
4257 order.state.filled_quantity,
4258 Decimal::ZERO,
4259 "None executedQty should default to zero"
4260 );
4261 }
4262
4263 #[test]
4264 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
4265 let instrument = InstrumentNameExchange::new("BTCUSDT");
4266 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4267 executed_qty: Some("bad-value".to_string()),
4268 ..make_base_open_order()
4269 };
4270 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4271 .expect("unparseable executedQty should still convert");
4272 assert_eq!(
4273 order.state.filled_quantity,
4274 Decimal::ZERO,
4275 "unparseable executedQty should default to zero"
4276 );
4277 }
4278
4279 #[test]
4284 fn test_dedup_key_from_event_non_open_states_return_none() {
4285 use crate::order::state::{
4286 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
4287 };
4288 use rustrade_integration::collection::snapshot::Snapshot;
4289
4290 let key = OrderKey::new(
4291 ExchangeId::BinanceSpot,
4292 InstrumentNameExchange::new("BTCUSDT"),
4293 StrategyId::unknown(),
4294 ClientOrderId::new("cid1"),
4295 );
4296
4297 let event = UnindexedAccountEvent::new(
4299 ExchangeId::BinanceSpot,
4300 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4301 key.clone(),
4302 Side::Buy,
4303 None, Decimal::ZERO,
4305 OrderKind::Market,
4306 TimeInForce::ImmediateOrCancel,
4307 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4308 ActiveOrderState::OpenInFlight(OpenInFlight::new(Utc::now())),
4309 ),
4310 ))),
4311 );
4312 assert!(
4313 dedup_key_from_event(&event).is_none(),
4314 "OpenInFlight should return None — dedup not meaningful before exchange ack"
4315 );
4316
4317 let event = UnindexedAccountEvent::new(
4319 ExchangeId::BinanceSpot,
4320 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4321 key.clone(),
4322 Side::Buy,
4323 None, Decimal::ZERO,
4325 OrderKind::Market,
4326 TimeInForce::ImmediateOrCancel,
4327 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4328 ActiveOrderState::CancelInFlight(CancelInFlight::new(None, Utc::now())),
4329 ),
4330 ))),
4331 );
4332 assert!(
4333 dedup_key_from_event(&event).is_none(),
4334 "CancelInFlight should return None"
4335 );
4336
4337 let event = UnindexedAccountEvent::new(
4339 ExchangeId::BinanceSpot,
4340 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4341 key,
4342 Side::Buy,
4343 None, Decimal::ONE,
4345 OrderKind::Market,
4346 TimeInForce::ImmediateOrCancel,
4347 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
4348 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
4349 OrderId::new("123"),
4350 Utc::now(),
4351 Decimal::ONE, None,
4353 )),
4354 ),
4355 ))),
4356 );
4357 assert!(
4358 dedup_key_from_event(&event).is_none(),
4359 "Inactive state should return None"
4360 );
4361 }
4362
4363 #[test]
4364 fn test_dedup_key_from_event_cancelled_error_returns_none() {
4365 let report = ExecutionReport {
4368 execution_type: Some("REJECTED".to_string()),
4369 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
4370 ..make_base_report()
4371 };
4372 let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
4373 assert!(
4374 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
4375 "prerequisite: event is OrderCancelled with Err"
4376 );
4377 assert!(
4378 dedup_key_from_event(&event).is_none(),
4379 "OrderCancelled with Err state should return None"
4380 );
4381 }
4382
4383 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
4393 let mut value = serde_json::to_value(event).expect("event serializes to Value");
4394 value
4395 .as_object_mut()
4396 .expect("event serializes to a JSON object")
4397 .insert(
4398 "e".to_string(),
4399 serde_json::Value::String(event_type.to_string()),
4400 );
4401 serde_json::json!({ "subscriptionId": 0, "event": value }).to_string()
4402 }
4403
4404 #[test]
4405 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
4406 let report = ExecutionReport {
4407 execution_type: Some("NEW".to_string()),
4408 side: Some("BUY".to_string()),
4409 order_type: Some("LIMIT".to_string()),
4410 price: Some("50000.00".to_string()),
4411 order_quantity: Some("0.01".to_string()),
4412 time_in_force: Some("GTC".to_string()),
4413 cumulative_filled_quantity: Some("0".to_string()),
4414 ..make_base_report()
4415 };
4416 let frame = user_data_frame("executionReport", &report);
4417 let mut buf = Vec::new();
4418 let terminated = convert_user_data_events(&frame, &mut buf);
4419 assert!(
4420 !terminated,
4421 "ExecutionReport should not signal stream termination"
4422 );
4423 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
4424 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
4425 }
4426
4427 #[test]
4428 fn test_convert_user_data_events_account_position_pushes_to_buf() {
4429 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4430 u: Some(1_700_000_000_000),
4431 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
4432 ..Default::default()
4433 };
4434 let frame = user_data_frame("outboundAccountPosition", &position);
4435 let mut buf = Vec::new();
4436 let terminated = convert_user_data_events(&frame, &mut buf);
4437 assert!(
4438 !terminated,
4439 "OutboundAccountPosition should not signal stream termination"
4440 );
4441 assert_eq!(
4442 buf.len(),
4443 1,
4444 "OutboundAccountPosition should push one balance event"
4445 );
4446 }
4447
4448 #[test]
4449 fn test_convert_user_data_events_balance_update_ignored() {
4450 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4451 ..Default::default()
4452 };
4453 let frame = user_data_frame("balanceUpdate", &update);
4454 let mut buf = Vec::new();
4455 let terminated = convert_user_data_events(&frame, &mut buf);
4456 assert!(
4457 !terminated,
4458 "BalanceUpdate should not signal stream termination"
4459 );
4460 assert!(buf.is_empty(), "BalanceUpdate should push no events");
4461 }
4462
4463 #[test]
4464 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4465 let frame =
4467 r#"{"subscriptionId":0,"event":{"e":"eventStreamTerminated","E":1700000000000}}"#;
4468 let mut buf = Vec::new();
4469 let terminated = convert_user_data_events(frame, &mut buf);
4470 assert!(
4471 terminated,
4472 "eventStreamTerminated must signal stream termination"
4473 );
4474 assert!(
4475 buf.is_empty(),
4476 "eventStreamTerminated should push no events"
4477 );
4478 }
4479
4480 #[test]
4481 fn test_convert_user_data_events_unknown_event_ignored() {
4482 let frame =
4485 r#"{"subscriptionId":0,"event":{"e":"listStatus","E":1700000000000,"s":"BTCUSDT"}}"#;
4486 let mut buf = Vec::new();
4487 let terminated = convert_user_data_events(frame, &mut buf);
4488 assert!(!terminated, "unknown event must not signal termination");
4489 assert!(buf.is_empty(), "unknown event should push no events");
4490 }
4491
4492 #[test]
4497 fn test_convert_user_data_events_reads_the_ws_api_envelope() {
4498 let fill = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000001000,
4499 "s":"BTCUSDT","c":"client-1","S":"SELL","o":"MARKET","f":"GTC","q":"0.5","p":"0",
4500 "x":"TRADE","X":"FILLED","i":777,"l":"0.5","z":"0.5","L":"40000","n":"0.02",
4501 "N":"USDT","T":1700000001000,"t":888}}"#;
4502 let mut buf = Vec::new();
4503 assert!(!convert_user_data_events(fill, &mut buf));
4504 assert!(
4505 buf.iter()
4506 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
4507 "the enveloped fill must produce its trade, got: {buf:?}"
4508 );
4509
4510 let bare = r#"{"e":"executionReport","s":"BTCUSDT","i":777,"x":"NEW","X":"NEW"}"#;
4512 buf.clear();
4513 assert!(!convert_user_data_events(bare, &mut buf));
4514 assert!(buf.is_empty());
4515 }
4516
4517 #[test]
4518 fn test_convert_user_data_events_non_user_data_frame_ignored() {
4519 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4521 let mut buf = Vec::new();
4522 let terminated = convert_user_data_events(frame, &mut buf);
4523 assert!(
4524 !terminated,
4525 "non-user-data frame must not signal termination"
4526 );
4527 assert!(buf.is_empty(), "non-user-data frame should push no events");
4528 }
4529
4530 #[tokio::test]
4535 async fn test_rate_limit_tracker_not_blocked_initially() {
4536 tokio::time::pause();
4537 let tracker = RateLimitTracker::new(WeightPool::Spot);
4538 tokio::time::timeout(
4540 std::time::Duration::from_millis(1),
4541 tracker.wait_if_blocked(RequestKind::Order),
4542 )
4543 .await
4544 .expect("wait_if_blocked should return immediately when not blocked");
4545 }
4546
4547 #[tokio::test]
4548 async fn test_rate_limit_tracker_blocks_until_deadline() {
4549 tokio::time::pause();
4550 let tracker = RateLimitTracker::new(WeightPool::Spot);
4551 let delay = Duration::from_secs(5);
4552 tracker.on_rate_limited(Some(delay));
4553
4554 assert!(
4556 tokio::time::timeout(
4557 Duration::from_millis(1),
4558 tracker.wait_if_blocked(RequestKind::Order)
4559 )
4560 .await
4561 .is_err(),
4562 "wait_if_blocked should block while cooldown is active"
4563 );
4564
4565 tokio::time::advance(delay + Duration::from_millis(1)).await;
4567 tokio::time::timeout(
4568 Duration::from_millis(1),
4569 tracker.wait_if_blocked(RequestKind::Order),
4570 )
4571 .await
4572 .expect("wait_if_blocked should return after cooldown expires");
4573 }
4574
4575 #[tokio::test]
4576 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
4577 tokio::time::pause();
4578 let tracker = RateLimitTracker::new(WeightPool::Spot);
4579
4580 tracker.on_rate_limited(Some(Duration::from_secs(5)));
4582 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4584
4585 tokio::time::advance(Duration::from_secs(6)).await;
4587 assert!(
4588 tokio::time::timeout(
4589 Duration::from_millis(1),
4590 tracker.wait_if_blocked(RequestKind::Order)
4591 )
4592 .await
4593 .is_err(),
4594 "cooldown should have been extended to 10s"
4595 );
4596
4597 tokio::time::advance(Duration::from_secs(5)).await;
4599 tokio::time::timeout(
4600 Duration::from_millis(1),
4601 tracker.wait_if_blocked(RequestKind::Order),
4602 )
4603 .await
4604 .expect("wait_if_blocked should return after extended cooldown expires");
4605 }
4606
4607 #[tokio::test]
4608 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
4609 tokio::time::pause();
4610 let tracker = RateLimitTracker::new(WeightPool::Spot);
4611
4612 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4614 tracker.on_rate_limited(Some(Duration::from_secs(2)));
4615
4616 tokio::time::advance(Duration::from_secs(3)).await;
4618 assert!(
4619 tokio::time::timeout(
4620 Duration::from_millis(1),
4621 tracker.wait_if_blocked(RequestKind::Order)
4622 )
4623 .await
4624 .is_err(),
4625 "shorter on_rate_limited must not shorten existing cooldown"
4626 );
4627 }
4628
4629 #[test]
4634 fn test_classify_ws_order_error_with_wrapped_error_chain() {
4635 let instrument = InstrumentNameExchange::new("BTCUSDT");
4640 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
4641 code: -2010,
4642 message: "insufficient balance".into(),
4643 });
4644 assert!(
4646 classify_ws_order_error(&raw, &instrument).is_some(),
4647 "unwrapped ResponseError at root must be detected"
4648 );
4649 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
4651 assert!(
4652 classify_ws_order_error(&wrapped, &instrument).is_some(),
4653 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
4654 );
4655 }
4656
4657 #[tokio::test]
4660 async fn rest_used_weight_near_the_limit_pauses_queries() {
4661 for (used, pauses) in [(5_399, false), (5_400, true)] {
4662 let server = wiremock::MockServer::start().await;
4663 wiremock::Mock::given(wiremock::matchers::method("GET"))
4664 .and(wiremock::matchers::path("/api/v3/openOrders"))
4665 .respond_with(
4666 wiremock::ResponseTemplate::new(200)
4667 .insert_header("x-mbx-used-weight-1m", used.to_string())
4668 .set_body_json(serde_json::json!([])),
4669 )
4670 .mount(&server)
4671 .await;
4672 let rest = Arc::new(SpotRestApi::from_config(
4673 ConfigurationRestApi::builder()
4674 .api_key("key")
4675 .api_secret("secret")
4676 .base_path(server.uri())
4677 .build()
4678 .unwrap(),
4679 ));
4680 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4681
4682 fetch_all_open_orders(rest, Arc::clone(&tracker))
4683 .await
4684 .unwrap();
4685
4686 let query_waits = tokio::time::timeout(
4687 Duration::from_millis(50),
4688 tracker.wait_if_blocked(RequestKind::Query),
4689 )
4690 .await
4691 .is_err();
4692 assert_eq!(query_waits, pauses, "used weight {used}");
4693 tokio::time::timeout(
4694 Duration::from_millis(50),
4695 tracker.wait_if_blocked(RequestKind::Order),
4696 )
4697 .await
4698 .expect("orders never wait on the pause");
4699 }
4700 }
4701
4702 fn client_with_ws_api(ws_url: String) -> BinanceSpot {
4704 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4705 "key".into(),
4706 "secret".into(),
4707 ));
4708 client.ws_handle = SpotWsApi::from_config(
4709 ConfigurationWebsocketApi::builder()
4710 .api_key("key")
4711 .api_secret("secret")
4712 .ws_url(ws_url)
4713 .build()
4714 .unwrap(),
4715 );
4716 client
4717 }
4718
4719 fn cancel_request(
4721 instrument: &InstrumentNameExchange,
4722 ) -> OrderRequestCancel<ExchangeId, &InstrumentNameExchange> {
4723 crate::order::OrderEvent {
4724 key: OrderKey {
4725 exchange: ExchangeId::BinanceSpot,
4726 instrument,
4727 strategy: StrategyId::new("strategy"),
4728 cid: ClientOrderId::new("cid"),
4729 },
4730 state: crate::order::request::RequestCancel { id: None },
4731 }
4732 }
4733
4734 async fn ws_server<F, Fut>(serve: F) -> String
4736 where
4737 F: Fn(tokio::net::TcpStream) -> Fut + Send + 'static,
4738 Fut: std::future::Future<Output = ()> + Send + 'static,
4739 {
4740 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4741 let url = format!("ws://{}", listener.local_addr().unwrap());
4742 tokio::spawn(async move {
4743 while let Ok((stream, _)) = listener.accept().await {
4744 tokio::spawn(serve(stream));
4745 }
4746 });
4747 url
4748 }
4749
4750 #[tokio::test]
4753 async fn cancel_without_a_session_during_a_cooldown_is_rejected_unsent() {
4754 let client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4755 "key".into(),
4756 "secret".into(),
4757 ));
4758 client
4759 .rate_limiter
4760 .on_rate_limited(Some(Duration::from_secs(60)));
4761 let instrument = InstrumentNameExchange::new("BTCUSDT");
4762 let response = client.cancel_order(cancel_request(&instrument)).await;
4763
4764 assert!(
4765 matches!(
4766 response.state,
4767 Err(UnindexedOrderError::Rejected(ApiError::RateLimit))
4768 ),
4769 "{:?}",
4770 response.state
4771 );
4772 assert!(
4773 client.ws_api.read().await.is_none(),
4774 "no session was opened"
4775 );
4776 }
4777
4778 #[tokio::test]
4781 async fn a_handshake_refused_with_429_starts_a_cooldown() {
4782 use tokio_tungstenite::tungstenite::http;
4783
4784 let url = ws_server(|stream| async move {
4785 #[allow(clippy::result_large_err)]
4787 let refuse = |_: &http::Request<()>, _| {
4788 Err(http::Response::builder()
4789 .status(http::StatusCode::TOO_MANY_REQUESTS)
4790 .body(None)
4791 .unwrap())
4792 };
4793 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
4794 })
4795 .await;
4796 let client = client_with_ws_api(url);
4797 let instrument = InstrumentNameExchange::new("BTCUSDT");
4798
4799 let response = client.cancel_order(cancel_request(&instrument)).await;
4800
4801 assert!(
4802 matches!(
4803 response.state,
4804 Err(UnindexedOrderError::Rejected(ApiError::RateLimit))
4805 ),
4806 "{:?}",
4807 response.state
4808 );
4809 assert!(client.rate_limiter.is_blocked());
4810 }
4811
4812 #[tokio::test]
4815 async fn a_ws_api_response_near_the_weight_limit_pauses_queries() {
4816 use futures::{SinkExt as _, StreamExt as _};
4817 use tokio_tungstenite::tungstenite::Message;
4818
4819 let url = ws_server(|stream| async move {
4820 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
4821 return;
4822 };
4823 while let Some(Ok(message)) = ws.next().await {
4824 let Message::Text(text) = message else {
4825 continue;
4826 };
4827 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
4828 let response = serde_json::json!({
4829 "id": request["id"],
4830 "status": 200,
4831 "result": {
4832 "symbol": "BTCUSDT",
4833 "origClientOrderId": "cid",
4834 "orderId": 7,
4835 "transactTime": 1_700_000_000_000_i64,
4836 "executedQty": "0",
4837 },
4838 "rateLimits": [{
4839 "rateLimitType": "REQUEST_WEIGHT",
4840 "interval": "MINUTE",
4841 "intervalNum": 1,
4842 "limit": 1_000,
4843 "count": 950,
4844 }],
4845 });
4846 if ws.send(Message::text(response.to_string())).await.is_err() {
4847 return;
4848 }
4849 }
4850 })
4851 .await;
4852 let client = client_with_ws_api(url);
4853 let instrument = InstrumentNameExchange::new("BTCUSDT");
4854
4855 let response = client.cancel_order(cancel_request(&instrument)).await;
4856
4857 assert!(response.state.is_ok(), "{:?}", response.state);
4858 assert_eq!(client.rate_limiter.weight_limit(), 1_000);
4859 assert!(
4860 tokio::time::timeout(
4861 Duration::from_millis(50),
4862 client.rate_limiter.wait_if_blocked(RequestKind::Query),
4863 )
4864 .await
4865 .is_err(),
4866 "queries pause"
4867 );
4868 tokio::time::timeout(
4869 Duration::from_millis(50),
4870 client.rate_limiter.wait_if_blocked(RequestKind::Order),
4871 )
4872 .await
4873 .expect("orders never wait on the pause");
4874 }
4875
4876 #[tokio::test]
4879 async fn fill_recovery_does_not_wait_for_the_weight_pause() {
4880 let server = wiremock::MockServer::start().await;
4881 wiremock::Mock::given(wiremock::matchers::method("GET"))
4882 .and(wiremock::matchers::path("/api/v3/myTrades"))
4883 .respond_with(
4885 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
4886 "symbol": "BTCUSDT",
4887 "id": 1,
4888 "orderId": 7,
4889 "price": "100",
4890 "qty": "1",
4891 "commission": "0",
4892 "commissionAsset": "USDT",
4893 "time": Utc::now().timestamp_millis(),
4894 "isBuyer": true,
4895 "isMaker": false,
4896 }])),
4897 )
4898 .expect(2)
4899 .mount(&server)
4900 .await;
4901 let rest = Arc::new(SpotRestApi::from_config(
4902 ConfigurationRestApi::builder()
4903 .api_key("key")
4904 .api_secret("secret")
4905 .base_path(server.uri())
4906 .build()
4907 .unwrap(),
4908 ));
4909 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4910 tracker.throttle(Duration::from_secs(60));
4911 let (tx, _rx) = mpsc::unbounded_channel();
4912 let mut unrecovered = UnrecoveredFills::default();
4913 unrecovered.open(
4914 &[InstrumentNameExchange::new("BTCUSDT")],
4915 Utc::now() - chrono::Duration::minutes(1),
4916 Utc::now(),
4917 );
4918
4919 tokio::time::timeout(
4921 Duration::from_millis(800),
4922 recover_fills(
4923 &rest,
4924 &tracker,
4925 &mut unrecovered,
4926 &tx,
4927 &new_dedup_cache(),
4928 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
4929 ),
4930 )
4931 .await
4932 .expect("recovery does not wait for the pause");
4933 }
4934
4935 #[tokio::test]
4937 async fn a_gap_whose_every_read_fails_is_reported_when_given_up() {
4938 let disconnect = Utc::now() - chrono::Duration::minutes(10);
4939 let reconnect = Utc::now() - chrono::Duration::minutes(5);
4940 let server = wiremock::MockServer::start().await;
4941 wiremock::Mock::given(wiremock::matchers::method("GET"))
4942 .and(wiremock::matchers::path("/api/v3/myTrades"))
4943 .respond_with(
4944 wiremock::ResponseTemplate::new(400)
4945 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
4946 )
4947 .mount(&server)
4948 .await;
4949 let rest = Arc::new(SpotRestApi::from_config(
4950 ConfigurationRestApi::builder()
4951 .api_key("key")
4952 .api_secret("secret")
4953 .base_path(server.uri())
4954 .build()
4955 .expect("valid config"),
4956 ));
4957 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4958 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4959 let dedup = new_dedup_cache();
4960 let btc = InstrumentNameExchange::new("BTCUSDT");
4961 let mut unrecovered = UnrecoveredFills::default();
4962 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
4963
4964 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
4965 recover_fills(
4966 &rest,
4967 &tracker,
4968 &mut unrecovered,
4969 &tx,
4970 &dedup,
4971 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
4972 )
4973 .await;
4974 unrecovered.make_due();
4975 }
4976
4977 assert!(unrecovered.is_empty(), "the gap is given up");
4978 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
4979 let [event] = forwarded.as_slice() else {
4980 panic!("only the give-up is sent: {forwarded:?}");
4981 };
4982 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
4983 panic!("expected FillRecoveryGaveUp, got {event:?}");
4984 };
4985 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
4986 assert_eq!(
4987 gave_up.scope,
4988 crate::FillRecoveryScope::Instruments(vec![btc])
4989 );
4990 assert_eq!(
4991 gave_up.start.timestamp_millis(),
4992 disconnect.timestamp_millis()
4993 );
4994 assert_eq!(
4995 gave_up.attempts,
4996 crate::client::order_recovery::MAX_GAP_RETRIES + 1
4997 );
4998 assert!(
4999 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
5000 "the last read's error: {:?}",
5001 gave_up.reason
5002 );
5003 }
5004
5005 #[tokio::test]
5008 async fn a_failed_recovery_keeps_its_closed_gap_for_the_retry() {
5009 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5010 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5011 let trade = |id: i64, time: DateTime<Utc>| {
5012 serde_json::json!({
5013 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5014 "commission": "0", "commissionAsset": "USDT", "time": time.timestamp_millis(),
5015 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5016 })
5017 };
5018 let server = wiremock::MockServer::start().await;
5019 wiremock::Mock::given(wiremock::matchers::method("GET"))
5020 .and(wiremock::matchers::path("/api/v3/myTrades"))
5021 .respond_with(
5022 wiremock::ResponseTemplate::new(400)
5023 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5024 )
5025 .up_to_n_times(1)
5026 .with_priority(1)
5027 .mount(&server)
5028 .await;
5029 wiremock::Mock::given(wiremock::matchers::method("GET"))
5030 .and(wiremock::matchers::path("/api/v3/myTrades"))
5031 .respond_with(
5032 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
5033 trade(1, disconnect + chrono::Duration::minutes(1)),
5034 trade(2, Utc::now() - chrono::Duration::minutes(1)),
5035 ])),
5036 )
5037 .mount(&server)
5038 .await;
5039 let rest = Arc::new(SpotRestApi::from_config(
5040 ConfigurationRestApi::builder()
5041 .api_key("key")
5042 .api_secret("secret")
5043 .base_path(server.uri())
5044 .build()
5045 .unwrap(),
5046 ));
5047 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5048 let (tx, mut rx) = mpsc::unbounded_channel();
5049 let dedup = new_dedup_cache();
5050 let mut unrecovered = UnrecoveredFills::default();
5051 unrecovered.open(
5052 &[InstrumentNameExchange::new("BTCUSDT")],
5053 disconnect,
5054 reconnect,
5055 );
5056
5057 recover_fills(
5058 &rest,
5059 &tracker,
5060 &mut unrecovered,
5061 &tx,
5062 &dedup,
5063 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5064 )
5065 .await;
5066 assert!(!unrecovered.is_empty(), "the failed gap is kept");
5067 assert!(rx.try_recv().is_err(), "nothing forwarded");
5068 assert!(
5069 unrecovered.due(tokio::time::Instant::now()).is_empty(),
5070 "and waits for its retry"
5071 );
5072
5073 unrecovered.make_due();
5074 recover_fills(
5075 &rest,
5076 &tracker,
5077 &mut unrecovered,
5078 &tx,
5079 &dedup,
5080 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5081 )
5082 .await;
5083 assert!(unrecovered.is_empty(), "the retry recovered it");
5084 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5085 assert_eq!(
5086 forwarded.len(),
5087 1,
5088 "only the fill inside the gap: {forwarded:?}"
5089 );
5090
5091 let start_times: Vec<String> = server
5092 .received_requests()
5093 .await
5094 .unwrap()
5095 .iter()
5096 .filter_map(|request| {
5097 request
5098 .url
5099 .query_pairs()
5100 .find(|(key, _)| key == "startTime")
5101 .map(|(_, value)| value.into_owned())
5102 })
5103 .collect();
5104 let start = disconnect.timestamp_millis().to_string();
5105 assert_eq!(start_times, [start.clone(), start]);
5106 }
5107
5108 #[tokio::test]
5111 async fn a_span_walk_pages_by_id_and_stops_past_the_end() {
5112 let start = Utc::now().timestamp_millis() - 600_000;
5113 let end = start + 10_000;
5114 let trade = |id: i64, time: i64| {
5115 serde_json::json!({
5116 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5117 "commission": "0", "commissionAsset": "USDT", "time": time,
5118 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5119 })
5120 };
5121 let server = wiremock::MockServer::start().await;
5122 wiremock::Mock::given(wiremock::matchers::method("GET"))
5123 .and(wiremock::matchers::path("/api/v3/myTrades"))
5124 .and(wiremock::matchers::query_param("fromId", "1001"))
5125 .respond_with(
5127 wiremock::ResponseTemplate::new(200).set_body_json(
5128 (1_001..=2_000)
5129 .map(|id| trade(id, if id == 1_001 { end } else { end + 1 }))
5130 .collect::<Vec<_>>(),
5131 ),
5132 )
5133 .with_priority(1)
5134 .mount(&server)
5135 .await;
5136 let full_page: Vec<_> = (1..=1_000).map(|id| trade(id, start + id)).collect();
5137 wiremock::Mock::given(wiremock::matchers::method("GET"))
5138 .and(wiremock::matchers::path("/api/v3/myTrades"))
5139 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(full_page))
5140 .mount(&server)
5141 .await;
5142 let rest = Arc::new(SpotRestApi::from_config(
5143 ConfigurationRestApi::builder()
5144 .api_key("key")
5145 .api_secret("secret")
5146 .base_path(server.uri())
5147 .build()
5148 .unwrap(),
5149 ));
5150
5151 let read = paginate_my_trades(
5152 &rest,
5153 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5154 &InstrumentNameExchange::new("BTCUSDT"),
5155 MyTradesFrom::Span { start, end },
5156 RequestKind::Query,
5157 )
5158 .await
5159 .unwrap();
5160
5161 let ids: Vec<_> = read.iter().filter_map(|t| t.id).collect();
5162 assert_eq!(ids, (1..=1_001).collect::<Vec<_>>());
5163 assert_eq!(server.received_requests().await.unwrap().len(), 2);
5164 }
5165
5166 #[tokio::test]
5169 async fn fetch_trades_returns_only_the_span() {
5170 let start = DateTime::from_timestamp_millis(
5172 (Utc::now() - chrono::Duration::minutes(10)).timestamp_millis(),
5173 )
5174 .unwrap()
5175 + chrono::Duration::microseconds(500);
5176 let end = start + chrono::Duration::seconds(10);
5177 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
5178 let trade = |id: i64, time: i64| {
5179 serde_json::json!({
5180 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5181 "commission": "0", "commissionAsset": "USDT", "time": time,
5182 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5183 })
5184 };
5185 let server = wiremock::MockServer::start().await;
5186 wiremock::Mock::given(wiremock::matchers::method("GET"))
5187 .and(wiremock::matchers::path("/api/v3/myTrades"))
5188 .and(wiremock::matchers::query_param(
5189 "startTime",
5190 start_ms.to_string(),
5191 ))
5192 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(vec![
5193 trade(1, start_ms),
5194 trade(2, start_ms + 1),
5195 trade(3, end_ms),
5196 trade(4, end_ms + 1),
5197 ]))
5198 .mount(&server)
5199 .await;
5200 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5201 "key".into(),
5202 "secret".into(),
5203 ));
5204 client.rest = Arc::new(SpotRestApi::from_config(
5205 ConfigurationRestApi::builder()
5206 .api_key("key")
5207 .api_secret("secret")
5208 .base_path(server.uri())
5209 .build()
5210 .unwrap(),
5211 ));
5212 let btc = [InstrumentNameExchange::new("BTCUSDT")];
5213
5214 let read = client.fetch_trades(start, end, &btc).await.unwrap();
5215 let ids: Vec<_> = read.trades.iter().map(|t| t.id.0.as_str()).collect();
5216 assert_eq!(ids, ["2", "3"]);
5217 assert_eq!(read.resume, None, "spot reads a span whole");
5218
5219 let empty = client.fetch_trades(end, start, &btc).await.unwrap();
5220 assert!(empty.trades.is_empty() && empty.resume.is_none());
5221 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5222 }
5223
5224 #[tokio::test]
5226 async fn a_gap_whose_every_read_times_out_is_reported_when_given_up() {
5227 let server = wiremock::MockServer::start().await;
5228 wiremock::Mock::given(wiremock::matchers::method("GET"))
5229 .and(wiremock::matchers::path("/api/v3/myTrades"))
5230 .respond_with(
5231 wiremock::ResponseTemplate::new(200)
5232 .set_body_json(serde_json::json!([]))
5233 .set_delay(Duration::from_secs(3_600)),
5234 )
5235 .mount(&server)
5236 .await;
5237 let rest = Arc::new(SpotRestApi::from_config(
5238 ConfigurationRestApi::builder()
5239 .api_key("key")
5240 .api_secret("secret")
5241 .base_path(server.uri())
5242 .timeout(3_600_000_u64)
5243 .retries(0_u32)
5244 .build()
5245 .expect("valid config"),
5246 ));
5247 let btc = InstrumentNameExchange::new("BTCUSDT");
5248 let mut unrecovered = UnrecoveredFills::default();
5249 unrecovered.open(
5250 std::slice::from_ref(&btc),
5251 Utc::now() - chrono::Duration::minutes(10),
5252 Utc::now(),
5253 );
5254 let (tx, mut rx) = mpsc::unbounded_channel();
5255 tokio::time::pause();
5257
5258 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
5259 recover_fills(
5260 &rest,
5261 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5262 &mut unrecovered,
5263 &tx,
5264 &new_dedup_cache(),
5265 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5266 )
5267 .await;
5268 unrecovered.make_due();
5269 }
5270
5271 assert!(unrecovered.is_empty(), "the gap is given up");
5272 let Ok(event) = rx.try_recv() else {
5273 panic!("the given-up gap is reported");
5274 };
5275 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
5276 panic!("expected FillRecoveryGaveUp, got {event:?}");
5277 };
5278 assert_eq!(
5279 gave_up.scope,
5280 crate::FillRecoveryScope::Instruments(vec![btc])
5281 );
5282 assert_eq!(
5283 gave_up.reason,
5284 FillRecoveryFailure::TimedOut {
5285 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS
5286 }
5287 );
5288 assert!(rx.try_recv().is_err(), "reported once");
5289 }
5290
5291 #[tokio::test]
5294 async fn a_timed_out_recovery_charges_only_the_gaps_it_started() {
5295 let server = wiremock::MockServer::start().await;
5296 wiremock::Mock::given(wiremock::matchers::method("GET"))
5297 .and(wiremock::matchers::path("/api/v3/myTrades"))
5298 .respond_with(
5299 wiremock::ResponseTemplate::new(200)
5300 .set_body_json(serde_json::json!([]))
5301 .set_delay(Duration::from_secs(3_600)),
5302 )
5303 .mount(&server)
5304 .await;
5305 let rest = Arc::new(SpotRestApi::from_config(
5306 ConfigurationRestApi::builder()
5307 .api_key("key")
5308 .api_secret("secret")
5309 .base_path(server.uri())
5310 .timeout(3_600_000_u64)
5311 .retries(0_u32)
5312 .build()
5313 .unwrap(),
5314 ));
5315 let instruments: Vec<_> = (0..9)
5316 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
5317 .collect();
5318 let mut unrecovered = UnrecoveredFills::default();
5319 unrecovered.open(
5320 &instruments,
5321 Utc::now() - chrono::Duration::minutes(10),
5322 Utc::now(),
5323 );
5324 let (tx, _rx) = mpsc::unbounded_channel();
5325 tokio::time::pause();
5327
5328 recover_fills(
5329 &rest,
5330 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5331 &mut unrecovered,
5332 &tx,
5333 &new_dedup_cache(),
5334 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5335 )
5336 .await;
5337
5338 let now = tokio::time::Instant::now();
5339 assert_eq!(
5340 unrecovered.due(now).len(),
5341 1,
5342 "the gap never started stays due"
5343 );
5344 assert_eq!(
5345 unrecovered
5346 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
5347 .len(),
5348 9,
5349 "the eight started wait for their first retry"
5350 );
5351 }
5352
5353 fn order_row(cid: &str, status: &str) -> serde_json::Value {
5359 serde_json::json!({
5360 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
5361 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
5362 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "time": 1_700_000_000_000_i64,
5363 "updateTime": 1_700_000_060_000_i64,
5364 })
5365 }
5366
5367 fn get_order_row(row: serde_json::Value) -> binance_sdk::spot::rest_api::GetOrderResponse {
5368 serde_json::from_value(row).unwrap()
5369 }
5370
5371 fn spot_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
5372 OrderKey::new(
5373 ExchangeId::BinanceSpot,
5374 InstrumentNameExchange::new(instrument),
5375 StrategyId::new("strategy"),
5376 ClientOrderId::new(cid),
5377 )
5378 }
5379
5380 fn ended_state(
5381 key: &UnindexedOrderKey,
5382 row: serde_json::Value,
5383 ) -> Option<crate::order::state::UnindexedInactiveOrderState> {
5384 let order = convert_ended_order(&get_order_row(row), ExchangeId::BinanceSpot, key, None)?;
5385 assert_eq!(&order.key, key, "reported under the key asked");
5386 assert_eq!(order.quantity, Decimal::TWO);
5387 Some(order.state)
5388 }
5389
5390 #[test]
5393 fn an_ended_order_row_without_a_fill_reports_it_unknown() {
5394 use crate::order::state::{Expired, Filled, InactiveOrderState};
5395 let key = spot_key("BTCUSDT", "a");
5396 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
5397 let id = OrderId::new("7");
5398 let row = |status: &str, executed_qty: Option<&str>| {
5399 let mut row = order_row("a", status);
5400 row["executedQty"] = executed_qty.map_or(serde_json::Value::Null, Into::into);
5401 row
5402 };
5403
5404 for executed_qty in [None, Some("not a number")] {
5405 assert_eq!(
5406 ended_state(&key, row("CANCELED", executed_qty)),
5407 Some(InactiveOrderState::Cancelled(Cancelled::new(
5408 id.clone(),
5409 ended,
5410 None
5411 ))),
5412 "{executed_qty:?}"
5413 );
5414 assert_eq!(
5415 ended_state(&key, row("EXPIRED", executed_qty)),
5416 Some(InactiveOrderState::Expired(Expired::new(
5417 id.clone(),
5418 ended,
5419 None
5420 ))),
5421 "{executed_qty:?}"
5422 );
5423 assert_eq!(
5424 ended_state(&key, row("FILLED", executed_qty)),
5425 Some(InactiveOrderState::FullyFilled(Filled::new(
5426 id.clone(),
5427 ended,
5428 Decimal::TWO,
5429 Some(Decimal::new(525, 1)),
5430 ))),
5431 "the whole quantity, at the quote over it: {executed_qty:?}"
5432 );
5433 }
5434 }
5435
5436 #[test]
5437 fn an_ended_order_row_says_how_the_order_ended() {
5438 use crate::order::state::{Expired, Filled, InactiveOrderState};
5439 let key = spot_key("BTCUSDT", "a");
5440 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
5441 let id = OrderId::new("7");
5442
5443 let mut filled = order_row("a", "FILLED");
5444 filled["executedQty"] = "2".into();
5445 filled["cummulativeQuoteQty"] = "210".into();
5446 assert_eq!(
5447 ended_state(&key, filled),
5448 Some(InactiveOrderState::FullyFilled(Filled::new(
5449 id.clone(),
5450 ended,
5451 Decimal::TWO,
5452 Some(Decimal::from(105)),
5453 )))
5454 );
5455 assert_eq!(
5456 ended_state(&key, order_row("a", "CANCELED")),
5457 Some(InactiveOrderState::Cancelled(Cancelled::new(
5458 id.clone(),
5459 ended,
5460 Some(Decimal::ONE)
5461 )))
5462 );
5463 for status in ["EXPIRED", "EXPIRED_IN_MATCH"] {
5464 assert_eq!(
5465 ended_state(&key, order_row("a", status)),
5466 Some(InactiveOrderState::Expired(Expired::new(
5467 id.clone(),
5468 ended,
5469 Some(Decimal::ONE)
5470 ))),
5471 "{status}"
5472 );
5473 }
5474 assert!(matches!(
5475 ended_state(&key, order_row("a", "REJECTED")),
5476 Some(InactiveOrderState::OpenFailed(OrderError::Rejected(
5477 ApiError::OrderRejected(_)
5478 )))
5479 ));
5480 }
5481
5482 #[test]
5483 fn a_live_or_unreadable_order_row_is_not_ended() {
5484 let key = spot_key("BTCUSDT", "a");
5485 for status in [
5486 "NEW",
5487 "PARTIALLY_FILLED",
5488 "PENDING_NEW",
5489 "PENDING_CANCEL",
5490 "SOMETHING_NEW",
5491 ] {
5492 assert_eq!(ended_state(&key, order_row("a", status)), None, "{status}");
5493 }
5494 let mut no_status = order_row("a", "FILLED");
5495 no_status["status"] = serde_json::Value::Null;
5496 assert_eq!(ended_state(&key, no_status), None);
5497 let mut no_side = order_row("a", "FILLED");
5498 no_side["side"] = serde_json::Value::Null;
5499 assert_eq!(ended_state(&key, no_side), None);
5500 }
5501
5502 #[test]
5503 fn an_unknown_order_or_symbol_is_recognised_by_its_code() {
5504 use binance_sdk::common::errors::ConnectorError;
5505 let bad = |code| {
5506 anyhow::Error::new(ConnectorError::BadRequestError {
5507 msg: "no".into(),
5508 code: Some(code),
5509 })
5510 };
5511 assert!(is_unknown_order(&bad(-2013)));
5512 assert!(is_unknown_order(&bad(-1121)));
5513 assert!(!is_unknown_order(&bad(-1100)));
5514 assert!(is_unknown_order(&anyhow::Error::new(
5515 ConnectorError::NotFoundError {
5516 msg: "no".into(),
5517 code: Some(-2013),
5518 }
5519 )));
5520 assert!(!is_unknown_order(&anyhow::anyhow!("-2013 in text only")));
5521 }
5522
5523 fn rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
5524 Arc::new(SpotRestApi::from_config(
5525 ConfigurationRestApi::builder()
5526 .api_key("key")
5527 .api_secret("secret")
5528 .base_path(server.uri())
5529 .build()
5530 .unwrap(),
5531 ))
5532 }
5533
5534 async fn mount_order(
5536 server: &wiremock::MockServer,
5537 cid: &str,
5538 response: wiremock::ResponseTemplate,
5539 ) {
5540 wiremock::Mock::given(wiremock::matchers::method("GET"))
5541 .and(wiremock::matchers::path("/api/v3/order"))
5542 .and(wiremock::matchers::query_param("origClientOrderId", cid))
5543 .respond_with(response)
5544 .mount(server)
5545 .await;
5546 }
5547
5548 fn venue_error(code: i64) -> wiremock::ResponseTemplate {
5549 wiremock::ResponseTemplate::new(400)
5550 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
5551 }
5552
5553 fn row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
5554 wiremock::ResponseTemplate::new(200).set_body_json(order_row(cid, status))
5555 }
5556
5557 async fn looked_up(server: &wiremock::MockServer) -> Vec<String> {
5559 server
5560 .received_requests()
5561 .await
5562 .unwrap()
5563 .iter()
5564 .filter(|request| request.url.path() == "/api/v3/order")
5565 .filter_map(|request| {
5566 request
5567 .url
5568 .query_pairs()
5569 .find(|(key, _)| key == "origClientOrderId")
5570 .map(|(_, value)| value.into_owned())
5571 })
5572 .collect()
5573 }
5574
5575 #[tokio::test]
5576 async fn fetch_ended_orders_reports_each_ended_order_under_its_key_and_omits_the_rest() {
5577 let server = wiremock::MockServer::start().await;
5578 mount_order(&server, "filled", row_response("filled", "FILLED")).await;
5579 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
5580 mount_order(&server, "live", row_response("live", "PARTIALLY_FILLED")).await;
5581 mount_order(&server, "unknown", venue_error(-2013)).await;
5582 mount_order(&server, "no-symbol", venue_error(-1121)).await;
5583 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5584 "key".into(),
5585 "secret".into(),
5586 ));
5587 client.rest = rest_at(&server);
5588 let keys = ["filled", "cancelled", "live", "unknown"].map(|cid| spot_key("BTCUSDT", cid));
5589
5590 let mut ended = client
5591 .fetch_ended_orders(&[keys.to_vec(), vec![spot_key("NOSUCH", "no-symbol")]].concat())
5592 .await
5593 .unwrap();
5594
5595 ended.sort_by(|a, b| a.key.cid.cmp(&b.key.cid));
5596 let reported: Vec<_> = ended.iter().map(|order| &order.key).collect();
5597 assert_eq!(
5598 reported,
5599 [&keys[1], &keys[0]],
5600 "the strategy asked survives"
5601 );
5602 assert!(matches!(
5603 ended[0].state,
5604 crate::order::state::InactiveOrderState::Cancelled(_)
5605 ));
5606 assert!(matches!(
5607 ended[1].state,
5608 crate::order::state::InactiveOrderState::FullyFilled(_)
5609 ));
5610 assert!(client.fetch_ended_orders(&[]).await.unwrap().is_empty());
5611 }
5612
5613 #[tokio::test]
5614 async fn fetch_ended_orders_fails_when_any_lookup_fails() {
5615 let server = wiremock::MockServer::start().await;
5616 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
5617 mount_order(&server, "refused", venue_error(-1100)).await;
5618 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5619 "key".into(),
5620 "secret".into(),
5621 ));
5622 client.rest = rest_at(&server);
5623
5624 let result = client
5625 .fetch_ended_orders(&[
5626 spot_key("BTCUSDT", "cancelled"),
5627 spot_key("BTCUSDT", "refused"),
5628 ])
5629 .await;
5630
5631 assert!(
5632 matches!(
5633 result,
5634 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
5635 ),
5636 "{result:?}"
5637 );
5638 }
5639
5640 async fn venue_after_a_disconnect(delay: Duration) -> wiremock::MockServer {
5643 let server = wiremock::MockServer::start().await;
5644 wiremock::Mock::given(wiremock::matchers::method("GET"))
5645 .and(wiremock::matchers::path("/api/v3/openOrders"))
5646 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
5647 .respond_with(
5648 wiremock::ResponseTemplate::new(200)
5649 .set_body_json(serde_json::json!([order_row("live", "NEW")])),
5650 )
5651 .mount(&server)
5652 .await;
5653 mount_order(
5654 &server,
5655 "gone",
5656 row_response("gone", "CANCELED").set_delay(delay),
5657 )
5658 .await;
5659 mount_order(&server, "unknown", venue_error(-2013)).await;
5660 server
5661 }
5662
5663 fn held_orders() -> SharedKnownLiveOrders {
5665 let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
5666 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
5667 known.lock().live(
5668 &spot_key("BTCUSDT", cid),
5669 Decimal::TWO,
5670 OrderKind::Limit,
5671 &Open::new(
5672 VenueOrderId::Assigned(OrderId::new(n.to_string())),
5673 Utc::now(),
5674 Decimal::ZERO,
5675 ),
5676 );
5677 }
5678 known
5679 }
5680
5681 fn btcusdt_unchecked() -> UncheckedOrders {
5682 let mut unchecked = UncheckedOrders::default();
5683 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
5684 unchecked
5685 }
5686
5687 #[tokio::test]
5688 async fn a_reconnect_reports_how_an_unlisted_order_ended_and_forgets_an_unknown_one() {
5689 let server = venue_after_a_disconnect(Duration::ZERO).await;
5690 let known = held_orders();
5691 let mut unchecked = btcusdt_unchecked();
5692 let (tx, mut rx) = mpsc::unbounded_channel();
5693
5694 recover_spot_ended_orders(
5695 &rest_at(&server),
5696 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5697 &known,
5698 &mut unchecked,
5699 &UnrecoveredFills::default(),
5700 &tx,
5701 )
5702 .await;
5703
5704 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5705 let [event] = sent.as_slice() else {
5706 panic!("one order ended: {sent:?}");
5707 };
5708 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
5709 panic!("an order snapshot: {event:?}");
5710 };
5711 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
5712 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
5713 assert!(matches!(
5714 snapshot.0.state,
5715 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
5716 ));
5717 let mut asked = looked_up(&server).await;
5718 asked.sort();
5719 assert_eq!(
5720 asked,
5721 ["gone", "unknown"],
5722 "a listed order is not looked up"
5723 );
5724 let known = known.lock();
5725 assert!(known.contains(&ClientOrderId::new("live")));
5726 assert!(!known.contains(&ClientOrderId::new("gone")));
5727 assert!(
5728 !known.contains(&ClientOrderId::new("unknown")),
5729 "asking again cannot help"
5730 );
5731 assert!(unchecked.is_empty());
5732 }
5733
5734 #[tokio::test]
5735 async fn an_order_the_stream_reports_ending_during_the_check_is_not_reported_again() {
5736 let server = venue_after_a_disconnect(Duration::from_millis(300)).await;
5737 let known = held_orders();
5738 let mut unchecked = btcusdt_unchecked();
5739 let (tx, mut rx) = mpsc::unbounded_channel();
5740 let (rest, tracker) = (
5741 rest_at(&server),
5742 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5743 );
5744 let unrecovered = UnrecoveredFills::default();
5745
5746 tokio::join!(
5747 recover_spot_ended_orders(&rest, &tracker, &known, &mut unchecked, &unrecovered, &tx,),
5748 async {
5749 tokio::time::sleep(Duration::from_millis(100)).await;
5750 known.lock().ended(&ClientOrderId::new("gone"));
5751 }
5752 );
5753
5754 assert!(rx.try_recv().is_err(), "the stream already reported it");
5755 assert!(unchecked.is_empty());
5756 }
5757
5758 #[tokio::test]
5759 async fn an_instrument_with_unrecovered_fills_is_not_checked_yet() {
5760 let server = venue_after_a_disconnect(Duration::ZERO).await;
5761 let known = held_orders();
5762 let mut unchecked = btcusdt_unchecked();
5763 let mut unrecovered = UnrecoveredFills::default();
5764 unrecovered.open(
5765 &[InstrumentNameExchange::new("BTCUSDT")],
5766 Utc::now() - chrono::Duration::minutes(1),
5767 Utc::now(),
5768 );
5769 let (tx, mut rx) = mpsc::unbounded_channel();
5770
5771 recover_spot_ended_orders(
5772 &rest_at(&server),
5773 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5774 &known,
5775 &mut unchecked,
5776 &unrecovered,
5777 &tx,
5778 )
5779 .await;
5780
5781 assert!(rx.try_recv().is_err());
5782 assert!(server.received_requests().await.unwrap().is_empty());
5783 assert!(!unchecked.is_empty(), "it waits for its fills");
5784 assert_eq!(unchecked.next_due(&unrecovered), None, "woken by the gap");
5785 }
5786
5787 #[tokio::test]
5788 async fn a_failed_check_sends_nothing_and_waits_for_its_retry() {
5789 let server = wiremock::MockServer::start().await;
5790 wiremock::Mock::given(wiremock::matchers::method("GET"))
5791 .and(wiremock::matchers::path("/api/v3/openOrders"))
5792 .respond_with(venue_error(-1100))
5793 .mount(&server)
5794 .await;
5795 let server_rest = rest_at(&server);
5796 let known = held_orders();
5797 let mut unchecked = btcusdt_unchecked();
5798 let (tx, mut rx) = mpsc::unbounded_channel();
5799
5800 recover_spot_ended_orders(
5801 &server_rest,
5802 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5803 &known,
5804 &mut unchecked,
5805 &UnrecoveredFills::default(),
5806 &tx,
5807 )
5808 .await;
5809
5810 assert!(rx.try_recv().is_err());
5811 assert!(!unchecked.is_empty());
5812 assert!(
5813 unchecked
5814 .next_due(&UnrecoveredFills::default())
5815 .is_some_and(|due| due > tokio::time::Instant::now()),
5816 "retried after a backoff"
5817 );
5818 assert!(
5819 known.lock().contains(&ClientOrderId::new("gone")),
5820 "still held"
5821 );
5822 }
5823
5824 #[tokio::test]
5825 async fn an_unlisted_order_the_lookup_finds_live_stays_held() {
5826 let server = wiremock::MockServer::start().await;
5827 wiremock::Mock::given(wiremock::matchers::method("GET"))
5828 .and(wiremock::matchers::path("/api/v3/openOrders"))
5829 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
5830 .mount(&server)
5831 .await;
5832 for (cid, status) in [
5833 ("live", "NEW"),
5834 ("gone", "PENDING_CANCEL"),
5835 ("unknown", "WHAT"),
5836 ] {
5837 mount_order(&server, cid, row_response(cid, status)).await;
5838 }
5839 let known = held_orders();
5840 let mut unchecked = btcusdt_unchecked();
5841 let (tx, mut rx) = mpsc::unbounded_channel();
5842
5843 recover_spot_ended_orders(
5844 &rest_at(&server),
5845 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5846 &known,
5847 &mut unchecked,
5848 &UnrecoveredFills::default(),
5849 &tx,
5850 )
5851 .await;
5852
5853 assert!(rx.try_recv().is_err(), "none has ended");
5854 let known = known.lock();
5855 for cid in ["live", "gone", "unknown"] {
5856 assert!(
5857 known.contains(&ClientOrderId::new(cid)),
5858 "{cid}: only an order Binance does not know is dropped"
5859 );
5860 }
5861 assert!(unchecked.is_empty());
5862 }
5863
5864 #[tokio::test]
5865 async fn one_instrument_failing_does_not_hold_up_another() {
5866 let server = venue_after_a_disconnect(Duration::ZERO).await;
5867 wiremock::Mock::given(wiremock::matchers::method("GET"))
5868 .and(wiremock::matchers::path("/api/v3/openOrders"))
5869 .and(wiremock::matchers::query_param("symbol", "ETHUSDT"))
5870 .respond_with(venue_error(-1100))
5871 .mount(&server)
5872 .await;
5873 let known = held_orders();
5874 let eth = spot_key("ETHUSDT", "eth");
5875 known.lock().live(
5876 ð,
5877 Decimal::TWO,
5878 OrderKind::Limit,
5879 &Open::new(
5880 VenueOrderId::Assigned(OrderId::new("9")),
5881 Utc::now(),
5882 Decimal::ZERO,
5883 ),
5884 );
5885 let mut unchecked = btcusdt_unchecked();
5886 unchecked.open([eth.instrument.clone()]);
5887 let (tx, mut rx) = mpsc::unbounded_channel();
5888
5889 recover_spot_ended_orders(
5890 &rest_at(&server),
5891 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5892 &known,
5893 &mut unchecked,
5894 &UnrecoveredFills::default(),
5895 &tx,
5896 )
5897 .await;
5898
5899 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5900 assert_eq!(sent.len(), 1, "BTCUSDT's ended order: {sent:?}");
5901 assert!(!unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
5902 assert!(
5903 unchecked.contains(ð.instrument),
5904 "ETHUSDT waits for its retry"
5905 );
5906 assert!(known.lock().contains(ð.cid));
5907 }
5908
5909 #[tokio::test]
5910 async fn a_check_dropped_part_way_changes_nothing() {
5911 let server = venue_after_a_disconnect(Duration::from_millis(500)).await;
5912 let known = held_orders();
5913 let mut unchecked = btcusdt_unchecked();
5914 let (tx, mut rx) = mpsc::unbounded_channel();
5915
5916 let dropped = tokio::time::timeout(
5917 Duration::from_millis(100),
5918 recover_spot_ended_orders(
5919 &rest_at(&server),
5920 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5921 &known,
5922 &mut unchecked,
5923 &UnrecoveredFills::default(),
5924 &tx,
5925 ),
5926 )
5927 .await;
5928
5929 assert!(dropped.is_err(), "the lookup of `gone` was still waiting");
5930 assert!(rx.try_recv().is_err());
5931 assert!(known.lock().contains(&ClientOrderId::new("gone")));
5932 assert!(unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
5933 assert!(
5934 unchecked
5935 .next_due(&UnrecoveredFills::default())
5936 .is_some_and(|due| due <= tokio::time::Instant::now()),
5937 "still due, not charged a failure"
5938 );
5939 }
5940}