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