1use super::rules::RulesLoader;
40use super::shared::{
41 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
42 CONNECT_TIMEOUT_SECS, DisconnectOnDrop, ExecutionReport, FILL_RECOVERY_TIMEOUT_SECS,
43 HEARTBEAT_TIMEOUT_SECS, HandshakeRefusal, MyTradesFrom, ORDER_EXECUTIONS_BUDGET,
44 OpenOrderListing, PlacementResponse, RateLimitTracker, RequestKind,
45 SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, UnhandledEvents, UnrecoveredFills,
46 UnsubscribeOnDrop, UserDataFrame, WeightPool, binance_filled_qty, classify_order_kind_tif,
47 classify_rest_query_error, classify_ws_order_error, convert_ended_order,
48 convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
49 dedup_key_from_event, disconnect_error, drop_after, frame_excerpt, gap_failed, gap_time,
50 is_duplicate, is_unknown_order, log_unhandled_event, log_unrecognised_frame, new_dedup_cache,
51 parse_user_data_frame, placed_order_state, recovered_order_cids, recovered_order_totals,
52 response_decode_error, rest_call_with_retry, retry_or_end, trailing_params, unix_ms,
53};
54use crate::rules::{OrderRules, PreSendRules};
55use crate::{
56 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
57 UnindexedAccountEvent, UnindexedAccountSnapshot,
58 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
59 client::{
60 ExecutionClient, OrderStatusClient,
61 order_recovery::{
62 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
63 fetch_ended_by_key, recover_ended_orders,
64 },
65 reconnect_budget::{BackoffLimits, ReconnectBudget},
66 },
67 emit_stream_terminated,
68 error::{ApiError, ConnectivityError, OrderError, UnindexedClientError, UnindexedOrderError},
69 margin::MarginEffect,
70 order::{
71 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
72 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
73 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
74 state::{Cancelled, Open, OrderState, UnindexedOrderState},
75 },
76 parse_env_bool,
77 position::PositionReport,
78 trade::{AssetFees, Trade, TradeId, TradesRead},
79};
80use binance_sdk::{
81 common::{
82 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
83 models::WebsocketEvent,
84 },
85 spot::{
86 SpotRestApi, SpotWsApi,
87 rest_api::{
88 GetAccountParams, GetOpenOrdersParams, GetOrderParams, MyTradesParams, RestApi,
89 },
90 websocket_api::{
91 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
92 OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
93 WebsocketApiHandle,
94 },
95 },
96};
97use chrono::{DateTime, TimeZone, Utc};
98use fnv::FnvHashSet;
99use futures::stream::BoxStream;
100use rust_decimal::Decimal;
101use rustrade_instrument::{
102 Side,
103 asset::name::AssetNameExchange,
104 exchange::ExchangeId,
105 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
106};
107use serde::Deserialize;
108use smol_str::format_smolstr;
109use std::{
110 collections::HashMap,
111 str::FromStr,
112 sync::{
113 Arc,
114 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
115 },
116 time::Duration,
117};
118use tokio::sync::{RwLock, mpsc, oneshot};
119use tracing::{debug, error, info, trace, warn};
120
121#[derive(Clone, Deserialize)]
128pub struct BinanceSpotConfig {
129 api_key: String,
132 secret_key: String,
133
134 #[serde(default = "default_testnet")]
136 pub testnet: bool,
137
138 #[serde(default)]
142 pub pre_send_rules: PreSendRules,
143}
144
145fn default_testnet() -> bool {
151 true
152}
153
154impl std::fmt::Debug for BinanceSpotConfig {
156 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157 f.debug_struct("BinanceSpotConfig")
158 .field("api_key", &"***")
159 .field("secret_key", &"***")
160 .field("testnet", &self.testnet)
161 .field("pre_send_rules", &self.pre_send_rules)
162 .finish()
163 }
164}
165
166impl BinanceSpotConfig {
167 pub fn new(api_key: String, secret_key: String) -> Self {
170 Self::testnet(api_key, secret_key)
171 }
172
173 pub fn testnet(api_key: String, secret_key: String) -> Self {
175 Self {
176 api_key,
177 secret_key,
178 testnet: true,
179 pre_send_rules: PreSendRules::default(),
180 }
181 }
182
183 #[must_use]
185 pub fn with_pre_send_rules(mut self, pre_send_rules: PreSendRules) -> Self {
186 self.pre_send_rules = pre_send_rules;
187 self
188 }
189
190 pub fn production(api_key: String, secret_key: String) -> Self {
195 Self {
196 api_key,
197 secret_key,
198 testnet: false,
199 pre_send_rules: PreSendRules::default(),
200 }
201 }
202
203 pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
222 let api_key = match std::env::var("BINANCE_API_KEY") {
223 Ok(value) => value,
224 Err(std::env::VarError::NotPresent) => {
225 return Err(BinanceSpotConfigError::MissingApiKey);
226 }
227 Err(std::env::VarError::NotUnicode(_)) => {
228 return Err(BinanceSpotConfigError::InvalidApiKey);
229 }
230 };
231 let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
232 Ok(value) => value,
233 Err(std::env::VarError::NotPresent) => {
234 return Err(BinanceSpotConfigError::MissingSecretKey);
235 }
236 Err(std::env::VarError::NotUnicode(_)) => {
237 return Err(BinanceSpotConfigError::InvalidSecretKey);
238 }
239 };
240 let testnet = match std::env::var("BINANCE_TESTNET") {
241 Ok(value) => {
242 parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
243 }
244 Err(std::env::VarError::NotPresent) => true,
245 Err(std::env::VarError::NotUnicode(value)) => {
248 return Err(BinanceSpotConfigError::InvalidTestnet(
249 value.to_string_lossy().into_owned(),
250 ));
251 }
252 };
253
254 if testnet {
255 Ok(Self::testnet(api_key, secret_key))
256 } else {
257 Ok(Self::production(api_key, secret_key))
258 }
259 }
260
261 pub fn api_key(&self) -> &str {
263 &self.api_key
264 }
265}
266
267#[derive(Debug, PartialEq, thiserror::Error)]
268pub enum BinanceSpotConfigError {
269 #[error("BINANCE_API_KEY environment variable not set")]
270 MissingApiKey,
271
272 #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
275 InvalidApiKey,
276
277 #[error("BINANCE_SECRET_KEY environment variable not set")]
278 MissingSecretKey,
279
280 #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
281 InvalidSecretKey,
282
283 #[error("BINANCE_TESTNET must be true or false, got {0}")]
284 InvalidTestnet(String),
285}
286
287#[derive(Debug)]
294enum WsApiUnavailable {
295 RateLimited {
298 msg: String,
299 retry_after: Option<Duration>,
300 },
301 Connect(String),
303}
304
305impl WsApiUnavailable {
306 fn into_order_error(self) -> UnindexedOrderError {
309 match self {
310 Self::RateLimited { msg, retry_after } => {
311 debug!(%msg, "BinanceSpot WS-API rate-limited, request not sent");
314 UnindexedOrderError::Rejected(ApiError::rate_limited(retry_after))
315 }
316 Self::Connect(msg) => UnindexedOrderError::Connectivity(ConnectivityError::Socket(msg)),
317 }
318 }
319}
320
321#[derive(Clone)]
360pub struct BinanceSpot {
361 config: Arc<BinanceSpotConfig>,
362 rest: Arc<RestApi>,
363 ws_handle: WebsocketApiHandle,
365 ws_api: Arc<RwLock<Option<DisconnectOnDrop<WebsocketApi>>>>,
371 rate_limiter: Arc<RateLimitTracker>,
373 known_live: SharedKnownLiveOrders,
376 rules: Arc<RulesLoader>,
379}
380
381impl std::fmt::Debug for BinanceSpot {
382 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
383 f.debug_struct("BinanceSpot")
384 .field("testnet", &self.config.testnet)
385 .finish_non_exhaustive()
386 }
387}
388
389impl BinanceSpot {
390 pub async fn load_rules(
399 &self,
400 instruments: &[InstrumentNameExchange],
401 ) -> Result<HashMap<InstrumentNameExchange, OrderRules>, UnindexedClientError> {
402 self.rules.load(instruments).await
403 }
404
405 fn remember_live<'a>(
407 &self,
408 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
409 ) {
410 let mut known = self.known_live.lock();
411 for order in orders {
412 known.live(&order.key, order.quantity, order.kind, &order.state);
413 }
414 }
415
416 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
420 let rest_config = ConfigurationRestApi::builder()
421 .api_key(config.api_key.clone())
422 .api_secret(config.secret_key.clone())
423 .build()
424 .expect("failed to build Binance REST configuration");
425
426 Arc::new(if config.testnet {
427 SpotRestApi::testnet(rest_config)
428 } else {
429 SpotRestApi::production(rest_config)
430 })
431 }
432
433 #[allow(clippy::expect_used)] fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
437 let ws_config = ConfigurationWebsocketApi::builder()
438 .api_key(config.api_key.clone())
439 .api_secret(config.secret_key.clone())
440 .build()
441 .expect("failed to build Binance WebSocket configuration");
442
443 if config.testnet {
444 SpotWsApi::testnet(ws_config)
445 } else {
446 SpotWsApi::production(ws_config)
447 }
448 }
449
450 async fn get_ws_api(&self) -> Result<WebsocketApi, WsApiUnavailable> {
461 if let Err(cooldown) = self.rate_limiter.wait_if_blocked(RequestKind::Order).await {
462 return Err(WsApiUnavailable::RateLimited {
463 msg: cooldown.to_string(),
464 retry_after: Some(cooldown.remaining),
465 });
466 }
467 {
469 let guard = self.ws_api.read().await;
470 if let Some(ref ws) = *guard {
471 return Ok(WebsocketApi::clone(ws));
472 }
473 }
474 let mut guard = self.ws_api.write().await;
481 if let Some(ref ws) = *guard {
483 return Ok(WebsocketApi::clone(ws));
484 }
485 if let Some(remaining) = self.rate_limiter.cooldown_remaining() {
486 return Err(WsApiUnavailable::RateLimited {
487 msg: "rate-limit cooldown active, not connecting the WS-API session".into(),
488 retry_after: Some(remaining),
489 });
490 }
491 let ws = match tokio::time::timeout(
492 Duration::from_secs(CONNECT_TIMEOUT_SECS),
493 self.ws_handle.connect(),
494 )
495 .await
496 {
497 Ok(Ok(ws)) => ws,
498 Ok(Err(e)) => {
499 let Some(refusal) = HandshakeRefusal::of(&e) else {
500 return Err(WsApiUnavailable::Connect(format!("{e:#}")));
501 };
502 warn!(error = %format!("{e:#}"), "Binance refused the WS-API handshake");
504 let retry_after = refusal.retry_after();
505 self.rate_limiter.on_rate_limited(retry_after);
506 return Err(WsApiUnavailable::RateLimited {
507 msg: format!("{e:#}"),
508 retry_after,
509 });
510 }
511 Err(_) => {
512 return Err(WsApiUnavailable::Connect(format!(
513 "BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s"
514 )));
515 }
516 };
517 *guard = Some(DisconnectOnDrop::new(
518 ws.clone(),
519 "BinanceSpot order session",
520 ));
521 Ok(ws)
522 }
523
524 async fn clear_ws_api(&self) {
527 let ws = {
539 let mut guard = self.ws_api.write().await;
540 guard.take()
541 };
542 if let Some(ws) = ws {
543 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
545 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
546 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
547 Ok(Ok(())) => {}
548 }
549 }
550 }
551}
552
553async fn fetch_open_orders_for_instrument(
560 rest: Arc<RestApi>,
561 rate_limiter: Arc<RateLimitTracker>,
562 instrument: InstrumentNameExchange,
563 kind: RequestKind,
564) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
565 let symbol_str = instrument.name().to_string();
567 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
568 let sym = symbol_str.clone();
569 Box::pin(async move {
570 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
571 rest.get_open_orders(params).await
572 })
573 })
574 .await
575 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
576
577 let orders_data = response.data().await.map_err(response_decode_error)?;
578
579 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceSpot, &instrument);
580
581 Ok((instrument, listing))
582}
583
584async fn fetch_all_open_orders(
589 rest: Arc<RestApi>,
590 rate_limiter: Arc<RateLimitTracker>,
591) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
592 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
593 Box::pin(async move {
594 let params = GetOpenOrdersParams::builder().build()?;
595 rest.get_open_orders(params).await
596 })
597 })
598 .await
599 .map_err(|e| classify_rest_query_error(&e, None))?;
600
601 let orders_data = response.data().await.map_err(response_decode_error)?;
602
603 let orders = orders_data
604 .into_iter()
605 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot))
606 .collect();
607
608 Ok(orders)
609}
610
611async fn fetch_order_lookup(
613 rest: Arc<RestApi>,
614 rate_limiter: Arc<RateLimitTracker>,
615 key: UnindexedOrderKey,
616 kind: RequestKind,
617) -> Result<OrderLookup, UnindexedClientError> {
618 let symbol = key.instrument.name().to_string();
620 let cid = key.cid.0.to_string();
621 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
622 let (symbol, cid) = (symbol.clone(), cid.clone());
623 Box::pin(async move {
624 let params = GetOrderParams::builder(symbol)
625 .orig_client_order_id(cid)
626 .build()?;
627 rest.get_order(params).await
628 })
629 })
630 .await
631 {
632 Ok(response) => response,
633 Err(e) if is_unknown_order(&e) => {
634 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceSpot does not know this order");
635 return Ok(OrderLookup::Unknown);
636 }
637 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
638 };
639
640 let row = response.data().await.map_err(response_decode_error)?;
641
642 Ok(
644 convert_ended_order(&row, ExchangeId::BinanceSpot, &key, None)
645 .map_or(OrderLookup::NotEnded, |order| {
646 OrderLookup::Ended(Box::new(order))
647 }),
648 )
649}
650
651async fn fetch_order_cid(
655 rest: &Arc<RestApi>,
656 rate_limiter: &RateLimitTracker,
657 instrument: &InstrumentNameExchange,
658 order_id: i64,
659) -> Result<Option<ClientOrderId>, UnindexedClientError> {
660 let symbol = instrument.name().to_string();
661 let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
662 let symbol = symbol.clone();
663 Box::pin(async move {
664 let params = GetOrderParams::builder(symbol).order_id(order_id).build()?;
665 rest.get_order(params).await
666 })
667 })
668 .await
669 {
670 Ok(response) => response,
671 Err(e) if is_unknown_order(&e) => {
672 debug!(%instrument, order_id, error = %e, "BinanceSpot does not know this order");
673 return Ok(None);
674 }
675 Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
676 };
677 let row = response.data().await.map_err(response_decode_error)?;
678 Ok(row
679 .client_order_id
680 .filter(|cid| !cid.is_empty())
681 .map(ClientOrderId::new))
682}
683
684async fn listed_open_cids(
687 rest: Arc<RestApi>,
688 rate_limiter: Arc<RateLimitTracker>,
689 instruments: Vec<InstrumentNameExchange>,
690) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
691 let mut listed = FnvHashSet::default();
692 for instrument in instruments {
693 let (_, listing) = fetch_open_orders_for_instrument(
694 rest.clone(),
695 rate_limiter.clone(),
696 instrument,
697 RequestKind::Essential,
698 )
699 .await?;
700 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
701 }
702 Ok(listed)
703}
704
705async fn recover_spot_ended_orders(
708 rest: &Arc<RestApi>,
709 rate_limiter: &Arc<RateLimitTracker>,
710 known: &SharedKnownLiveOrders,
711 unchecked: &mut UncheckedOrders,
712 unrecovered: &UnrecoveredFills,
713 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
714) {
715 recover_ended_orders(
716 ExchangeId::BinanceSpot,
717 known,
718 unchecked,
719 unrecovered,
720 tx,
721 OpenListing::PerInstrument,
722 |instruments| listed_open_cids(rest.clone(), rate_limiter.clone(), instruments),
723 |key| {
724 fetch_order_lookup(
725 rest.clone(),
726 rate_limiter.clone(),
727 key,
728 RequestKind::Essential,
729 )
730 },
731 )
732 .await;
733}
734
735async fn paginate_my_trades(
746 rest: &Arc<RestApi>,
747 rate_limiter: &Arc<RateLimitTracker>,
748 instrument: &InstrumentNameExchange,
749 from: MyTradesFrom,
750 kind: RequestKind,
751) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
752 let symbol_str = instrument.name().to_string();
754 let mut all_pages = Vec::new();
759 let mut cursor: Option<i64> = None;
760 loop {
761 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
763 let sym = symbol_str.clone();
764 Box::pin(async move {
765 #[allow(clippy::cast_possible_truncation)]
767 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
768 let params = match (from, fid) {
769 (MyTradesFrom::Span { start, .. }, None) => builder.start_time(start),
770 (MyTradesFrom::Span { .. }, Some(id)) => builder.from_id(id),
771 (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
772 (MyTradesFrom::Order(order_id), Some(id)) => {
773 builder.order_id(order_id).from_id(id)
774 }
775 }
776 .build()?;
777 rest.my_trades(params).await
778 })
779 })
780 .await
781 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
782
783 let mut page = response.data().await.map_err(response_decode_error)?;
784
785 let page_len = page.len();
786 let last_id = page.last().and_then(|t| t.id);
787 let past_end = match from {
789 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
790 MyTradesFrom::Order(_) => false,
791 };
792 all_pages.extend(page);
793
794 if past_end || page_len < BINANCE_MAX_TRADES {
795 break;
796 }
797 match last_id {
798 Some(id) => {
799 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
800 match id.checked_add(1) {
801 Some(next) => cursor = Some(next),
802 None => break, }
804 }
805 None => {
806 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
807 break;
808 }
809 }
810 }
811 Ok(all_pages)
812}
813
814impl ExecutionClient for BinanceSpot {
819 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
820
821 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
824 &[InstrumentKindDiscriminant::Spot];
825 type Config = BinanceSpotConfig;
826 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
827
828 fn new(config: Self::Config) -> Self {
833 let rest = Self::build_rest(&config);
834 let ws_handle = Self::build_ws_handle(&config);
835 let rate_limiter = Arc::new(RateLimitTracker::new(WeightPool::Spot));
836 let rules = Arc::new(RulesLoader::new(
837 config.pre_send_rules,
838 Arc::clone(&rest),
839 Arc::clone(&rate_limiter),
840 ));
841 Self {
842 config: Arc::new(config),
843 rest,
844 ws_handle,
845 ws_api: Arc::new(RwLock::new(None)),
846 rate_limiter,
847 known_live: KnownLiveOrders::shared(ExchangeId::BinanceSpot),
848 rules,
849 }
850 }
851
852 async fn account_snapshot(
853 &self,
854 assets: &[AssetNameExchange],
855 instruments: &[InstrumentNameExchange],
856 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
857 let response =
859 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
860 Box::pin(async move {
861 let params = GetAccountParams::builder().build()?;
862 rest.get_account(params).await
863 })
864 })
865 .await
866 .map_err(|e| classify_rest_query_error(&e, None))?;
867
868 let account = response.data().await.map_err(response_decode_error)?;
869
870 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
872
873 use futures::{StreamExt as _, TryStreamExt};
879 let instrument_snapshots: Vec<_> =
880 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
881 fetch_open_orders_for_instrument(
882 self.rest.clone(),
883 self.rate_limiter.clone(),
884 instrument,
885 RequestKind::Query,
886 )
887 }))
888 .buffer_unordered(8)
889 .map(|result| {
890 let (inst, listing) = result?;
891 self.remember_live(&listing.orders);
892 let wrapped = listing
893 .orders
894 .into_iter()
895 .map(|o| Order {
896 key: o.key,
897 side: o.side,
898 price: o.price,
899 quantity: o.quantity,
900 kind: o.kind,
901 time_in_force: o.time_in_force,
902 state: OrderState::active(o.state),
903 })
904 .collect();
905 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
906 inst,
907 wrapped,
908 listing.complete,
909 PositionReport::Unreported,
910 None,
911 ))
912 })
913 .try_collect()
914 .await?;
915
916 Ok(AccountSnapshot::new(
917 ExchangeId::BinanceSpot,
918 balances,
919 instrument_snapshots,
920 ))
921 }
922
923 async fn account_stream(
1003 &self,
1004 _assets: &[AssetNameExchange],
1008 instruments: &[InstrumentNameExchange],
1009 ) -> Result<Self::AccountStream, UnindexedClientError> {
1010 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1025 let dedup = new_dedup_cache();
1026 let ws_handle = self.ws_handle.clone();
1027 let rest = self.rest.clone();
1028 let rate_limiter = self.rate_limiter.clone();
1029 let known_live = self.known_live.clone();
1030 let instruments = instruments.to_vec();
1031 debug_assert!(
1035 instruments.iter().all(|i| i.name().len() <= 23),
1036 "instrument name exceeds SmolStr inline capacity: {:?}",
1037 instruments.iter().find(|i| i.name().len() > 23)
1038 );
1039
1040 self.rate_limiter.before_stream_open().await?;
1042
1043 let initial_ws = ws_handle.connect().await.map_err(|e| {
1047 self.rate_limiter.stream_open_error(&e, |e| {
1048 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1049 })
1050 })?;
1051 let initial_ws = DisconnectOnDrop::new(initial_ws, ACCOUNT_STREAM_WS);
1053
1054 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1056 .build()
1057 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1058
1059 match initial_ws
1060 .user_data_stream_subscribe_signature(params)
1061 .await
1062 {
1063 Ok(_) => {}
1064 Err(e) => {
1065 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
1069 Ok(Err(de)) => {
1070 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
1071 }
1072 Err(_) => {
1073 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
1074 }
1075 Ok(Ok(())) => {}
1076 }
1077 return Err(self
1078 .rate_limiter
1079 .stream_open_error(&e, |e| UnindexedClientError::Internal(e.to_string())));
1080 }
1081 }
1082
1083 let cm_handle = tokio::spawn(connection_manager(
1095 tx,
1096 dedup,
1097 ws_handle,
1098 rest,
1099 rate_limiter,
1100 known_live,
1101 instruments,
1102 Some(initial_ws),
1103 ));
1104
1105 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1109 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1110 Ok(futures::StreamExt::boxed(guarded_stream))
1111 }
1112
1113 async fn cancel_order(
1114 &self,
1115 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1116 ) -> UnindexedOrderResponseCancel {
1117 let instrument = request.key.instrument.clone();
1118 let key = OrderKey {
1119 exchange: request.key.exchange,
1120 instrument: instrument.clone(),
1121 strategy: request.key.strategy.clone(),
1122 cid: request.key.cid.clone(),
1123 };
1124
1125 let ws = match self.get_ws_api().await {
1126 Ok(ws) => ws,
1127 Err(unavailable) => {
1128 return UnindexedOrderResponseCancel {
1129 key,
1130 state: Err(unavailable.into_order_error()),
1131 };
1132 }
1133 };
1134
1135 let mut params_builder =
1138 OrderCancelParams::builder(request.key.instrument.name().to_string());
1139
1140 if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1142 if let Ok(id) = order_id.0.parse::<i64>() {
1143 params_builder = params_builder.order_id(id);
1144 } else {
1145 error!(
1150 order_id = %order_id.0,
1151 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
1152 );
1153 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1154 }
1155 } else {
1156 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1157 }
1158
1159 let params = match params_builder.build() {
1160 Ok(p) => p,
1161 Err(e) => {
1162 error!(%e, "BinanceSpot failed to build cancel order params");
1163 return UnindexedOrderResponseCancel {
1164 key,
1165 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1166 e.to_string(),
1167 ))),
1168 };
1169 }
1170 };
1171
1172 let sent_ms = unix_ms();
1173 let result = ws.order_cancel(params).await;
1174 if let Ok(response) = &result {
1176 self.rate_limiter
1177 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1178 }
1179 match result {
1180 Ok(response) => match response.data() {
1181 Ok(data) => {
1182 let time_exchange = data
1183 .transact_time
1184 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1185 .unwrap_or_else(Utc::now);
1186
1187 let exchange_order_id = match data.order_id {
1188 Some(id) => OrderId(format_smolstr!("{id}")),
1189 None => {
1190 error!("BinanceSpot cancel response missing orderId");
1191 return UnindexedOrderResponseCancel {
1192 key,
1193 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1194 "cancel response missing orderId".into(),
1195 ))),
1196 };
1197 }
1198 };
1199
1200 let filled_qty = binance_filled_qty(
1201 ExchangeId::BinanceSpot,
1202 &exchange_order_id,
1203 data.executed_qty.as_deref(),
1204 );
1205
1206 self.known_live.lock().ended(&key.cid);
1207 UnindexedOrderResponseCancel {
1208 key,
1209 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1210 }
1211 }
1212 Err(e) => {
1213 UnindexedOrderResponseCancel {
1215 key,
1216 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1217 e.to_string(),
1218 ))),
1219 }
1220 }
1221 },
1222 Err(e) => {
1223 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1231 if let OrderError::Rejected(api @ ApiError::RateLimit { .. }) = &order_err {
1235 self.rate_limiter.on_rate_limited(api.retry_after());
1236 }
1237 UnindexedOrderResponseCancel {
1238 key,
1239 state: Err(order_err),
1240 }
1241 } else {
1242 self.clear_ws_api().await;
1245 UnindexedOrderResponseCancel {
1246 key,
1247 state: Err(UnindexedOrderError::Connectivity(
1248 ConnectivityError::Socket(format!("{e:#}")),
1249 )),
1250 }
1251 }
1252 }
1253 }
1254 }
1255
1256 async fn open_order(
1257 &self,
1258 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1259 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1260 let instrument = request.key.instrument.clone();
1261 let side = request.state.side;
1262 let price = request.state.price;
1263 let quantity = request.state.quantity;
1264 let kind = request.state.kind;
1265 let time_in_force = request.state.time_in_force;
1266 let cid = request.key.cid.clone();
1267
1268 let order_key = OrderKey::new(
1269 ExchangeId::BinanceSpot,
1270 instrument.clone(),
1271 request.key.strategy.clone(),
1272 cid.clone(),
1273 );
1274
1275 if let Some(effect) = request
1277 .state
1278 .margin_effect
1279 .filter(|effect| *effect != MarginEffect::NoSideEffect)
1280 {
1281 return Order {
1282 key: order_key,
1283 side,
1284 price,
1285 quantity,
1286 kind,
1287 time_in_force,
1288 state: OrderState::inactive(OrderError::UnsupportedMarginEffect(effect)),
1289 };
1290 }
1291
1292 if let Err(refused) = self
1293 .rules
1294 .check(instrument.name(), kind, price, quantity)
1295 .await
1296 {
1297 return Order {
1298 key: order_key,
1299 side,
1300 price,
1301 quantity,
1302 kind,
1303 time_in_force,
1304 state: OrderState::inactive(refused),
1305 };
1306 }
1307
1308 let ws = match self.get_ws_api().await {
1309 Ok(ws) => ws,
1310 Err(unavailable) => {
1311 return Order {
1312 key: order_key,
1313 side,
1314 price,
1315 quantity,
1316 kind,
1317 time_in_force,
1318 state: OrderState::inactive(unavailable.into_order_error()),
1319 };
1320 }
1321 };
1322
1323 let binance_side = match side {
1324 Side::Buy => OrderPlaceSideEnum::Buy,
1325 Side::Sell => OrderPlaceSideEnum::Sell,
1326 };
1327
1328 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
1329 Some(converted) => converted,
1330 None => {
1331 return Order {
1332 key: order_key,
1333 side,
1334 price,
1335 quantity,
1336 kind,
1337 time_in_force,
1338 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1339 "Binance Spot does not yet support OrderKind::{kind:?}"
1340 ))),
1341 };
1342 }
1343 };
1344
1345 let mut params_builder =
1352 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
1353 .quantity(quantity)
1354 .new_client_order_id(cid.0.to_string());
1355
1356 match trailing_params(&kind, price) {
1358 Ok(Some(trailing)) => {
1359 params_builder = params_builder.trailing_delta(trailing.trailing_delta);
1360 if let Some(limit) = trailing.price {
1361 params_builder = params_builder.price(limit);
1362 }
1363 if let Some(activation_price) = trailing.stop_price {
1364 params_builder = params_builder.stop_price(activation_price);
1366 }
1367 }
1368 Err(refused) => {
1369 return Order {
1370 key: order_key,
1371 side,
1372 price,
1373 quantity,
1374 kind,
1375 time_in_force,
1376 state: OrderState::inactive(refused.into_order_error()),
1377 };
1378 }
1379 Ok(None) => {}
1380 }
1381
1382 match kind {
1385 OrderKind::Limit => {
1386 params_builder = params_builder.price(price);
1387 }
1388 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
1389 params_builder = params_builder.stop_price(trigger_price);
1390 }
1391 OrderKind::StopLimit { trigger_price }
1392 | OrderKind::TakeProfitLimit { trigger_price } => {
1393 params_builder = params_builder.price(price).stop_price(trigger_price);
1394 }
1395 OrderKind::TrailingStop { .. }
1398 | OrderKind::TrailingStopFixedLimit { .. }
1399 | OrderKind::Market
1400 | OrderKind::TrailingStopLimit { .. } => {}
1401 }
1402
1403 if let Some(tif) = binance_tif {
1404 params_builder = params_builder.time_in_force(tif);
1405 }
1406
1407 let params = match params_builder.build() {
1408 Ok(p) => p,
1409 Err(e) => {
1410 error!(%e, "BinanceSpot failed to build new order params");
1411 return Order {
1412 key: order_key,
1413 side,
1414 price,
1415 quantity,
1416 kind,
1417 time_in_force,
1418 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1419 e.to_string(),
1420 ))),
1421 };
1422 }
1423 };
1424
1425 let sent_ms = unix_ms();
1426 let result = ws.order_place(params).await;
1427 if let Ok(response) = &result {
1429 self.rate_limiter
1430 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1431 }
1432 match result {
1433 Ok(response) => match response.data() {
1434 Ok(data) => {
1435 let time_exchange = data
1436 .transact_time
1437 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1438 .unwrap_or_else(Utc::now);
1439
1440 let exchange_order_id = match data.order_id {
1441 Some(id) => OrderId(format_smolstr!("{id}")),
1442 None => {
1443 error!("BinanceSpot open_order response missing orderId");
1444 return Order {
1445 key: order_key,
1446 side,
1447 price,
1448 quantity,
1449 kind,
1450 time_in_force,
1451 state: OrderState::inactive(OrderError::Rejected(
1452 ApiError::OrderRejected(
1453 "open_order response missing orderId".into(),
1454 ),
1455 )),
1456 };
1457 }
1458 };
1459
1460 let state = placed_order_state(
1465 ExchangeId::BinanceSpot,
1466 &instrument,
1467 quantity,
1468 PlacementResponse {
1469 status: data.status.as_deref(),
1470 order_id: exchange_order_id,
1471 time_exchange,
1472 executed_qty: data.executed_qty.as_deref(),
1473 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
1474 },
1475 );
1476 self.known_live
1477 .lock()
1478 .placed(&order_key, quantity, kind, &state);
1479
1480 Order {
1481 key: order_key,
1482 side,
1483 price,
1484 quantity,
1485 kind,
1486 time_in_force,
1487 state,
1488 }
1489 }
1490 Err(e) => {
1491 Order {
1493 key: order_key,
1494 side,
1495 price,
1496 quantity,
1497 kind,
1498 time_in_force,
1499 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1500 e.to_string(),
1501 ))),
1502 }
1503 }
1504 },
1505 Err(e) => {
1506 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1514 if let OrderError::Rejected(api @ ApiError::RateLimit { .. }) = &order_err {
1518 self.rate_limiter.on_rate_limited(api.retry_after());
1519 }
1520 Order {
1521 key: order_key,
1522 side,
1523 price,
1524 quantity,
1525 kind,
1526 time_in_force,
1527 state: OrderState::inactive(order_err),
1528 }
1529 } else {
1530 self.clear_ws_api().await;
1533 Order {
1534 key: order_key,
1535 side,
1536 price,
1537 quantity,
1538 kind,
1539 time_in_force,
1540 state: OrderState::inactive(OrderError::Connectivity(
1541 ConnectivityError::Socket(format!("{e:#}")),
1542 )),
1543 }
1544 }
1545 }
1546 }
1547 }
1548
1549 async fn fetch_balances(
1550 &self,
1551 assets: &[AssetNameExchange],
1552 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1553 let response =
1554 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1555 Box::pin(async move {
1556 let params = GetAccountParams::builder().build()?;
1557 rest.get_account(params).await
1558 })
1559 })
1560 .await
1561 .map_err(|e| classify_rest_query_error(&e, None))?;
1562
1563 let account = response.data().await.map_err(response_decode_error)?;
1564
1565 Ok(filter_and_convert_balances(
1566 account.balances.unwrap_or_default(),
1567 assets,
1568 ))
1569 }
1570
1571 async fn fetch_open_orders(
1576 &self,
1577 instruments: &[InstrumentNameExchange],
1578 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1579 if instruments.is_empty() {
1582 let orders =
1583 fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await?;
1584 self.remember_live(&orders);
1585 return Ok(orders);
1586 }
1587 use futures::{StreamExt as _, TryStreamExt as _};
1592 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1593 fetch_open_orders_for_instrument(
1594 self.rest.clone(),
1595 self.rate_limiter.clone(),
1596 instrument,
1597 RequestKind::Query,
1598 )
1599 }))
1600 .buffer_unordered(8)
1601 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1602 acc.extend(listing.orders);
1603 Ok(acc)
1604 })
1605 .await
1606 .inspect(|orders| self.remember_live(orders))
1607 }
1608
1609 #[allow(clippy::redundant_iter_cloned)]
1620 async fn fetch_trades(
1621 &self,
1622 start: DateTime<Utc>,
1623 end: DateTime<Utc>,
1624 instruments: &[InstrumentNameExchange],
1625 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1626 use futures::StreamExt;
1627
1628 if instruments.is_empty() {
1629 debug!(
1630 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1631 );
1632 return Ok(TradesRead::complete(Vec::new()));
1633 }
1634 if start > end {
1635 return Ok(TradesRead::complete(Vec::new()));
1636 }
1637 let span = MyTradesFrom::Span {
1639 start: start.timestamp_millis(),
1640 end: end.timestamp_millis(),
1641 };
1642 let mut all_trades = Vec::new();
1645
1646 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1650 let rest = self.rest.clone();
1651 let rate_limiter = self.rate_limiter.clone();
1652 async move {
1653 let pages =
1654 paginate_my_trades(&rest, &rate_limiter, &inst, span, RequestKind::Query)
1655 .await?;
1656 Ok::<_, UnindexedClientError>((inst, pages))
1657 }
1658 }))
1659 .buffer_unordered(8);
1660 while let Some(result) = stream.next().await {
1661 let (instrument, trades_data) = result?;
1662 for t in trades_data {
1663 if let Some(trade) = convert_my_trade(&t, &instrument)
1664 && (start..=end).contains(&trade.time_exchange)
1665 {
1666 all_trades.push(trade);
1667 }
1668 }
1669 }
1670
1671 Ok(TradesRead::complete(all_trades))
1672 }
1673}
1674
1675impl OrderStatusClient for BinanceSpot {
1690 async fn fetch_ended_orders(
1691 &self,
1692 orders: &[UnindexedOrderKey],
1693 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1694 fetch_ended_by_key(orders, |key| {
1695 fetch_order_lookup(
1696 self.rest.clone(),
1697 self.rate_limiter.clone(),
1698 key,
1699 RequestKind::Query,
1700 )
1701 })
1702 .await
1703 }
1704}
1705
1706const ACCOUNT_STREAM_WS: &str = "BinanceSpot account stream";
1708
1709async fn connect_and_subscribe(
1713 ws_handle: &WebsocketApiHandle,
1714) -> anyhow::Result<DisconnectOnDrop<WebsocketApi>> {
1715 let ws = DisconnectOnDrop::new(ws_handle.connect().await?, ACCOUNT_STREAM_WS);
1716 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1718 .build()
1719 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1720 match ws.user_data_stream_subscribe_signature(params).await {
1721 Ok(_) => Ok(ws),
1722 Err(e) => {
1723 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1726 Err(e)
1727 }
1728 }
1729}
1730
1731#[allow(clippy::cognitive_complexity)]
1755async fn connection_manager(
1756 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1757 dedup: SharedDedupCache,
1758 ws_handle: WebsocketApiHandle,
1759 rest: Arc<RestApi>,
1760 rate_limiter: Arc<RateLimitTracker>,
1761 known: SharedKnownLiveOrders,
1762 instruments: Vec<InstrumentNameExchange>,
1763 initial_ws: Option<DisconnectOnDrop<WebsocketApi>>,
1764) {
1765 let mut budget = ReconnectBudget::new("Binance", BackoffLimits::ACCOUNT_STREAM);
1766 let mut disconnect_time: Option<DateTime<Utc>> = None;
1767 let mut unrecovered = UnrecoveredFills::default();
1769 let mut unchecked = UncheckedOrders::default();
1771 let mut current_ws = initial_ws;
1772
1773 loop {
1774 let ws = match current_ws.take() {
1776 Some(ws) => ws,
1777 None => {
1778 if let Err(reason) = rate_limiter.before_stream_connect().await {
1781 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
1782 break;
1783 }
1784 match connect_and_subscribe(&ws_handle).await {
1785 Ok(ws) => ws,
1791 Err(e) => {
1792 error!(%e, "BinanceSpot WS connect/subscribe failed");
1793 rate_limiter.on_stream_refused(&e);
1795 if let Err(reason) =
1796 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
1797 {
1798 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
1800 break;
1801 }
1802 continue;
1803 }
1804 }
1805 }
1806 };
1807 info!("BinanceSpot account_stream connected and subscribed");
1808
1809 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1815 let mut signal_tx_opt = Some(signal_tx);
1816 let heartbeat_flag = Arc::new(AtomicBool::new(false));
1820 let hb_callback = heartbeat_flag.clone();
1821 let dedup_callback = dedup.clone();
1822 let known_callback = known.clone();
1823 let mut event_tx = Some(tx.clone());
1827 let mut event_buf = Vec::with_capacity(32);
1831
1832 let subscription = ws.subscribe_on_ws_events(move |event| {
1839 let Some(ref sender) = event_tx else { return };
1840 match event {
1841 WebsocketEvent::Message(json_str) => {
1842 hb_callback.store(true, Ordering::Release);
1845 let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1849 for ev in event_buf.drain(..) {
1850 if let Some(key) = dedup_key_from_event(&ev)
1852 && is_duplicate(&dedup_callback, key)
1853 {
1854 trace!("BinanceSpot dedup: skipping duplicate event");
1855 continue;
1856 }
1857 let known = KnownLiveOrders::observes(&ev.kind).then(|| {
1861 let mut known = known_callback.lock();
1862 known.observe(&ev);
1863 known
1864 });
1865 if sender.send(ev).is_err() {
1866 drop(known);
1867 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1868 event_tx.take();
1869 if let Some(s) = signal_tx_opt.take() {
1870 let _ = s.send(());
1871 }
1872 return;
1873 }
1874 }
1875 if stream_terminated {
1878 event_tx.take();
1879 if let Some(s) = signal_tx_opt.take() {
1880 let _ = s.send(());
1881 }
1882 }
1883 }
1884 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1885 hb_callback.store(true, Ordering::Release);
1888 }
1889 WebsocketEvent::Error(e) => {
1890 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1894 event_tx.take();
1895 if let Some(s) = signal_tx_opt.take() {
1896 let _ = s.send(());
1897 }
1898 }
1899 WebsocketEvent::Close(code, reason) => {
1900 warn!(code, %reason, "BinanceSpot WebSocket closed");
1901 event_tx.take();
1902 if let Some(s) = signal_tx_opt.take() {
1903 let _ = s.send(());
1904 }
1905 }
1906 _ => {}
1907 }
1908 });
1909 let subscription = UnsubscribeOnDrop::new(subscription);
1911
1912 if let Some(dt) = disconnect_time.take() {
1919 unrecovered.open(&instruments, dt, Utc::now());
1920 let held = known.lock().instruments_among(&instruments);
1922 unchecked.open(held);
1923 }
1924 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known).await;
1925 recover_spot_ended_orders(
1926 &rest,
1927 &rate_limiter,
1928 &known,
1929 &mut unchecked,
1930 &unrecovered,
1931 &tx,
1932 )
1933 .await;
1934
1935 enum DisconnectReason {
1937 Signal,
1938 HeartbeatTimeout,
1939 ConsumerDropped,
1940 }
1941 let reason = {
1942 let mut signal_rx = signal_rx;
1943 let retry_gaps = async {
1949 loop {
1950 let next_check = unchecked.next_due(&unrecovered);
1951 match unrecovered.next_due().into_iter().chain(next_check).min() {
1952 Some(due) => tokio::time::sleep_until(due).await,
1953 None => std::future::pending::<()>().await,
1954 }
1955 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known)
1956 .await;
1957 recover_spot_ended_orders(
1958 &rest,
1959 &rate_limiter,
1960 &known,
1961 &mut unchecked,
1962 &unrecovered,
1963 &tx,
1964 )
1965 .await;
1966 }
1967 };
1968 tokio::pin!(retry_gaps);
1969 let mut grace = true;
1971 loop {
1972 tokio::select! {
1973 biased;
1975 _ = tx.closed() => {
1976 debug!("BinanceSpot account_stream consumer dropped, terminating");
1977 break DisconnectReason::ConsumerDropped;
1978 }
1979 _ = &mut signal_rx => {
1980 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1981 break DisconnectReason::Signal;
1982 }
1983 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1984 let in_grace = std::mem::take(&mut grace);
1991 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1992 budget.reset();
1996 continue;
1997 }
1998 if in_grace {
1999 continue;
2002 }
2003 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2004 break DisconnectReason::HeartbeatTimeout;
2005 }
2006 () = &mut retry_gaps => {}
2008 }
2009 }
2010 };
2011 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2012
2013 if should_reconnect {
2020 disconnect_time = Some(match reason {
2021 DisconnectReason::HeartbeatTimeout => {
2022 Utc::now()
2023 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2024 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2025 }
2026 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2027 });
2028 }
2029
2030 subscription.unsubscribe();
2038 if let Err(e) = ws.disconnect().await {
2041 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
2042 }
2043
2044 if !should_reconnect || tx.is_closed() {
2045 debug!("BinanceSpot connection manager exiting");
2048 break;
2049 }
2050 let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
2053 let last_error = || disconnect_error(heartbeat_timed_out);
2054 if let Err(reason) = retry_or_end(&mut budget, &rate_limiter, last_error).await {
2055 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
2056 break;
2057 }
2058 }
2059}
2060
2061async fn recover_fills(
2080 rest: &Arc<RestApi>,
2081 rate_limiter: &Arc<RateLimitTracker>,
2082 unrecovered: &mut UnrecoveredFills,
2083 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2084 dedup: &SharedDedupCache,
2085 known: &SharedKnownLiveOrders,
2086) {
2087 use futures::StreamExt;
2088
2089 let due = unrecovered.due(tokio::time::Instant::now());
2090 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2091 return;
2092 };
2093 info!(
2094 gaps = due.len(),
2095 oldest = %gap_time(oldest),
2096 "BinanceSpot recovering fills missed while disconnected"
2097 );
2098
2099 let mut settled = vec![false; due.len()];
2102 let started = AtomicUsize::new(0);
2103 let recovery = async {
2104 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2105 let mut recovered = 0u32;
2106 let mut duplicates = 0u32;
2107
2108 let mut stream =
2114 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2115 started.store(index + 1, Ordering::Relaxed);
2116 let rest = rest.clone();
2117 let rl = rate_limiter.clone();
2118 async move {
2119 let span = MyTradesFrom::Span {
2120 start: gap.start_ms,
2121 end: gap.end_ms,
2122 };
2123 let raw =
2124 match paginate_my_trades(&rest, &rl, &inst, span, RequestKind::Essential)
2125 .await
2126 {
2127 Ok(pages) => pages,
2128 Err(e) => return (index, Err(e)),
2129 };
2130 let totals = recovered_order_totals(
2134 ExchangeId::BinanceSpot,
2135 &inst,
2136 &raw,
2137 order_executions_deadline,
2138 |order_id| {
2139 paginate_my_trades(
2140 &rest,
2141 &rl,
2142 &inst,
2143 MyTradesFrom::Order(order_id),
2144 RequestKind::Essential,
2145 )
2146 },
2147 )
2148 .await;
2149 let cids = recovered_order_cids(
2152 ExchangeId::BinanceSpot,
2153 &inst,
2154 &raw,
2155 order_executions_deadline,
2156 |order_id| fetch_order_cid(&rest, &rl, &inst, order_id),
2157 )
2158 .await;
2159 let trades: Vec<_> = raw
2160 .iter()
2161 .filter_map(|t| {
2162 let mut trade = convert_my_trade(t, &inst)?;
2163 trade.order_filled_quantity =
2164 t.id.and_then(|id| totals.get(&id).copied());
2165 trade.client_order_id =
2166 t.order_id.and_then(|id| cids.get(&id).cloned());
2167 Some(trade)
2168 })
2169 .collect();
2170 (index, Ok(trades))
2171 }
2172 }))
2173 .buffer_unordered(8);
2174 while let Some((index, result)) = stream.next().await {
2175 let (inst, gap) = &due[index];
2176 settled[index] = true;
2177 let trades = match result {
2178 Ok(trades) => trades,
2179 Err(e) => {
2180 gap_failed(
2181 unrecovered,
2182 tx,
2183 ExchangeId::BinanceSpot,
2184 inst,
2185 gap,
2186 FillRecoveryFailure::Request(e.to_string()),
2187 );
2188 continue;
2189 }
2190 };
2191 for trade in trades {
2192 let event = UnindexedAccountEvent::new(
2195 ExchangeId::BinanceSpot,
2196 AccountEventKind::Trade(trade),
2197 );
2198 if let Some(key) = dedup_key_from_event(&event)
2201 && is_duplicate(dedup, key)
2202 {
2203 duplicates += 1;
2204 continue;
2205 }
2206 let sent = {
2209 let mut known = known.lock();
2210 known.observe(&event);
2211 tx.send(event)
2212 };
2213 if sent.is_err() {
2214 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
2217 return;
2218 }
2219 recovered += 1;
2220 }
2221 unrecovered.recovered(inst, gap);
2222 }
2223 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
2224 };
2225 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2229 .await
2230 .is_err()
2231 {
2232 let started = started.load(Ordering::Relaxed);
2233 for ((inst, gap), _) in due[..started]
2234 .iter()
2235 .zip(&settled)
2236 .filter(|(_, settled)| !**settled)
2237 {
2238 gap_failed(
2239 unrecovered,
2240 tx,
2241 ExchangeId::BinanceSpot,
2242 inst,
2243 gap,
2244 FillRecoveryFailure::TimedOut {
2245 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2246 },
2247 );
2248 }
2249 }
2250}
2251
2252fn convert_balance_entry(
2266 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
2267 now: chrono::DateTime<Utc>,
2268) -> Option<AssetBalance<AssetNameExchange>> {
2269 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2270 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2271 Some(v) => v,
2272 None => {
2273 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
2274 return None;
2275 }
2276 };
2277 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2278 Some(v) => v,
2279 None => {
2280 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
2281 return None;
2282 }
2283 };
2284 Some(AssetBalance::new(
2285 asset_name,
2286 Balance::new(free + locked, free),
2287 now,
2288 ))
2289}
2290
2291fn filter_and_convert_balances(
2292 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
2293 assets: &[AssetNameExchange],
2294) -> Vec<AssetBalance<AssetNameExchange>> {
2295 let now = Utc::now();
2296 if assets.is_empty() {
2298 return balances
2299 .into_iter()
2300 .filter_map(|b| convert_balance_entry(b, now))
2301 .collect();
2302 }
2303 if assets.len() <= 16 {
2306 return balances
2307 .into_iter()
2308 .filter_map(|b| {
2309 let asset_name_str = b.asset.as_deref()?;
2310 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
2311 return None;
2312 }
2313 convert_balance_entry(b, now)
2314 })
2315 .collect();
2316 }
2317 use std::collections::HashSet;
2318 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
2319 balances
2320 .into_iter()
2321 .filter_map(|b| {
2322 let asset_name_str = b.asset.as_deref()?;
2323 if !asset_set.contains(asset_name_str) {
2324 return None;
2325 }
2326 convert_balance_entry(b, now)
2327 })
2328 .collect()
2329}
2330
2331fn convert_my_trade(
2333 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
2334 instrument: &InstrumentNameExchange,
2335) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
2336 let trade_id_raw = match t.id {
2337 Some(id) => id,
2338 None => {
2339 warn!(%instrument, "BinanceSpot trade missing id");
2340 return None;
2341 }
2342 };
2343 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
2344 let order_id = match t.order_id {
2345 Some(id) => OrderId(format_smolstr!("{}", id)),
2346 None => {
2347 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
2348 return None;
2349 }
2350 };
2351 let side = match t.is_buyer {
2352 Some(is_buyer) => {
2353 if is_buyer {
2354 Side::Buy
2355 } else {
2356 Side::Sell
2357 }
2358 }
2359 None => {
2360 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
2361 return None;
2362 }
2363 };
2364 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2365 Some(v) => v,
2366 None => {
2367 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
2368 return None;
2369 }
2370 };
2371 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2372 Some(v) => v,
2373 None => {
2374 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2375 return None;
2376 }
2377 };
2378 let commission = t
2379 .commission
2380 .as_deref()
2381 .and_then(|s| Decimal::from_str(s).ok())
2382 .unwrap_or(Decimal::ZERO);
2383 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
2384 Some(ts) => ts,
2385 None => {
2386 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2387 Utc::now()
2388 }
2389 };
2390
2391 let fee_asset = t
2397 .commission_asset
2398 .as_deref()
2399 .map(AssetNameExchange::from)
2400 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2401
2402 Some(Trade::new(
2403 trade_id,
2404 order_id,
2405 instrument.clone(),
2406 StrategyId::unknown(), time_exchange,
2408 side,
2409 price,
2410 quantity,
2411 None,
2413 AssetFees::new(fee_asset, commission, None),
2414 ))
2415}
2416
2417fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2442 let (event_type, event) = match parse_user_data_frame(frame) {
2443 UserDataFrame::Response => return false,
2444 UserDataFrame::Event {
2445 event_type, event, ..
2446 } => (event_type, event),
2447 UserDataFrame::Unrecognised => {
2448 static SEEN: AtomicU64 = AtomicU64::new(0);
2449 log_unrecognised_frame("BinanceSpot", &SEEN, frame);
2450 return false;
2451 }
2452 };
2453 match event_type {
2455 "executionReport" => {
2456 match serde_json::from_str::<ExecutionReport>(event) {
2459 Ok(report) => {
2460 convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
2461 }
2462 Err(e) => {
2463 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable executionReport, dropping")
2464 }
2465 }
2466 false
2467 }
2468 "outboundAccountPosition" => {
2469 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2470 event,
2471 ) {
2472 Ok(position) => convert_account_position(position, buf),
2473 Err(e) => {
2474 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2475 }
2476 }
2477 false
2478 }
2479 "balanceUpdate" => {
2480 false
2487 }
2488 "eventStreamTerminated" => {
2489 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2493 true
2494 }
2495 "listStatus" | "externalLockUpdate" => {
2496 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2500 false
2501 }
2502 _ => {
2503 static SEEN: UnhandledEvents = UnhandledEvents::new();
2504 log_unhandled_event("BinanceSpot", &SEEN, event_type, event);
2505 false
2506 }
2507 }
2508}
2509
2510fn convert_account_position(
2515 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2516 buf: &mut Vec<UnindexedAccountEvent>,
2517) {
2518 let time_exchange = position
2521 .u
2522 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2523 .unwrap_or_else(Utc::now);
2524
2525 for b in position.b_uppercase.unwrap_or_default() {
2526 let asset = match b.a {
2527 Some(a) => AssetNameExchange::new(a),
2528 None => {
2529 warn!("BinanceSpot account position entry missing asset name");
2530 continue;
2531 }
2532 };
2533 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2534 Some(v) => v,
2535 None => {
2536 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2537 continue;
2538 }
2539 };
2540 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2541 Some(v) => v,
2542 None => {
2543 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2544 continue;
2545 }
2546 };
2547 let update =
2552 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2553 buf.push(UnindexedAccountEvent::new(
2554 ExchangeId::BinanceSpot,
2555 AccountEventKind::BalanceStreamUpdate(
2556 rustrade_integration::collection::snapshot::Snapshot::new(update),
2557 ),
2558 ));
2559 }
2560}
2561
2562fn convert_order_kind_tif(
2593 kind: OrderKind,
2594 tif: TimeInForce,
2595) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2596 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2600 let type_enum = match binance_type {
2601 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2602 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2603 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2604 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2605 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2606 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2607 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2608 };
2609 let tif_enum = binance_tif.map(|t| match t {
2610 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2611 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2612 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2613 });
2614 Some((type_enum, tif_enum))
2615}
2616
2617#[cfg(test)]
2618#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2620 use super::*;
2621 use crate::client::binance::shared::*;
2622 use crate::client::dedup::{DedupEventKind, DedupKey};
2623 use crate::error::StreamTerminationReason;
2624 use crate::order::{TrailingOffsetType, id::ClientOrderId};
2625 use binance_sdk::common::errors::WebsocketError;
2626 use smol_str::SmolStr;
2627
2628 #[tokio::test]
2631 async fn open_order_refuses_a_borrow_or_repay_effect_unsent() {
2632 use crate::{
2633 client::ExecutionClient,
2634 margin::MarginEffect,
2635 order::{request::RequestOpen, state::InactiveOrderState},
2636 };
2637
2638 let client = BinanceSpot::new(BinanceSpotConfig::testnet("key".into(), "secret".into()));
2639 let instrument = InstrumentNameExchange::new("BTCUSDT");
2640
2641 for effect in [MarginEffect::Borrow, MarginEffect::Repay] {
2642 let order = client
2643 .open_order(OrderRequestOpen {
2644 key: OrderKey::new(
2645 ExchangeId::BinanceSpot,
2646 &instrument,
2647 StrategyId::new("strategy"),
2648 ClientOrderId::new("cid"),
2649 ),
2650 state: RequestOpen::new(
2651 Side::Buy,
2652 Some(Decimal::from(50_000)),
2653 Decimal::ONE,
2654 OrderKind::Limit,
2655 TimeInForce::GoodUntilCancelled { post_only: false },
2656 )
2657 .with_margin_effect(effect),
2658 })
2659 .await;
2660
2661 assert!(
2662 matches!(
2663 order.state,
2664 OrderState::Inactive(InactiveOrderState::OpenFailed(
2665 OrderError::UnsupportedMarginEffect(refused)
2666 )) if refused == effect
2667 ),
2668 "{order:?}"
2669 );
2670 }
2671 }
2672
2673 #[tokio::test]
2675 async fn load_rules_returns_the_rules_it_read() {
2676 let server = wiremock::MockServer::start().await;
2677 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
2678 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2679 serde_json::json!({"symbols": [{
2680 "symbol": "BTCUSDT", "status": "TRADING",
2681 "filters": [
2682 {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
2683 ],
2684 }]}),
2685 ))
2686 .mount(&server)
2687 .await;
2688 let mut client =
2689 BinanceSpot::new(BinanceSpotConfig::testnet("key".into(), "secret".into()));
2690 client.rules = Arc::new(RulesLoader::new(
2691 PreSendRules::Enforce,
2692 Arc::new(SpotRestApi::from_config(
2693 ConfigurationRestApi::builder()
2694 .api_key("key")
2695 .api_secret("secret")
2696 .base_path(server.uri())
2697 .build()
2698 .unwrap(),
2699 )),
2700 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
2701 ));
2702 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
2703
2704 let loaded = client.load_rules(std::slice::from_ref(&btcusdt)).await;
2705 let rules = loaded.ok().and_then(|mut loaded| loaded.remove(&btcusdt));
2706 assert_eq!(
2707 rules.map(|rules| rules.quantity.step),
2708 Some(Some(Decimal::new(1, 3)))
2709 );
2710 }
2711
2712 #[tokio::test]
2715 async fn open_order_refuses_an_order_breaking_the_symbols_rules_unsent() {
2716 use crate::{
2717 client::ExecutionClient,
2718 error::{Bound, BoundsViolation, OrderField},
2719 order::{request::RequestOpen, state::InactiveOrderState},
2720 };
2721
2722 let server = wiremock::MockServer::start().await;
2723 wiremock::Mock::given(wiremock::matchers::method("GET"))
2724 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
2725 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2726 serde_json::json!({"symbols": [{
2727 "symbol": "BTCUSDT", "status": "TRADING",
2728 "filters": [
2729 {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
2730 ],
2731 }]}),
2732 ))
2733 .expect(1)
2734 .mount(&server)
2735 .await;
2736 let mut client =
2737 BinanceSpot::new(BinanceSpotConfig::testnet("key".into(), "secret".into()));
2738 client.rules = Arc::new(RulesLoader::new(
2739 PreSendRules::Enforce,
2740 Arc::new(SpotRestApi::from_config(
2741 ConfigurationRestApi::builder()
2742 .api_key("key")
2743 .api_secret("secret")
2744 .base_path(server.uri())
2745 .build()
2746 .unwrap(),
2747 )),
2748 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
2749 ));
2750 let instrument = InstrumentNameExchange::new("BTCUSDT");
2751
2752 let order = client
2753 .open_order(OrderRequestOpen {
2754 key: OrderKey::new(
2755 ExchangeId::BinanceSpot,
2756 &instrument,
2757 StrategyId::new("strategy"),
2758 ClientOrderId::new("cid"),
2759 ),
2760 state: RequestOpen::new(
2761 Side::Buy,
2762 Some(Decimal::from(50_000)),
2763 Decimal::from(101),
2764 OrderKind::Limit,
2765 TimeInForce::GoodUntilCancelled { post_only: false },
2766 ),
2767 })
2768 .await;
2769
2770 assert!(
2771 matches!(
2772 order.state,
2773 OrderState::Inactive(InactiveOrderState::OpenFailed(
2774 OrderError::OutOfBounds(violation)
2775 )) if violation == BoundsViolation::new(
2776 OrderField::Quantity,
2777 Bound::Max,
2778 Decimal::from(100),
2779 Decimal::from(101),
2780 )
2781 ),
2782 "{order:?}"
2783 );
2784 }
2785
2786 #[test]
2787 fn test_binance_spot_config_new_uses_testnet_by_default() {
2788 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2789 assert!(cfg.testnet);
2790 assert_eq!(cfg.api_key(), "my_key");
2791 }
2792
2793 #[test]
2794 fn test_binance_spot_config_production_is_explicit() {
2795 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2796 assert!(!cfg.testnet);
2797 }
2798
2799 #[test]
2800 fn test_binance_spot_config_debug_redacts_credentials() {
2801 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2802 let debug = format!("{cfg:?}");
2803
2804 assert!(!debug.contains("my_key"), "api_key should be redacted");
2805 assert!(
2806 !debug.contains("my_secret"),
2807 "secret_key should be redacted"
2808 );
2809 assert!(debug.contains("testnet: true"));
2810 }
2811
2812 #[test]
2813 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2814 let cfg: BinanceSpotConfig = serde_json::from_str(
2815 r#"{
2816 "api_key": "my_key",
2817 "secret_key": "my_secret"
2818 }"#,
2819 )
2820 .unwrap();
2821
2822 assert!(cfg.testnet);
2823 assert_eq!(cfg.api_key(), "my_key");
2824 }
2825
2826 #[test]
2827 fn test_binance_spot_config_deserialize_testnet_true() {
2828 let cfg: BinanceSpotConfig = serde_json::from_str(
2829 r#"{
2830 "api_key": "my_key",
2831 "secret_key": "my_secret",
2832 "testnet": true
2833 }"#,
2834 )
2835 .unwrap();
2836
2837 assert!(cfg.testnet);
2838 }
2839
2840 #[test]
2841 fn test_binance_spot_config_deserialize_testnet_false() {
2842 let cfg: BinanceSpotConfig = serde_json::from_str(
2843 r#"{
2844 "api_key": "my_key",
2845 "secret_key": "my_secret",
2846 "testnet": false
2847 }"#,
2848 )
2849 .unwrap();
2850
2851 assert!(!cfg.testnet);
2852 }
2853
2854 #[test]
2855 #[serial_test::serial]
2856 fn test_binance_spot_config_from_env_defaults_to_testnet() {
2857 temp_env::with_vars(
2858 [
2859 ("BINANCE_API_KEY", Some("my_key")),
2860 ("BINANCE_SECRET_KEY", Some("my_secret")),
2861 ("BINANCE_TESTNET", None),
2862 ],
2863 || {
2864 let cfg = BinanceSpotConfig::from_env().unwrap();
2865 assert!(cfg.testnet);
2866 assert_eq!(cfg.api_key(), "my_key");
2867 },
2868 );
2869 }
2870
2871 #[test]
2872 #[serial_test::serial]
2873 fn test_binance_spot_config_from_env_accepts_explicit_production() {
2874 temp_env::with_vars(
2875 [
2876 ("BINANCE_API_KEY", Some("my_key")),
2877 ("BINANCE_SECRET_KEY", Some("my_secret")),
2878 ("BINANCE_TESTNET", Some("false")),
2879 ],
2880 || {
2881 let cfg = BinanceSpotConfig::from_env().unwrap();
2882 assert!(!cfg.testnet);
2883 },
2884 );
2885 }
2886
2887 #[test]
2888 #[serial_test::serial]
2889 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2890 temp_env::with_vars(
2891 [
2892 ("BINANCE_API_KEY", Some("my_key")),
2893 ("BINANCE_SECRET_KEY", Some("my_secret")),
2894 ("BINANCE_TESTNET", Some("maybe")),
2895 ],
2896 || {
2897 let err = BinanceSpotConfig::from_env().unwrap_err();
2898 assert!(
2899 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2900 );
2901 },
2902 );
2903 }
2904
2905 #[test]
2906 #[serial_test::serial]
2907 fn test_binance_spot_config_from_env_requires_credentials() {
2908 temp_env::with_vars(
2909 [
2910 ("BINANCE_API_KEY", None),
2911 ("BINANCE_SECRET_KEY", Some("my_secret")),
2912 ("BINANCE_TESTNET", None),
2913 ],
2914 || {
2915 let err = BinanceSpotConfig::from_env().unwrap_err();
2916 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2917 },
2918 );
2919 }
2920
2921 #[test]
2922 fn test_parse_side() {
2923 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2924 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2925 assert_eq!(parse_side("buy"), None);
2926 assert_eq!(parse_side("UNKNOWN"), None);
2927 }
2928
2929 #[test]
2930 fn test_parse_time_in_force() {
2931 assert_eq!(
2932 parse_time_in_force("GTC"),
2933 TimeInForce::GoodUntilCancelled { post_only: false }
2934 );
2935 assert_eq!(
2936 parse_time_in_force("GTX"),
2937 TimeInForce::GoodUntilCancelled { post_only: true }
2938 );
2939 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2940 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2941 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2942 assert_eq!(
2944 parse_time_in_force("UNKNOWN"),
2945 TimeInForce::GoodUntilCancelled { post_only: false }
2946 );
2947 }
2948
2949 #[test]
2950 fn test_convert_order_kind_tif() {
2951 use rust_decimal::Decimal;
2952
2953 assert!(matches!(
2955 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2956 Some((OrderPlaceTypeEnum::Market, None))
2957 ));
2958 assert!(matches!(
2959 convert_order_kind_tif(
2960 OrderKind::Limit,
2961 TimeInForce::GoodUntilCancelled { post_only: false }
2962 ),
2963 Some((
2964 OrderPlaceTypeEnum::Limit,
2965 Some(OrderPlaceTimeInForceEnum::Gtc)
2966 ))
2967 ));
2968 assert!(matches!(
2969 convert_order_kind_tif(
2970 OrderKind::Limit,
2971 TimeInForce::GoodUntilCancelled { post_only: true }
2972 ),
2973 Some((OrderPlaceTypeEnum::LimitMaker, None))
2974 ));
2975 assert!(matches!(
2976 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2977 Some((
2978 OrderPlaceTypeEnum::Limit,
2979 Some(OrderPlaceTimeInForceEnum::Fok)
2980 ))
2981 ));
2982 assert!(matches!(
2983 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2984 Some((
2985 OrderPlaceTypeEnum::Limit,
2986 Some(OrderPlaceTimeInForceEnum::Ioc)
2987 ))
2988 ));
2989 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2992
2993 assert!(matches!(
2995 convert_order_kind_tif(
2996 OrderKind::Stop {
2997 trigger_price: Decimal::from(100)
2998 },
2999 TimeInForce::GoodUntilCancelled { post_only: false }
3000 ),
3001 Some((OrderPlaceTypeEnum::StopLoss, None))
3002 ));
3003 assert!(matches!(
3004 convert_order_kind_tif(
3005 OrderKind::StopLimit {
3006 trigger_price: Decimal::from(100)
3007 },
3008 TimeInForce::GoodUntilCancelled { post_only: false }
3009 ),
3010 Some((
3011 OrderPlaceTypeEnum::StopLossLimit,
3012 Some(OrderPlaceTimeInForceEnum::Gtc)
3013 ))
3014 ));
3015 assert!(matches!(
3016 convert_order_kind_tif(
3017 OrderKind::TakeProfit {
3018 trigger_price: Decimal::from(150)
3019 },
3020 TimeInForce::ImmediateOrCancel
3021 ),
3022 Some((OrderPlaceTypeEnum::TakeProfit, None))
3023 ));
3024 assert!(matches!(
3025 convert_order_kind_tif(
3026 OrderKind::TakeProfitLimit {
3027 trigger_price: Decimal::from(150)
3028 },
3029 TimeInForce::FillOrKill
3030 ),
3031 Some((
3032 OrderPlaceTypeEnum::TakeProfitLimit,
3033 Some(OrderPlaceTimeInForceEnum::Fok)
3034 ))
3035 ));
3036
3037 assert!(matches!(
3039 convert_order_kind_tif(
3040 OrderKind::TrailingStop {
3041 offset: Decimal::from(100),
3042 offset_type: TrailingOffsetType::BasisPoints,
3043 activation_price: None,
3044 },
3045 TimeInForce::GoodUntilCancelled { post_only: false }
3046 ),
3047 Some((OrderPlaceTypeEnum::StopLoss, None))
3048 ));
3049 assert!(matches!(
3050 convert_order_kind_tif(
3051 OrderKind::TrailingStop {
3052 offset: Decimal::from(5),
3053 offset_type: TrailingOffsetType::Percentage,
3054 activation_price: None,
3055 },
3056 TimeInForce::GoodUntilCancelled { post_only: false }
3057 ),
3058 Some((OrderPlaceTypeEnum::StopLoss, None))
3059 ));
3060
3061 assert!(
3063 convert_order_kind_tif(
3064 OrderKind::TrailingStop {
3065 offset: Decimal::from(10),
3066 offset_type: TrailingOffsetType::Absolute,
3067 activation_price: None,
3068 },
3069 TimeInForce::GoodUntilCancelled { post_only: false }
3070 )
3071 .is_none()
3072 );
3073
3074 assert!(
3076 convert_order_kind_tif(
3077 OrderKind::TrailingStopLimit {
3078 offset: Decimal::from(100),
3079 offset_type: TrailingOffsetType::BasisPoints,
3080 stop_price: Decimal::from(95),
3081 limit_offset: Decimal::from(10),
3082 },
3083 TimeInForce::GoodUntilCancelled { post_only: false }
3084 )
3085 .is_none()
3086 );
3087 }
3088
3089 #[test]
3092 fn a_fixed_limit_trailing_stop_converts_to_stop_loss_limit() {
3093 let fixed_limit = |offset_type| OrderKind::TrailingStopFixedLimit {
3094 offset: Decimal::from(100),
3095 offset_type,
3096 activation_price: Some(Decimal::from(50_000)),
3097 };
3098 assert!(matches!(
3099 convert_order_kind_tif(
3100 fixed_limit(TrailingOffsetType::BasisPoints),
3101 TimeInForce::GoodUntilCancelled { post_only: false }
3102 ),
3103 Some((
3104 OrderPlaceTypeEnum::StopLossLimit,
3105 Some(OrderPlaceTimeInForceEnum::Gtc)
3106 ))
3107 ));
3108 assert!(
3109 convert_order_kind_tif(
3110 fixed_limit(TrailingOffsetType::Absolute),
3111 TimeInForce::GoodUntilCancelled { post_only: false }
3112 )
3113 .is_none()
3114 );
3115 }
3116
3117 #[test]
3118 fn test_parse_binance_api_error() {
3119 let instrument = InstrumentNameExchange::new("BTCUSDT");
3120
3121 assert!(matches!(
3122 parse_binance_api_error("Insufficient balance".into(), &instrument),
3123 ApiError::BalanceInsufficient(None, _)
3124 ));
3125 assert!(matches!(
3126 parse_binance_api_error("Not enough funds".into(), &instrument),
3127 ApiError::BalanceInsufficient(None, _)
3128 ));
3129 assert_eq!(
3130 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
3131 ApiError::rate_limited(None)
3132 );
3133 assert_eq!(
3134 parse_binance_api_error("Error code -1015".into(), &instrument),
3135 ApiError::rate_limited(None)
3136 );
3137 assert_eq!(
3139 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
3140 ApiError::rate_limited(None)
3141 );
3142 assert_eq!(
3144 parse_binance_api_error("Error code -2011".into(), &instrument),
3145 ApiError::OrderAlreadyCancelled
3146 );
3147 assert!(matches!(
3149 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
3150 ApiError::OrderAlreadyCancelled
3151 ));
3152 assert!(matches!(
3154 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
3155 ApiError::OrderAlreadyCancelled
3156 ));
3157 assert!(matches!(
3159 parse_binance_api_error("Unknown order encountered".into(), &instrument),
3160 ApiError::OrderRejected(_)
3161 ));
3162 assert!(matches!(
3163 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
3164 ApiError::InstrumentInvalid(_, _)
3165 ));
3166 assert!(matches!(
3168 parse_binance_api_error(
3169 "Server-side response error (code -2010): Account has insufficient balance".into(),
3170 &instrument
3171 ),
3172 ApiError::BalanceInsufficient(None, _)
3173 ));
3174 assert!(matches!(
3175 parse_binance_api_error(
3176 "Server-side response error (code -2010): Order would trigger immediately.".into(),
3177 &instrument
3178 ),
3179 ApiError::OrderRejected(_)
3180 ));
3181 assert!(matches!(
3182 parse_binance_api_error("Some other error".into(), &instrument),
3183 ApiError::OrderRejected(_)
3184 ));
3185 }
3186
3187 #[test]
3188 fn test_contains_error_code_suffix_guard() {
3189 assert!(
3191 !contains_error_code("-20130", "-2013"),
3192 "-20130 should not match -2013"
3193 );
3194 assert!(
3195 !contains_error_code("-20131", "-2013"),
3196 "-20131 should not match -2013"
3197 );
3198 assert!(contains_error_code("-2013", "-2013"), "exact match");
3200 assert!(
3201 contains_error_code("Error -2013: text", "-2013"),
3202 "match with trailing text"
3203 );
3204 }
3205
3206 #[test]
3207 fn test_contains_error_code_prefix_guard() {
3208 assert!(
3211 !contains_error_code("1-2013", "-2013"),
3212 "1-2013 should not match -2013"
3213 );
3214 assert!(
3215 !contains_error_code("error 1-2013 text", "-2013"),
3216 "embedded 1-2013 should not match"
3217 );
3218 assert!(
3220 contains_error_code("code=-2013,", "-2013"),
3221 "=-2013 prefix should match"
3222 );
3223 assert!(
3224 contains_error_code(" -2013 ", "-2013"),
3225 "space prefix should match"
3226 );
3227 }
3228
3229 #[test]
3230 fn test_contains_error_code_second_occurrence_valid() {
3231 assert!(
3234 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
3235 "second valid occurrence must match when first fails digit guard"
3236 );
3237 assert!(
3239 contains_error_code("-2013 or -20130", "-2013"),
3240 "first valid occurrence must match when second is a longer code"
3241 );
3242 }
3243
3244 #[test]
3245 fn test_dedup_cache() {
3246 let cache = new_dedup_cache();
3247 let key = DedupKey {
3248 instrument: SmolStr::from("BTCUSDT"),
3249 id: SmolStr::from("12345"),
3250 kind: DedupEventKind::Trade,
3251 };
3252
3253 assert!(!is_duplicate(&cache, key));
3255 let key = DedupKey {
3257 instrument: SmolStr::from("BTCUSDT"),
3258 id: SmolStr::from("12345"),
3259 kind: DedupEventKind::Trade,
3260 };
3261 assert!(is_duplicate(&cache, key));
3262
3263 let key2 = DedupKey {
3265 instrument: SmolStr::from("BTCUSDT"),
3266 id: SmolStr::from("12345"),
3267 kind: DedupEventKind::OrderState {
3268 filled_quantity: Decimal::ZERO,
3269 },
3270 };
3271 assert!(!is_duplicate(&cache, key2));
3272 }
3273
3274 #[test]
3278 fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
3279 let cache = new_dedup_cache();
3280 let state = |filled: &str| DedupKey {
3281 instrument: SmolStr::from("BTCUSDT"),
3282 id: SmolStr::from("12345"),
3283 kind: DedupEventKind::OrderState {
3284 filled_quantity: Decimal::from_str(filled).unwrap(),
3285 },
3286 };
3287
3288 assert!(!is_duplicate(&cache, state("0")), "ack is new");
3290 assert!(
3291 !is_duplicate(&cache, state("1")),
3292 "first fill is not the ack"
3293 );
3294 assert!(
3295 !is_duplicate(&cache, state("2")),
3296 "second fill is not the first"
3297 );
3298
3299 assert!(
3302 is_duplicate(&cache, state("0")),
3303 "replayed ack is a duplicate"
3304 );
3305 assert!(
3306 is_duplicate(&cache, state("2")),
3307 "replayed fill is a duplicate"
3308 );
3309
3310 assert!(
3312 is_duplicate(&cache, state("2.00")),
3313 "2.00 must be the same key as 2"
3314 );
3315 }
3316
3317 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
3324 use crate::client::dedup::dedup_key_from_event;
3325
3326 let cache = new_dedup_cache();
3327 let mut delivered = Vec::new();
3328 let mut buf = Vec::new();
3329 for frame in frames {
3330 let _ = convert_user_data_events(frame, &mut buf);
3331 for ev in buf.drain(..) {
3332 if let Some(key) = dedup_key_from_event(&ev)
3333 && is_duplicate(&cache, key)
3334 {
3335 continue;
3336 }
3337 delivered.push(ev);
3338 }
3339 }
3340 delivered
3341 }
3342
3343 const WS_NEW: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport","s":"BTCUSDT",
3345 "i":12345,"c":"client-1","x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2",
3346 "p":"100","z":"0","T":1700000000000}}"#;
3347
3348 const WS_PARTIAL_FILL: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport",
3349 "s":"BTCUSDT","i":12345,"c":"client-1","x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY",
3350 "o":"LIMIT","f":"GTC","q":"2","p":"100","z":"1","l":"1","L":"100","t":555,"n":"0.1",
3351 "N":"USDT","T":1700000001000}}"#;
3352
3353 #[test]
3361 fn ws_fill_snapshot_survives_the_dedup_gate() {
3362 use crate::order::state::ActiveOrderState;
3363
3364 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
3365
3366 assert_eq!(
3367 delivered.len(),
3368 3,
3369 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
3370 );
3371
3372 let snapshots: Vec<Decimal> = delivered
3373 .iter()
3374 .filter_map(|ev| match &ev.kind {
3375 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
3376 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
3377 _ => None,
3378 },
3379 _ => None,
3380 })
3381 .collect();
3382
3383 assert_eq!(
3384 snapshots,
3385 vec![Decimal::ZERO, Decimal::ONE],
3386 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
3387 );
3388 }
3389
3390 #[test]
3393 fn ws_replayed_frame_is_still_deduplicated() {
3394 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
3395
3396 assert_eq!(
3397 delivered.len(),
3398 3,
3399 "re-delivering both frames must add nothing, got: {delivered:?}"
3400 );
3401 }
3402
3403 #[test]
3404 fn test_is_rate_limit_error() {
3405 assert!(is_rate_limit_error(&anyhow::anyhow!(
3407 "Too many requests. You are being rate-limited. Please slow down."
3408 )));
3409 assert!(is_rate_limit_error(&anyhow::anyhow!(
3411 "The IP address has been banned for exceeding rate limits. Contact support."
3412 )));
3413 assert!(is_rate_limit_error(&anyhow::anyhow!(
3415 "Error -1015: too many new orders"
3416 )));
3417 assert!(is_rate_limit_error(&anyhow::anyhow!(
3418 "Error -1003: too many requests"
3419 )));
3420 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
3422 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
3423 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
3424 assert!(
3426 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
3427 "-10150 should not match -1015"
3428 );
3429 assert!(
3430 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
3431 "-10030 should not match -1003"
3432 );
3433 }
3434
3435 #[test]
3436 fn test_classify_ws_order_error_recognises_venue_responses_only() {
3437 let instrument = InstrumentNameExchange::new("BTCUSDT");
3438
3439 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
3441 code: -2010,
3442 message: "Account has insufficient balance for requested action.".into(),
3443 retry_after: None,
3444 });
3445 assert!(
3446 matches!(
3447 classify_ws_order_error(&rejection, &instrument),
3448 Some(OrderError::Rejected(ApiError::BalanceInsufficient(None, _)))
3449 ),
3450 "ResponseError should be classified as a venue response"
3451 );
3452
3453 let transport = anyhow::anyhow!("connection reset by peer");
3455 assert!(
3456 classify_ws_order_error(&transport, &instrument).is_none(),
3457 "plain transport error should not be classified as a venue response"
3458 );
3459
3460 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
3462 assert!(
3463 classify_ws_order_error(&rate_limit, &instrument).is_none(),
3464 "rate-limit string error should not be classified as a venue response"
3465 );
3466 }
3467
3468 #[test]
3469 fn test_dedup_key_from_event_trade_includes_instrument() {
3470 use crate::order::id::{OrderId, StrategyId};
3471 use crate::trade::{AssetFees, Trade, TradeId};
3472 use chrono::Utc;
3473 use rust_decimal::Decimal;
3474 use rustrade_instrument::Side;
3475
3476 let instrument = InstrumentNameExchange::new("BTCUSDT");
3477 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3478 TradeId::new("9001"),
3479 OrderId::new("4242"),
3480 instrument.clone(),
3481 StrategyId::unknown(),
3482 Utc::now(),
3483 Side::Buy,
3484 Decimal::ZERO,
3485 Decimal::ZERO,
3486 None,
3487 AssetFees::new(
3488 AssetNameExchange::from("USDT"),
3489 Decimal::ZERO,
3490 Some(Decimal::ZERO),
3491 ),
3492 );
3493 let event =
3494 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
3495
3496 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
3497 assert_eq!(key.kind, DedupEventKind::Trade);
3498 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
3499 assert_eq!(
3500 key.instrument.as_str(),
3501 "BTCUSDT",
3502 "key.instrument should be the symbol"
3503 );
3504
3505 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
3507 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3508 TradeId::new("9001"),
3509 OrderId::new("7777"),
3510 instrument2,
3511 StrategyId::unknown(),
3512 Utc::now(),
3513 Side::Buy,
3514 Decimal::ZERO,
3515 Decimal::ZERO,
3516 None,
3517 AssetFees::new(
3518 AssetNameExchange::from("USDT"),
3519 Decimal::ZERO,
3520 Some(Decimal::ZERO),
3521 ),
3522 );
3523 let event2 =
3524 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
3525 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
3526 assert_ne!(
3527 key, key2,
3528 "same trade ID on different symbols must produce distinct keys"
3529 );
3530 }
3531
3532 fn make_balance_inner(
3537 asset: &str,
3538 free: &str,
3539 locked: &str,
3540 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3541 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3542 a: Some(asset.to_string()),
3543 f: Some(free.to_string()),
3544 l: Some(locked.to_string()),
3545 }
3546 }
3547
3548 #[test]
3549 fn test_convert_account_position_happy_path() {
3550 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3551 u: Some(1_700_000_000_000),
3552 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
3553 ..Default::default()
3554 };
3555 let mut buf = Vec::new();
3556 convert_account_position(position, &mut buf);
3557
3558 assert_eq!(buf.len(), 1);
3559 match &buf[0].kind {
3560 AccountEventKind::BalanceStreamUpdate(snap) => {
3561 let update = &snap.0;
3562 assert_eq!(update.asset.as_ref(), "BTC");
3563 let expected_free = Decimal::from_str("1.5").unwrap();
3565 let expected_locked = Decimal::from_str("0.5").unwrap();
3566 assert_eq!(update.update.free, expected_free);
3567 assert_eq!(update.update.locked, expected_locked);
3568 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3569 }
3570 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3571 }
3572 }
3573
3574 #[test]
3575 fn test_convert_account_position_u_field_none_uses_now() {
3576 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3578 u: None,
3579 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3580 ..Default::default()
3581 };
3582 let mut buf = Vec::new();
3583 convert_account_position(position, &mut buf);
3584 assert_eq!(buf.len(), 1);
3585 }
3586
3587 #[test]
3588 fn test_convert_account_position_missing_asset_name_skipped() {
3589 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3590 u: Some(1_700_000_000_000),
3591 b_uppercase: Some(vec![
3592 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3593 a: None, f: Some("1.0".to_string()),
3595 l: Some("0.0".to_string()),
3596 },
3597 make_balance_inner("USDT", "100.0", "0.0"), ]),
3599 ..Default::default()
3600 };
3601 let mut buf = Vec::new();
3602 convert_account_position(position, &mut buf);
3603 assert_eq!(buf.len(), 1);
3605 match &buf[0].kind {
3606 AccountEventKind::BalanceStreamUpdate(snap) => {
3607 assert_eq!(snap.0.asset.as_ref(), "USDT");
3608 }
3609 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3610 }
3611 }
3612
3613 #[test]
3614 fn test_convert_account_position_unparseable_free_skipped() {
3615 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3616 u: Some(1_700_000_000_000),
3617 b_uppercase: Some(vec![
3618 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3619 a: Some("BTC".to_string()),
3620 f: Some("not-a-number".to_string()),
3621 l: Some("0.0".to_string()),
3622 },
3623 make_balance_inner("ETH", "1.0", "0.0"), ]),
3625 ..Default::default()
3626 };
3627 let mut buf = Vec::new();
3628 convert_account_position(position, &mut buf);
3629 assert_eq!(buf.len(), 1);
3631 match &buf[0].kind {
3632 AccountEventKind::BalanceStreamUpdate(snap) => {
3633 assert_eq!(snap.0.asset.as_ref(), "ETH");
3634 }
3635 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3636 }
3637 }
3638
3639 #[test]
3640 fn test_convert_account_position_empty_balances() {
3641 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3642 u: Some(1_700_000_000_000),
3643 b_uppercase: Some(vec![]),
3644 ..Default::default()
3645 };
3646 let mut buf = Vec::new();
3647 convert_account_position(position, &mut buf);
3648 assert!(
3649 buf.is_empty(),
3650 "empty balance list should produce no events"
3651 );
3652 }
3653
3654 #[test]
3655 fn test_convert_account_position_b_field_none() {
3656 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3657 u: Some(1_700_000_000_000),
3658 b_uppercase: None, ..Default::default()
3660 };
3661 let mut buf = Vec::new();
3662 convert_account_position(position, &mut buf);
3663 assert!(buf.is_empty(), "None B field should produce no events");
3664 }
3665
3666 fn make_base_report() -> ExecutionReport {
3670 ExecutionReport {
3671 symbol: Some("BTCUSDT".to_string()),
3672 order_id: Some(12345),
3673 client_order_id: Some("client-1".to_string()),
3674 transaction_time: Some(1_700_000_000_000),
3675 ..Default::default()
3676 }
3677 }
3678
3679 fn convert(report: ExecutionReport) -> Vec<UnindexedAccountEvent> {
3681 let mut buf = Vec::new();
3682 convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
3683 buf
3684 }
3685
3686 fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
3691 assert!(
3692 events.len() <= 1,
3693 "expected at most a single event, got: {events:?}"
3694 );
3695 events.pop()
3696 }
3697
3698 #[test]
3699 fn test_convert_execution_report_new() {
3700 let report = ExecutionReport {
3701 execution_type: Some("NEW".to_string()),
3702 side: Some("BUY".to_string()),
3703 order_type: Some("LIMIT".to_string()),
3704 price: Some("50000.00".to_string()),
3705 order_quantity: Some("0.01".to_string()),
3706 time_in_force: Some("GTC".to_string()),
3707 cumulative_filled_quantity: Some("0".to_string()),
3708 ..make_base_report()
3709 };
3710
3711 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3712 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3713 match &event.kind {
3714 AccountEventKind::OrderSnapshot(snap) => {
3715 let order = &snap.0;
3716 assert_eq!(order.side, Side::Buy);
3717 assert_eq!(order.kind, OrderKind::Limit);
3718 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3719 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3720 assert_eq!(order.key.cid.0.as_str(), "client-1");
3721 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3722 }
3723 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3724 }
3725 }
3726
3727 #[test]
3730 fn a_trailing_stop_loss_limit_report_keeps_its_limit_and_activation_price() {
3731 let report = ExecutionReport {
3732 execution_type: Some("NEW".to_string()),
3733 side: Some("SELL".to_string()),
3734 order_type: Some("STOP_LOSS_LIMIT".to_string()),
3735 price: Some("49000.00".to_string()),
3736 stop_price: Some("50000.00".to_string()),
3737 trailing_delta: Some(WireInt::Int(25)),
3738 order_quantity: Some("0.01".to_string()),
3739 time_in_force: Some("GTC".to_string()),
3740 cumulative_filled_quantity: Some("0".to_string()),
3741 ..make_base_report()
3742 };
3743
3744 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3745 let AccountEventKind::OrderSnapshot(snap) = &event.kind else {
3746 panic!("NEW should yield OrderSnapshot, got {:?}", event.kind);
3747 };
3748 assert_eq!(
3749 snap.0.kind,
3750 OrderKind::TrailingStopFixedLimit {
3751 offset: Decimal::from(25),
3752 offset_type: TrailingOffsetType::BasisPoints,
3753 activation_price: Some(Decimal::from(50_000)),
3754 }
3755 );
3756 assert_eq!(snap.0.price, Some(Decimal::from(49_000)));
3757 }
3758
3759 #[test]
3763 fn a_report_without_an_order_type_has_no_order_snapshot() {
3764 let new = ExecutionReport {
3765 execution_type: Some("NEW".to_string()),
3766 side: Some("BUY".to_string()),
3767 price: Some("50000.00".to_string()),
3768 order_quantity: Some("0.01".to_string()),
3769 ..make_base_report()
3770 };
3771 assert!(convert(new).is_empty());
3772
3773 let fill = ExecutionReport {
3774 execution_type: Some("TRADE".to_string()),
3775 order_status: Some("PARTIALLY_FILLED".to_string()),
3776 side: Some("BUY".to_string()),
3777 trade_id: Some(9999),
3778 last_executed_price: Some("50000.00".to_string()),
3779 last_executed_quantity: Some("0.005".to_string()),
3780 commission_amount: Some("0".to_string()),
3781 cumulative_filled_quantity: Some("0.005".to_string()),
3782 order_type: Some("LIMIT".to_string()),
3783 price: Some("50000.00".to_string()),
3784 order_quantity: Some("0.01".to_string()),
3785 ..make_base_report()
3786 };
3787 let kinds = |report| {
3788 convert(report)
3789 .into_iter()
3790 .map(|event| match event.kind {
3791 AccountEventKind::Trade(_) => "trade",
3792 AccountEventKind::OrderSnapshot(_) => "snapshot",
3793 _ => "other",
3794 })
3795 .collect::<Vec<_>>()
3796 };
3797 assert_eq!(kinds(fill.clone()), ["trade", "snapshot"]);
3798 let untyped = ExecutionReport {
3799 order_type: None,
3800 ..fill
3801 };
3802 assert_eq!(kinds(untyped), ["trade"]);
3803 }
3804
3805 #[test]
3806 fn test_convert_execution_report_trade() {
3807 let report = ExecutionReport {
3810 execution_type: Some("TRADE".to_string()),
3811 side: Some("BUY".to_string()),
3812 trade_id: Some(9999),
3813 last_executed_price: Some("50000.00".to_string()),
3814 last_executed_quantity: Some("0.01".to_string()),
3815 commission_amount: Some("0.000001".to_string()),
3816 ..make_base_report()
3817 };
3818
3819 let event = sole_event(convert(report)).expect("TRADE event should produce Some");
3820 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3821 match &event.kind {
3822 AccountEventKind::Trade(trade) => {
3823 assert_eq!(trade.side, Side::Buy);
3824 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3825 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3826 assert_eq!(trade.id.0.as_str(), "9999");
3827 assert_eq!(trade.order_id.0.as_str(), "12345");
3828 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3829 }
3830 other => panic!("TRADE should yield Trade, got {other:?}"),
3831 }
3832 }
3833
3834 fn trade_events(
3836 events: Vec<UnindexedAccountEvent>,
3837 ) -> (
3838 Trade<AssetNameExchange, InstrumentNameExchange>,
3839 crate::order::Order<
3840 ExchangeId,
3841 InstrumentNameExchange,
3842 OrderState<AssetNameExchange, InstrumentNameExchange>,
3843 >,
3844 ) {
3845 let [first, second]: [UnindexedAccountEvent; 2] = events
3846 .try_into()
3847 .expect("a fill report must produce an execution and an order snapshot");
3848 let AccountEventKind::Trade(trade) = first.kind else {
3849 panic!("first event must be the Trade, got {:?}", first.kind);
3850 };
3851 let AccountEventKind::OrderSnapshot(snap) = second.kind else {
3852 panic!(
3853 "second event must be the OrderSnapshot, got {:?}",
3854 second.kind
3855 );
3856 };
3857 (trade, snap.0)
3858 }
3859
3860 #[test]
3864 fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
3865 let report = ExecutionReport {
3866 execution_type: Some("TRADE".to_string()),
3867 order_status: Some("PARTIALLY_FILLED".to_string()),
3868 side: Some("BUY".to_string()),
3869 order_type: Some("LIMIT".to_string()),
3870 price: Some("50000.00".to_string()),
3871 order_quantity: Some("0.01".to_string()),
3872 time_in_force: Some("GTC".to_string()),
3873 trade_id: Some(9999),
3874 last_executed_price: Some("50000.00".to_string()),
3875 last_executed_quantity: Some("0.004".to_string()),
3876 cumulative_filled_quantity: Some("0.004".to_string()),
3877 ..make_base_report()
3878 };
3879
3880 let (trade, order) = trade_events(convert(report));
3881 assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
3882
3883 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3884 else {
3885 panic!("expected an Open snapshot, got {:?}", order.state);
3886 };
3887 assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
3888 assert_eq!(
3889 open.quantity_remaining(order.quantity),
3890 Decimal::from_str("0.006").unwrap()
3891 );
3892 }
3893
3894 #[test]
3897 fn a_filled_report_reports_nothing_left_to_fill() {
3898 let report = ExecutionReport {
3899 execution_type: Some("TRADE".to_string()),
3900 order_status: Some("FILLED".to_string()),
3901 side: Some("BUY".to_string()),
3902 order_type: Some("LIMIT".to_string()),
3903 price: Some("50000.00".to_string()),
3904 order_quantity: Some("0.01".to_string()),
3905 time_in_force: Some("GTC".to_string()),
3906 trade_id: Some(10_000),
3907 last_executed_price: Some("50000.00".to_string()),
3908 last_executed_quantity: Some("0.006".to_string()),
3909 cumulative_filled_quantity: Some("0.01".to_string()),
3910 ..make_base_report()
3911 };
3912
3913 let (_trade, order) = trade_events(convert(report));
3914 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3915 else {
3916 panic!("expected an Open snapshot, got {:?}", order.state);
3917 };
3918 assert!(
3919 open.quantity_remaining(order.quantity).is_zero(),
3920 "a completed order must report nothing left to fill so the engine retires it"
3921 );
3922 }
3923
3924 #[test]
3928 fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
3929 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
3930 let report = ExecutionReport {
3931 execution_type: Some("TRADE".to_string()),
3932 order_status: Some(status.to_string()),
3933 side: Some("BUY".to_string()),
3934 order_type: Some("LIMIT".to_string()),
3935 price: Some("50000.00".to_string()),
3936 order_quantity: Some("0.01".to_string()),
3937 time_in_force: Some("GTC".to_string()),
3938 trade_id: Some(9999),
3939 last_executed_price: Some("50000.00".to_string()),
3940 last_executed_quantity: Some("0.004".to_string()),
3941 cumulative_filled_quantity: Some("0.004".to_string()),
3942 ..make_base_report()
3943 };
3944 let events = convert(report);
3945 assert_eq!(
3946 events.len(),
3947 1,
3948 "status {status} must produce the execution alone, got: {events:?}"
3949 );
3950 assert!(
3951 matches!(events[0].kind, AccountEventKind::Trade(_)),
3952 "the execution must still be reported for status {status}"
3953 );
3954 }
3955 }
3956
3957 #[test]
3958 fn test_convert_execution_report_trade_missing_last_price() {
3959 let report = ExecutionReport {
3961 execution_type: Some("TRADE".to_string()),
3962 side: Some("BUY".to_string()),
3963 trade_id: Some(9999),
3964 last_executed_price: None, last_executed_quantity: Some("0.01".to_string()),
3966 ..make_base_report()
3967 };
3968 assert!(
3969 sole_event(convert(report)).is_none(),
3970 "missing last price (L) should return None"
3971 );
3972 }
3973
3974 #[test]
3975 fn test_convert_execution_report_trade_missing_last_qty() {
3976 let report = ExecutionReport {
3978 execution_type: Some("TRADE".to_string()),
3979 side: Some("BUY".to_string()),
3980 trade_id: Some(9999),
3981 last_executed_price: Some("50000.00".to_string()),
3982 last_executed_quantity: None, ..make_base_report()
3984 };
3985 assert!(
3986 sole_event(convert(report)).is_none(),
3987 "missing last qty (l) should return None"
3988 );
3989 }
3990
3991 #[test]
3992 fn test_convert_execution_report_canceled() {
3993 let report = ExecutionReport {
3994 execution_type: Some("CANCELED".to_string()),
3995 ..make_base_report()
3996 };
3997
3998 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3999 assert!(
4000 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
4001 "CANCELED should yield OrderCancelled with Ok state"
4002 );
4003 }
4004
4005 #[test]
4007 fn a_cancel_report_carries_its_fill_or_none() {
4008 let cancelled = |z: Option<&str>| {
4009 let report = ExecutionReport {
4010 execution_type: Some("CANCELED".to_string()),
4011 cumulative_filled_quantity: z.map(str::to_string),
4012 ..make_base_report()
4013 };
4014 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
4015 let AccountEventKind::OrderCancelled(response) = event.kind else {
4016 panic!("expected OrderCancelled, got {:?}", event.kind);
4017 };
4018 let Ok(cancelled) = response.state else {
4019 panic!("expected Ok, got {:?}", response.state);
4020 };
4021 cancelled.filled_quantity
4022 };
4023
4024 assert_eq!(cancelled(Some("0.5")), Some(Decimal::new(5, 1)));
4025 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
4026 assert_eq!(cancelled(None), None);
4027 assert_eq!(cancelled(Some("not a number")), None);
4028 }
4029
4030 #[test]
4031 fn test_convert_execution_report_expired() {
4032 let report = ExecutionReport {
4033 execution_type: Some("EXPIRED".to_string()),
4034 ..make_base_report()
4035 };
4036 let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
4037 assert!(
4038 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
4039 "EXPIRED should yield OrderCancelled with Ok state"
4040 );
4041 }
4042
4043 #[test]
4044 fn test_convert_execution_report_expired_in_match() {
4045 let report = ExecutionReport {
4046 execution_type: Some("EXPIRED_IN_MATCH".to_string()),
4047 ..make_base_report()
4048 };
4049 let event = sole_event(convert(report)).expect("EXPIRED_IN_MATCH should produce Some");
4050 assert!(
4051 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
4052 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
4053 );
4054 }
4055
4056 #[test]
4057 fn test_convert_execution_report_rejected() {
4058 let report = ExecutionReport {
4059 execution_type: Some("REJECTED".to_string()),
4060 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
4061 ..make_base_report()
4062 };
4063
4064 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
4065 assert!(
4066 matches!(
4067 event.kind,
4068 AccountEventKind::OrderCancelled(ref r) if matches!(
4069 &r.state,
4070 Err(OrderError::Rejected(ApiError::BalanceInsufficient(None, reason)))
4071 if reason == "INSUFFICIENT_FUNDS"
4072 )
4073 ),
4074 "a REJECTED shortfall should go through the classifier, got {:?}",
4075 event.kind
4076 );
4077 }
4078
4079 #[test]
4080 fn test_convert_execution_report_rejected_for_another_reason() {
4081 let report = ExecutionReport {
4082 execution_type: Some("REJECTED".to_string()),
4083 reject_reason: Some("WOULD_MATCH_IMMEDIATELY".to_string()),
4084 ..make_base_report()
4085 };
4086
4087 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
4088 assert!(
4089 matches!(
4090 event.kind,
4091 AccountEventKind::OrderCancelled(ref r) if matches!(
4092 &r.state,
4093 Err(OrderError::Rejected(ApiError::OrderRejected(reason)))
4094 if reason == "WOULD_MATCH_IMMEDIATELY"
4095 )
4096 ),
4097 "an unrecognised REJECTED reason should stay OrderRejected, got {:?}",
4098 event.kind
4099 );
4100 }
4101
4102 #[test]
4103 fn test_convert_execution_report_missing_exec_type() {
4104 let report = ExecutionReport {
4106 symbol: Some("BTCUSDT".to_string()),
4107 ..Default::default()
4108 };
4109 assert!(
4110 sole_event(convert(report)).is_none(),
4111 "missing execution type should return None"
4112 );
4113 }
4114
4115 #[test]
4116 fn test_convert_execution_report_missing_symbol() {
4117 let report = ExecutionReport {
4119 execution_type: Some("NEW".to_string()),
4120 ..Default::default()
4121 };
4122 assert!(
4123 sole_event(convert(report)).is_none(),
4124 "missing symbol should return None"
4125 );
4126 }
4127
4128 #[test]
4129 fn test_convert_execution_report_missing_order_id() {
4130 let report = ExecutionReport {
4132 execution_type: Some("NEW".to_string()),
4133 symbol: Some("BTCUSDT".to_string()),
4134 order_id: None,
4135 ..Default::default()
4136 };
4137 assert!(
4138 sole_event(convert(report)).is_none(),
4139 "missing orderId should return None"
4140 );
4141 }
4142
4143 #[test]
4144 fn test_convert_execution_report_trade_missing_trade_id() {
4145 let report = ExecutionReport {
4147 execution_type: Some("TRADE".to_string()),
4148 trade_id: None,
4149 ..make_base_report()
4150 };
4151 assert!(
4152 sole_event(convert(report)).is_none(),
4153 "TRADE event missing tradeId should return None"
4154 );
4155 }
4156
4157 #[test]
4158 fn test_convert_execution_report_replace_yields_cancelled() {
4159 let report = ExecutionReport {
4160 execution_type: Some("REPLACE".to_string()),
4161 ..make_base_report()
4162 };
4163 let event = sole_event(convert(report)).expect("REPLACE should produce Some");
4166 assert!(
4167 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
4168 "REPLACE should yield OrderCancelled with Ok state"
4169 );
4170 }
4171
4172 fn make_balance(
4177 asset: &str,
4178 free: &str,
4179 locked: &str,
4180 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4181 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4182 asset: Some(asset.to_string()),
4183 free: Some(free.to_string()),
4184 locked: Some(locked.to_string()),
4185 }
4186 }
4187
4188 #[test]
4189 fn test_filter_balances_empty_assets_returns_all() {
4190 let balances = vec![
4191 make_balance("BTC", "1.0", "0.0"),
4192 make_balance("USDT", "500.0", "50.0"),
4193 ];
4194 let result = filter_and_convert_balances(balances, &[]);
4195 assert_eq!(result.len(), 2, "empty filter should return all balances");
4196 }
4197
4198 #[test]
4199 fn test_filter_balances_matching_asset_returned() {
4200 let balances = vec![
4201 make_balance("BTC", "1.5", "0.5"),
4202 make_balance("ETH", "10.0", "0.0"),
4203 ];
4204 let assets = vec![AssetNameExchange::new("BTC")];
4205 let result = filter_and_convert_balances(balances, &assets);
4206 assert_eq!(result.len(), 1);
4207 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
4208 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
4210 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
4211 }
4212
4213 #[test]
4214 fn test_filter_balances_non_matching_asset_filtered_out() {
4215 let balances = vec![
4216 make_balance("BTC", "1.0", "0.0"),
4217 make_balance("ETH", "2.0", "0.0"),
4218 ];
4219 let assets = vec![AssetNameExchange::new("USDT")];
4220 let result = filter_and_convert_balances(balances, &assets);
4221 assert!(
4222 result.is_empty(),
4223 "non-matching asset should be filtered out"
4224 );
4225 }
4226
4227 #[test]
4228 fn test_filter_balances_missing_asset_field_skipped() {
4229 let balances = vec![
4230 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4231 asset: None,
4232 free: Some("1.0".to_string()),
4233 locked: Some("0.0".to_string()),
4234 },
4235 make_balance("USDT", "100.0", "0.0"),
4236 ];
4237 let result = filter_and_convert_balances(balances, &[]);
4238 assert_eq!(
4239 result.len(),
4240 1,
4241 "entry with missing asset should be skipped"
4242 );
4243 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
4244 }
4245
4246 #[test]
4247 fn test_filter_balances_unparseable_free_skipped() {
4248 let balances = vec![
4249 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4250 asset: Some("BTC".to_string()),
4251 free: Some("not-a-number".to_string()),
4252 locked: Some("0.0".to_string()),
4253 },
4254 ];
4255 let result = filter_and_convert_balances(balances, &[]);
4256 assert!(
4257 result.is_empty(),
4258 "unparseable free field should be skipped"
4259 );
4260 }
4261
4262 #[test]
4263 fn test_filter_balances_zero_balance_included() {
4264 let balances = vec![
4267 make_balance("BTC", "0.00000000", "0.00000000"),
4268 make_balance("USDT", "100.0", "0.0"),
4269 ];
4270 let result = filter_and_convert_balances(balances, &[]);
4271 assert_eq!(result.len(), 2, "zero-balance entries must be included");
4272 let btc = result
4273 .iter()
4274 .find(|b| b.asset == AssetNameExchange::new("BTC"))
4275 .unwrap();
4276 assert_eq!(btc.balance.total, Decimal::ZERO);
4277 assert_eq!(btc.balance.free, Decimal::ZERO);
4278 }
4279
4280 #[test]
4281 fn test_filter_balances_duplicate_assets_in_response() {
4282 let balances = vec![
4286 make_balance("BTC", "1.0", "0.0"),
4287 make_balance("BTC", "2.0", "0.0"),
4288 ];
4289 let result = filter_and_convert_balances(balances, &[]);
4290 assert_eq!(
4291 result.len(),
4292 2,
4293 "duplicate asset entries produce two AssetBalance entries"
4294 );
4295 }
4296
4297 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
4302 binance_sdk::spot::rest_api::MyTradesResponseInner {
4303 id: Some(9001),
4304 order_id: Some(4242),
4305 price: Some("50000.00".to_string()),
4306 qty: Some("0.01".to_string()),
4307 commission: Some("0.05".to_string()),
4308 time: Some(1_700_000_000_000),
4309 is_buyer: Some(true),
4310 ..Default::default()
4311 }
4312 }
4313
4314 #[test]
4315 fn test_convert_my_trade_happy_path() {
4316 let instrument = InstrumentNameExchange::new("BTCUSDT");
4317 let trade =
4318 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
4319 assert_eq!(trade.instrument, instrument);
4320 assert_eq!(trade.side, Side::Buy);
4321 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
4322 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
4323 }
4324
4325 #[test]
4326 fn test_convert_my_trade_sell_side() {
4327 let instrument = InstrumentNameExchange::new("BTCUSDT");
4328 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4329 is_buyer: Some(false),
4330 ..make_base_trade()
4331 };
4332 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
4333 assert_eq!(trade.side, Side::Sell);
4334 }
4335
4336 #[test]
4337 fn test_convert_my_trade_missing_id_returns_none() {
4338 let instrument = InstrumentNameExchange::new("BTCUSDT");
4339 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4340 id: None,
4341 ..make_base_trade()
4342 };
4343 assert!(
4344 convert_my_trade(&t, &instrument).is_none(),
4345 "missing id should return None"
4346 );
4347 }
4348
4349 #[test]
4350 fn test_convert_my_trade_missing_order_id_returns_none() {
4351 let instrument = InstrumentNameExchange::new("BTCUSDT");
4352 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4353 order_id: None,
4354 ..make_base_trade()
4355 };
4356 assert!(
4357 convert_my_trade(&t, &instrument).is_none(),
4358 "missing orderId should return None"
4359 );
4360 }
4361
4362 #[test]
4363 fn test_convert_my_trade_missing_is_buyer_returns_none() {
4364 let instrument = InstrumentNameExchange::new("BTCUSDT");
4365 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4366 is_buyer: None,
4367 ..make_base_trade()
4368 };
4369 assert!(
4370 convert_my_trade(&t, &instrument).is_none(),
4371 "missing isBuyer should return None"
4372 );
4373 }
4374
4375 #[test]
4376 fn test_convert_my_trade_commission_none_defaults_to_zero() {
4377 let instrument = InstrumentNameExchange::new("BTCUSDT");
4378 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4379 commission: None,
4380 ..make_base_trade()
4381 };
4382 let trade =
4383 convert_my_trade(&t, &instrument).expect("None commission should still convert");
4384 assert_eq!(trade.fees.fees, Decimal::ZERO);
4385 }
4386
4387 fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4392 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4393 order_id: Some(12345),
4394 client_order_id: Some("cid-abc".to_string()),
4395 side: Some("BUY".to_string()),
4396 r#type: Some("LIMIT".to_string()),
4397 price: Some("50000.00".to_string()),
4398 orig_qty: Some("0.01".to_string()),
4399 executed_qty: Some("0.0".to_string()),
4400 time_in_force: Some("GTC".to_string()),
4401 time: Some(1_700_000_000_000),
4402 status: Some("NEW".to_string()),
4403 ..Default::default()
4404 }
4405 }
4406
4407 #[test]
4408 fn test_convert_open_order_all_orders_response_converts_identically() {
4409 let instrument = InstrumentNameExchange::new("BTCUSDT");
4416 let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4417 order_id: Some(12345),
4418 client_order_id: Some("cid-abc".to_string()),
4419 side: Some("BUY".to_string()),
4420 r#type: Some("LIMIT".to_string()),
4421 price: Some("50000.00".to_string()),
4422 orig_qty: Some("0.01".to_string()),
4423 executed_qty: Some("0.0".to_string()),
4424 time_in_force: Some("GTC".to_string()),
4425 time: Some(1_700_000_000_000),
4426 status: Some("NEW".to_string()),
4427 ..Default::default()
4428 };
4429 let from_all_orders = convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument)
4430 .expect("valid order should convert");
4431 let from_open_orders = convert_open_order(
4432 &make_base_open_order(),
4433 ExchangeId::BinanceSpot,
4434 &instrument,
4435 )
4436 .expect("valid order should convert");
4437 assert_eq!(from_all_orders, from_open_orders);
4438 }
4439
4440 #[test]
4446 fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
4447 let instrument = InstrumentNameExchange::new("BTCUSDT");
4448 for status in [
4449 "CANCELED",
4450 "FILLED",
4451 "EXPIRED",
4452 "EXPIRED_IN_MATCH",
4453 "REJECTED",
4454 "PENDING_CANCEL",
4455 "SOME_FUTURE_STATUS",
4456 ] {
4457 let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4458 order_id: Some(12345),
4459 client_order_id: Some("cid-abc".to_string()),
4460 side: Some("BUY".to_string()),
4461 r#type: Some("LIMIT".to_string()),
4462 price: Some("50000.00".to_string()),
4463 orig_qty: Some("0.01".to_string()),
4464 executed_qty: Some("0.004".to_string()),
4465 time_in_force: Some("GTC".to_string()),
4466 time: Some(1_700_000_000_000),
4467 status: Some(status.to_string()),
4468 ..Default::default()
4469 };
4470 assert_eq!(
4471 convert_open_order(&row, ExchangeId::BinanceSpot, &instrument),
4472 None,
4473 "{status}"
4474 );
4475 }
4476 }
4477
4478 #[test]
4479 fn test_convert_open_order_admits_every_live_status() {
4480 let instrument = InstrumentNameExchange::new("BTCUSDT");
4481 for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
4482 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4483 status: Some(status.to_string()),
4484 ..make_base_open_order()
4485 };
4486 assert!(
4487 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_some(),
4488 "{status}"
4489 );
4490 }
4491 }
4492
4493 #[test]
4498 fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
4499 let instrument = InstrumentNameExchange::new("BTCUSDT");
4500 let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4501 order_id: Some(67890),
4502 client_order_id: Some("cid-def".to_string()),
4503 ..make_base_open_order()
4504 };
4505
4506 let listing = convert_open_order_listing(
4507 &[make_base_open_order(), second()],
4508 ExchangeId::BinanceSpot,
4509 &instrument,
4510 );
4511 assert_eq!(listing.orders.len(), 2);
4512 assert!(listing.complete);
4513
4514 let empty = convert_open_order_listing::<
4515 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
4516 >(&[], ExchangeId::BinanceSpot, &instrument);
4517 assert!(empty.orders.is_empty());
4518 assert!(empty.complete, "no open orders is a complete answer");
4519
4520 let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4521 side: None,
4522 ..second()
4523 };
4524 let listing = convert_open_order_listing(
4525 &[make_base_open_order(), dropped],
4526 ExchangeId::BinanceSpot,
4527 &instrument,
4528 );
4529 assert_eq!(listing.orders.len(), 1);
4530 assert!(!listing.complete, "a dropped row may be a live order");
4531
4532 let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4533 client_order_id: None,
4534 ..second()
4535 };
4536 let listing = convert_open_order_listing(
4537 &[make_base_open_order(), without_cid],
4538 ExchangeId::BinanceSpot,
4539 &instrument,
4540 );
4541 assert_eq!(listing.orders.len(), 2, "the row is still converted");
4542 assert!(
4543 !listing.complete,
4544 "an order kept under its orderId cannot be found under the cid it was placed with"
4545 );
4546 }
4547
4548 #[test]
4549 fn test_convert_open_order_missing_status_returns_none() {
4550 let instrument = InstrumentNameExchange::new("BTCUSDT");
4551 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4552 status: None,
4553 ..make_base_open_order()
4554 };
4555 assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none());
4556 }
4557
4558 #[test]
4564 fn test_convert_open_order_prefers_update_time_over_creation_time() {
4565 let instrument = InstrumentNameExchange::new("BTCUSDT");
4566
4567 let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4568 update_time: Some(1_700_000_005_000),
4569 ..make_base_open_order()
4570 };
4571 let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument)
4572 .expect("valid order should convert");
4573 assert_eq!(
4574 order.state.time_exchange,
4575 Utc.timestamp_millis_opt(1_700_000_005_000)
4576 .single()
4577 .unwrap(),
4578 "update_time must win over time"
4579 );
4580
4581 let order = convert_open_order(
4584 &make_base_open_order(),
4585 ExchangeId::BinanceSpot,
4586 &instrument,
4587 )
4588 .expect("valid order should convert");
4589 assert_eq!(
4590 order.state.time_exchange,
4591 Utc.timestamp_millis_opt(1_700_000_000_000)
4592 .single()
4593 .unwrap(),
4594 "absent update_time falls back to time"
4595 );
4596 }
4597
4598 #[test]
4599 fn test_convert_open_order_happy_path() {
4600 let instrument = InstrumentNameExchange::new("BTCUSDT");
4601 let order = convert_open_order(
4602 &make_base_open_order(),
4603 ExchangeId::BinanceSpot,
4604 &instrument,
4605 )
4606 .expect("valid order should convert");
4607 assert_eq!(order.key.instrument, instrument);
4608 assert_eq!(order.side, Side::Buy);
4609 assert_eq!(order.kind, OrderKind::Limit);
4610 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
4611 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
4612 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
4613 }
4614
4615 #[test]
4616 fn test_convert_open_order_owned_symbol_recovers_instrument() {
4617 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4619 symbol: Some("ETHUSDT".to_string()),
4620 ..make_base_open_order()
4621 };
4622 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot)
4623 .expect("valid order should convert");
4624 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4625 assert_eq!(order.side, Side::Buy);
4626 }
4627
4628 #[test]
4629 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
4630 let o = make_base_open_order();
4632 assert!(o.symbol.is_none());
4633 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot).is_none());
4634 }
4635
4636 #[test]
4637 fn test_convert_open_order_missing_order_id_returns_none() {
4638 let instrument = InstrumentNameExchange::new("BTCUSDT");
4639 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4640 order_id: None,
4641 ..make_base_open_order()
4642 };
4643 assert!(
4644 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4645 "missing orderId should return None"
4646 );
4647 }
4648
4649 #[test]
4650 fn test_convert_open_order_missing_side_returns_none() {
4651 let instrument = InstrumentNameExchange::new("BTCUSDT");
4652 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4653 side: None,
4654 ..make_base_open_order()
4655 };
4656 assert!(
4657 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4658 "missing side should return None"
4659 );
4660 }
4661
4662 #[test]
4663 fn test_convert_open_order_missing_type_returns_none() {
4664 let instrument = InstrumentNameExchange::new("BTCUSDT");
4665 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4666 r#type: None,
4667 ..make_base_open_order()
4668 };
4669 assert!(
4670 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4671 "missing type should return None"
4672 );
4673 }
4674
4675 #[test]
4676 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
4677 let instrument = InstrumentNameExchange::new("BTCUSDT");
4678 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4679 executed_qty: None,
4680 ..make_base_open_order()
4681 };
4682 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4683 .expect("None executedQty should still convert");
4684 assert_eq!(
4685 order.state.filled_quantity,
4686 Decimal::ZERO,
4687 "None executedQty should default to zero"
4688 );
4689 }
4690
4691 #[test]
4692 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
4693 let instrument = InstrumentNameExchange::new("BTCUSDT");
4694 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4695 executed_qty: Some("bad-value".to_string()),
4696 ..make_base_open_order()
4697 };
4698 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4699 .expect("unparseable executedQty should still convert");
4700 assert_eq!(
4701 order.state.filled_quantity,
4702 Decimal::ZERO,
4703 "unparseable executedQty should default to zero"
4704 );
4705 }
4706
4707 #[test]
4712 fn test_dedup_key_from_event_non_open_states_return_none() {
4713 use crate::order::state::{
4714 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
4715 };
4716 use rustrade_integration::collection::snapshot::Snapshot;
4717
4718 let key = OrderKey::new(
4719 ExchangeId::BinanceSpot,
4720 InstrumentNameExchange::new("BTCUSDT"),
4721 StrategyId::unknown(),
4722 ClientOrderId::new("cid1"),
4723 );
4724
4725 let event = UnindexedAccountEvent::new(
4727 ExchangeId::BinanceSpot,
4728 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4729 key.clone(),
4730 Side::Buy,
4731 None, Decimal::ZERO,
4733 OrderKind::Market,
4734 TimeInForce::ImmediateOrCancel,
4735 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4736 ActiveOrderState::OpenInFlight(OpenInFlight::new(Utc::now())),
4737 ),
4738 ))),
4739 );
4740 assert!(
4741 dedup_key_from_event(&event).is_none(),
4742 "OpenInFlight should return None — dedup not meaningful before exchange ack"
4743 );
4744
4745 let event = UnindexedAccountEvent::new(
4747 ExchangeId::BinanceSpot,
4748 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4749 key.clone(),
4750 Side::Buy,
4751 None, Decimal::ZERO,
4753 OrderKind::Market,
4754 TimeInForce::ImmediateOrCancel,
4755 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4756 ActiveOrderState::CancelInFlight(CancelInFlight::new(None, Utc::now())),
4757 ),
4758 ))),
4759 );
4760 assert!(
4761 dedup_key_from_event(&event).is_none(),
4762 "CancelInFlight should return None"
4763 );
4764
4765 let event = UnindexedAccountEvent::new(
4767 ExchangeId::BinanceSpot,
4768 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4769 key,
4770 Side::Buy,
4771 None, Decimal::ONE,
4773 OrderKind::Market,
4774 TimeInForce::ImmediateOrCancel,
4775 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
4776 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
4777 OrderId::new("123"),
4778 Utc::now(),
4779 Decimal::ONE, None,
4781 )),
4782 ),
4783 ))),
4784 );
4785 assert!(
4786 dedup_key_from_event(&event).is_none(),
4787 "Inactive state should return None"
4788 );
4789 }
4790
4791 #[test]
4792 fn test_dedup_key_from_event_cancelled_error_returns_none() {
4793 let report = ExecutionReport {
4796 execution_type: Some("REJECTED".to_string()),
4797 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
4798 ..make_base_report()
4799 };
4800 let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
4801 assert!(
4802 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
4803 "prerequisite: event is OrderCancelled with Err"
4804 );
4805 assert!(
4806 dedup_key_from_event(&event).is_none(),
4807 "OrderCancelled with Err state should return None"
4808 );
4809 }
4810
4811 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
4821 let mut value = serde_json::to_value(event).expect("event serializes to Value");
4822 value
4823 .as_object_mut()
4824 .expect("event serializes to a JSON object")
4825 .insert(
4826 "e".to_string(),
4827 serde_json::Value::String(event_type.to_string()),
4828 );
4829 serde_json::json!({ "subscriptionId": 0, "event": value }).to_string()
4830 }
4831
4832 #[test]
4833 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
4834 let report = ExecutionReport {
4835 execution_type: Some("NEW".to_string()),
4836 side: Some("BUY".to_string()),
4837 order_type: Some("LIMIT".to_string()),
4838 price: Some("50000.00".to_string()),
4839 order_quantity: Some("0.01".to_string()),
4840 time_in_force: Some("GTC".to_string()),
4841 cumulative_filled_quantity: Some("0".to_string()),
4842 ..make_base_report()
4843 };
4844 let frame = user_data_frame("executionReport", &report);
4845 let mut buf = Vec::new();
4846 let terminated = convert_user_data_events(&frame, &mut buf);
4847 assert!(
4848 !terminated,
4849 "ExecutionReport should not signal stream termination"
4850 );
4851 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
4852 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
4853 }
4854
4855 #[test]
4856 fn test_convert_user_data_events_account_position_pushes_to_buf() {
4857 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4858 u: Some(1_700_000_000_000),
4859 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
4860 ..Default::default()
4861 };
4862 let frame = user_data_frame("outboundAccountPosition", &position);
4863 let mut buf = Vec::new();
4864 let terminated = convert_user_data_events(&frame, &mut buf);
4865 assert!(
4866 !terminated,
4867 "OutboundAccountPosition should not signal stream termination"
4868 );
4869 assert_eq!(
4870 buf.len(),
4871 1,
4872 "OutboundAccountPosition should push one balance event"
4873 );
4874 }
4875
4876 #[test]
4877 fn test_convert_user_data_events_balance_update_ignored() {
4878 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4879 ..Default::default()
4880 };
4881 let frame = user_data_frame("balanceUpdate", &update);
4882 let mut buf = Vec::new();
4883 let terminated = convert_user_data_events(&frame, &mut buf);
4884 assert!(
4885 !terminated,
4886 "BalanceUpdate should not signal stream termination"
4887 );
4888 assert!(buf.is_empty(), "BalanceUpdate should push no events");
4889 }
4890
4891 #[test]
4892 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4893 let frame =
4895 r#"{"subscriptionId":0,"event":{"e":"eventStreamTerminated","E":1700000000000}}"#;
4896 let mut buf = Vec::new();
4897 let terminated = convert_user_data_events(frame, &mut buf);
4898 assert!(
4899 terminated,
4900 "eventStreamTerminated must signal stream termination"
4901 );
4902 assert!(
4903 buf.is_empty(),
4904 "eventStreamTerminated should push no events"
4905 );
4906 }
4907
4908 #[test]
4909 fn test_convert_user_data_events_unknown_event_ignored() {
4910 for e in ["listStatus", "externalLockUpdate", "someFutureEvent"] {
4913 let frame = format!(
4914 r#"{{"subscriptionId":0,"event":{{"e":"{e}","E":1700000000000,"s":"BTCUSDT"}}}}"#
4915 );
4916 let mut buf = Vec::new();
4917 let terminated = convert_user_data_events(&frame, &mut buf);
4918 assert!(!terminated, "{e} must not signal termination");
4919 assert!(buf.is_empty(), "{e} should push no events");
4920 }
4921 }
4922
4923 #[test]
4928 fn test_convert_user_data_events_reads_the_ws_api_envelope() {
4929 let fill = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000001000,
4930 "s":"BTCUSDT","c":"client-1","S":"SELL","o":"MARKET","f":"GTC","q":"0.5","p":"0",
4931 "x":"TRADE","X":"FILLED","i":777,"l":"0.5","z":"0.5","L":"40000","n":"0.02",
4932 "N":"USDT","T":1700000001000,"t":888}}"#;
4933 let mut buf = Vec::new();
4934 assert!(!convert_user_data_events(fill, &mut buf));
4935 assert!(
4936 buf.iter()
4937 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
4938 "the enveloped fill must produce its trade, got: {buf:?}"
4939 );
4940
4941 let bare = r#"{"e":"executionReport","s":"BTCUSDT","i":777,"x":"NEW","X":"NEW"}"#;
4943 buf.clear();
4944 assert!(!convert_user_data_events(bare, &mut buf));
4945 assert!(buf.is_empty());
4946 }
4947
4948 #[test]
4949 fn test_convert_user_data_events_non_user_data_frame_ignored() {
4950 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4952 let mut buf = Vec::new();
4953 let terminated = convert_user_data_events(frame, &mut buf);
4954 assert!(
4955 !terminated,
4956 "non-user-data frame must not signal termination"
4957 );
4958 assert!(buf.is_empty(), "non-user-data frame should push no events");
4959 }
4960
4961 #[tokio::test]
4966 async fn test_rate_limit_tracker_not_blocked_initially() {
4967 tokio::time::pause();
4968 let tracker = RateLimitTracker::new(WeightPool::Spot);
4969 tokio::time::timeout(
4971 std::time::Duration::from_millis(1),
4972 tracker.wait_if_blocked(RequestKind::Order),
4973 )
4974 .await
4975 .expect("wait_if_blocked should return immediately when not blocked")
4976 .expect("no cooldown too long to sleep through");
4977 }
4978
4979 #[tokio::test]
4980 async fn test_rate_limit_tracker_blocks_until_deadline() {
4981 tokio::time::pause();
4982 let tracker = RateLimitTracker::new(WeightPool::Spot);
4983 let delay = Duration::from_secs(5);
4984 tracker.on_rate_limited(Some(delay));
4985
4986 assert!(
4988 tokio::time::timeout(
4989 Duration::from_millis(1),
4990 tracker.wait_if_blocked(RequestKind::Order)
4991 )
4992 .await
4993 .is_err(),
4994 "wait_if_blocked should block while cooldown is active"
4995 );
4996
4997 tokio::time::advance(delay + Duration::from_millis(1)).await;
4999 tokio::time::timeout(
5000 Duration::from_millis(1),
5001 tracker.wait_if_blocked(RequestKind::Order),
5002 )
5003 .await
5004 .expect("wait_if_blocked should return after cooldown expires")
5005 .expect("no cooldown too long to sleep through");
5006 }
5007
5008 #[tokio::test]
5009 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
5010 tokio::time::pause();
5011 let tracker = RateLimitTracker::new(WeightPool::Spot);
5012
5013 tracker.on_rate_limited(Some(Duration::from_secs(5)));
5015 tracker.on_rate_limited(Some(Duration::from_secs(10)));
5017
5018 tokio::time::advance(Duration::from_secs(6)).await;
5020 assert!(
5021 tokio::time::timeout(
5022 Duration::from_millis(1),
5023 tracker.wait_if_blocked(RequestKind::Order)
5024 )
5025 .await
5026 .is_err(),
5027 "cooldown should have been extended to 10s"
5028 );
5029
5030 tokio::time::advance(Duration::from_secs(5)).await;
5032 tokio::time::timeout(
5033 Duration::from_millis(1),
5034 tracker.wait_if_blocked(RequestKind::Order),
5035 )
5036 .await
5037 .expect("wait_if_blocked should return after extended cooldown expires")
5038 .expect("no cooldown too long to sleep through");
5039 }
5040
5041 #[tokio::test]
5042 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
5043 tokio::time::pause();
5044 let tracker = RateLimitTracker::new(WeightPool::Spot);
5045
5046 tracker.on_rate_limited(Some(Duration::from_secs(10)));
5048 tracker.on_rate_limited(Some(Duration::from_secs(2)));
5049
5050 tokio::time::advance(Duration::from_secs(3)).await;
5052 assert!(
5053 tokio::time::timeout(
5054 Duration::from_millis(1),
5055 tracker.wait_if_blocked(RequestKind::Order)
5056 )
5057 .await
5058 .is_err(),
5059 "shorter on_rate_limited must not shorten existing cooldown"
5060 );
5061 }
5062
5063 #[test]
5068 fn test_classify_ws_order_error_with_wrapped_error_chain() {
5069 let instrument = InstrumentNameExchange::new("BTCUSDT");
5074 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
5075 code: -2010,
5076 message: "insufficient balance".into(),
5077 retry_after: None,
5078 });
5079 assert!(
5081 classify_ws_order_error(&raw, &instrument).is_some(),
5082 "unwrapped ResponseError at root must be detected"
5083 );
5084 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
5086 assert!(
5087 classify_ws_order_error(&wrapped, &instrument).is_some(),
5088 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
5089 );
5090 }
5091
5092 #[tokio::test]
5095 async fn rest_used_weight_near_the_limit_pauses_queries() {
5096 for (used, pauses) in [(5_399, false), (5_400, true)] {
5097 let server = wiremock::MockServer::start().await;
5098 wiremock::Mock::given(wiremock::matchers::method("GET"))
5099 .and(wiremock::matchers::path("/api/v3/openOrders"))
5100 .respond_with(
5101 wiremock::ResponseTemplate::new(200)
5102 .insert_header("x-mbx-used-weight-1m", used.to_string())
5103 .set_body_json(serde_json::json!([])),
5104 )
5105 .mount(&server)
5106 .await;
5107 let rest = Arc::new(SpotRestApi::from_config(
5108 ConfigurationRestApi::builder()
5109 .api_key("key")
5110 .api_secret("secret")
5111 .base_path(server.uri())
5112 .build()
5113 .unwrap(),
5114 ));
5115 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5116
5117 fetch_all_open_orders(rest, Arc::clone(&tracker))
5118 .await
5119 .unwrap();
5120
5121 let query_waits = tokio::time::timeout(
5122 Duration::from_millis(50),
5123 tracker.wait_if_blocked(RequestKind::Query),
5124 )
5125 .await
5126 .is_err();
5127 assert_eq!(query_waits, pauses, "used weight {used}");
5128 tokio::time::timeout(
5129 Duration::from_millis(50),
5130 tracker.wait_if_blocked(RequestKind::Order),
5131 )
5132 .await
5133 .expect("orders never wait on the pause")
5134 .expect("no cooldown too long to sleep through");
5135 }
5136 }
5137
5138 fn client_with_ws_api(ws_url: String) -> BinanceSpot {
5140 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5141 "key".into(),
5142 "secret".into(),
5143 ));
5144 client.ws_handle = SpotWsApi::from_config(
5145 ConfigurationWebsocketApi::builder()
5146 .api_key("key")
5147 .api_secret("secret")
5148 .ws_url(ws_url)
5149 .build()
5150 .unwrap(),
5151 );
5152 client
5153 }
5154
5155 fn cancel_request(
5157 instrument: &InstrumentNameExchange,
5158 ) -> OrderRequestCancel<ExchangeId, &InstrumentNameExchange> {
5159 crate::order::OrderEvent {
5160 key: OrderKey {
5161 exchange: ExchangeId::BinanceSpot,
5162 instrument,
5163 strategy: StrategyId::new("strategy"),
5164 cid: ClientOrderId::new("cid"),
5165 },
5166 state: crate::order::request::RequestCancel { id: None },
5167 }
5168 }
5169
5170 async fn ws_server<F, Fut>(serve: F) -> String
5172 where
5173 F: Fn(tokio::net::TcpStream) -> Fut + Send + 'static,
5174 Fut: std::future::Future<Output = ()> + Send + 'static,
5175 {
5176 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5177 let url = format!("ws://{}", listener.local_addr().unwrap());
5178 tokio::spawn(async move {
5179 while let Ok((stream, _)) = listener.accept().await {
5180 tokio::spawn(serve(stream));
5181 }
5182 });
5183 url
5184 }
5185
5186 #[tokio::test]
5189 async fn cancel_without_a_session_during_a_cooldown_is_rejected_unsent() {
5190 let client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5191 "key".into(),
5192 "secret".into(),
5193 ));
5194 client
5195 .rate_limiter
5196 .on_rate_limited(Some(Duration::from_secs(60)));
5197 let instrument = InstrumentNameExchange::new("BTCUSDT");
5198 let response = client.cancel_order(cancel_request(&instrument)).await;
5199
5200 assert_rate_limited_for(&response.state, Duration::from_secs(60));
5201 assert!(
5202 client.ws_api.read().await.is_none(),
5203 "no session was opened"
5204 );
5205 }
5206
5207 fn assert_rate_limited_for<T: std::fmt::Debug>(
5210 state: &Result<T, UnindexedOrderError>,
5211 wait: Duration,
5212 ) {
5213 let retry_after = match state {
5214 Err(UnindexedOrderError::Rejected(api @ ApiError::RateLimit { .. })) => {
5215 api.retry_after()
5216 }
5217 other => panic!("expected a rate-limit rejection, got {other:?}"),
5218 };
5219 assert!(
5220 retry_after.is_some_and(|got| got <= wait && got > wait - Duration::from_secs(5)),
5221 "expected a wait of about {wait:?}, got {retry_after:?}"
5222 );
5223 }
5224
5225 async fn ws_api_answering(
5228 answer: fn(&serde_json::Value) -> serde_json::Value,
5229 ) -> (String, Arc<std::sync::atomic::AtomicU32>) {
5230 use futures::{SinkExt as _, StreamExt as _};
5231 use tokio_tungstenite::tungstenite::Message;
5232
5233 let requests = Arc::new(std::sync::atomic::AtomicU32::new(0));
5234 let url = ws_server({
5235 let requests = Arc::clone(&requests);
5236 move |stream| {
5237 let requests = Arc::clone(&requests);
5238 async move {
5239 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5240 return;
5241 };
5242 while let Some(Ok(message)) = ws.next().await {
5243 let Message::Text(text) = message else {
5244 continue;
5245 };
5246 requests.fetch_add(1, Ordering::SeqCst);
5247 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5248 let response = answer(&request);
5249 if ws.send(Message::text(response.to_string())).await.is_err() {
5250 return;
5251 }
5252 }
5253 }
5254 }
5255 })
5256 .await;
5257 (url, requests)
5258 }
5259
5260 fn cancel_accepted(request: &serde_json::Value) -> serde_json::Value {
5262 serde_json::json!({
5263 "id": request["id"],
5264 "status": 200,
5265 "result": {
5266 "symbol": "BTCUSDT",
5267 "origClientOrderId": "cid",
5268 "orderId": 7,
5269 "transactTime": 1_700_000_000_000_i64,
5270 "executedQty": "0",
5271 },
5272 })
5273 }
5274
5275 #[tokio::test]
5278 async fn cancel_on_an_open_session_during_a_long_cooldown_is_rejected_unsent() {
5279 let (url, requests) = ws_api_answering(cancel_accepted).await;
5280 let client = client_with_ws_api(url);
5281 let instrument = InstrumentNameExchange::new("BTCUSDT");
5282 let response = client.cancel_order(cancel_request(&instrument)).await;
5283 assert!(response.state.is_ok(), "{:?}", response.state);
5284
5285 client
5286 .rate_limiter
5287 .on_rate_limited(Some(Duration::from_secs(60)));
5288 let response = client.cancel_order(cancel_request(&instrument)).await;
5289
5290 assert_rate_limited_for(&response.state, Duration::from_secs(60));
5291 assert_eq!(
5292 requests.load(Ordering::SeqCst),
5293 1,
5294 "the second was not sent"
5295 );
5296 assert!(
5297 client.ws_api.read().await.is_some(),
5298 "the session stays open"
5299 );
5300 }
5301
5302 #[tokio::test]
5306 async fn a_ws_api_ban_reports_its_wait_and_holds_back_later_requests() {
5307 let (url, requests) = ws_api_answering(banned_for_two_minutes).await;
5308 let client = client_with_ws_api(url);
5309 let instrument = InstrumentNameExchange::new("BTCUSDT");
5310
5311 let response = client.cancel_order(cancel_request(&instrument)).await;
5312 assert_rate_limited_for(&response.state, TWO_MINUTE_BAN);
5313 assert_cooldown_about(&client, TWO_MINUTE_BAN);
5314
5315 let response = client.cancel_order(cancel_request(&instrument)).await;
5316 assert_rate_limited_for(&response.state, TWO_MINUTE_BAN);
5317 assert_eq!(
5318 requests.load(Ordering::SeqCst),
5319 1,
5320 "the second was not sent"
5321 );
5322 }
5323
5324 const TWO_MINUTE_BAN: Duration = Duration::from_millis(120_000 + CLOCK_SKEW_SLACK_MS);
5326
5327 fn banned_for_two_minutes(request: &serde_json::Value) -> serde_json::Value {
5329 let until = unix_ms() + 120_000;
5330 serde_json::json!({
5331 "id": request["id"],
5332 "status": 418,
5333 "error": {
5334 "code": -1003,
5335 "msg": format!("Way too much request weight used; IP banned until {until}."),
5336 "data": { "serverTime": unix_ms(), "retryAfter": until },
5337 },
5338 })
5339 }
5340
5341 fn assert_cooldown_about(client: &BinanceSpot, wait: Duration) {
5344 let left = client.rate_limiter.cooldown_remaining();
5345 assert!(
5346 left.is_some_and(|left| left <= wait && left > wait - Duration::from_secs(5)),
5347 "expected a cooldown of about {wait:?}, got {left:?}"
5348 );
5349 }
5350
5351 #[tokio::test]
5354 async fn a_ws_api_ban_on_an_order_holds_back_later_requests() {
5355 let (url, requests) = ws_api_answering(banned_for_two_minutes).await;
5356 let client = client_with_ws_api(url);
5357 let instrument = InstrumentNameExchange::new("BTCUSDT");
5358 let request = crate::order::OrderEvent {
5359 key: OrderKey {
5360 exchange: ExchangeId::BinanceSpot,
5361 instrument: &instrument,
5362 strategy: StrategyId::new("strategy"),
5363 cid: ClientOrderId::new("cid"),
5364 },
5365 state: crate::order::request::RequestOpen::new(
5366 Side::Buy,
5367 Some(Decimal::new(50_000, 0)),
5368 Decimal::new(1, 3),
5369 OrderKind::Limit,
5370 TimeInForce::GoodUntilCancelled { post_only: false },
5371 ),
5372 };
5373
5374 let order = client.open_order(request).await;
5375
5376 let state = match order.state {
5377 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(error)) => {
5378 Err::<(), _>(error)
5379 }
5380 other => panic!("expected the open to fail, got {other:?}"),
5381 };
5382 assert_rate_limited_for(&state, TWO_MINUTE_BAN);
5383 assert_cooldown_about(&client, TWO_MINUTE_BAN);
5384 assert_eq!(requests.load(Ordering::SeqCst), 1);
5385 }
5386
5387 fn subscribed(request: &serde_json::Value) -> serde_json::Value {
5389 serde_json::json!({
5390 "id": request["id"],
5391 "status": 200,
5392 "result": { "subscriptionId": 0 },
5393 })
5394 }
5395
5396 fn rate_limited_wait<T>(opened: Result<T, UnindexedClientError>) -> Option<Duration> {
5398 match opened {
5399 Err(UnindexedClientError::Api(api @ ApiError::RateLimit { .. })) => api.retry_after(),
5400 Err(other) => panic!("expected a rate limit, got {other:?}"),
5401 Ok(_) => panic!("expected the open to fail"),
5402 }
5403 }
5404
5405 #[tokio::test]
5408 async fn opening_the_account_stream_during_a_long_cooldown_fails_unsent() {
5409 let (url, requests) = ws_api_answering(subscribed).await;
5410 let client = client_with_ws_api(url);
5411 client
5412 .rate_limiter
5413 .on_rate_limited(Some(Duration::from_secs(60)));
5414
5415 let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
5416
5417 assert!(
5418 wait.is_some_and(
5419 |wait| wait <= Duration::from_secs(60) && wait > Duration::from_secs(55)
5420 ),
5421 "{wait:?}"
5422 );
5423 assert_eq!(requests.load(Ordering::SeqCst), 0);
5424 }
5425
5426 #[tokio::test]
5429 async fn a_ban_on_the_account_stream_subscribe_starts_its_cooldown() {
5430 let (url, _) = ws_api_answering(banned_for_two_minutes).await;
5431 let client = client_with_ws_api(url);
5432
5433 let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
5434
5435 assert!(
5436 wait.is_some_and(
5437 |wait| wait <= TWO_MINUTE_BAN && wait > TWO_MINUTE_BAN - Duration::from_secs(5)
5438 ),
5439 "{wait:?}"
5440 );
5441 assert_cooldown_about(&client, TWO_MINUTE_BAN);
5442 }
5443
5444 #[tokio::test]
5447 async fn a_refused_account_stream_handshake_starts_a_cooldown() {
5448 use tokio_tungstenite::tungstenite::http;
5449
5450 let url = ws_server(|stream| async move {
5451 #[allow(clippy::result_large_err)]
5453 let refuse = |_: &http::Request<()>, _| {
5454 Err(http::Response::builder()
5455 .status(http::StatusCode::IM_A_TEAPOT)
5456 .body(None)
5457 .unwrap())
5458 };
5459 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
5460 })
5461 .await;
5462 let client = client_with_ws_api(url);
5463
5464 let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
5465
5466 assert!(
5468 wait.is_some_and(
5469 |wait| wait <= MIN_IP_BAN && wait > MIN_IP_BAN - Duration::from_secs(5)
5470 ),
5471 "{wait:?}"
5472 );
5473 assert_cooldown_about(&client, MIN_IP_BAN);
5474 }
5475
5476 #[tokio::test]
5479 async fn a_drop_during_a_long_cooldown_ends_the_account_stream_rate_limited() {
5480 use futures::{SinkExt as _, StreamExt as _};
5481 use tokio_tungstenite::tungstenite::Message;
5482
5483 let subscribes = Arc::new(std::sync::atomic::AtomicU32::new(0));
5484 let url = ws_server({
5485 let subscribes = Arc::clone(&subscribes);
5486 move |stream| {
5487 let subscribes = Arc::clone(&subscribes);
5488 async move {
5489 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5490 return;
5491 };
5492 while let Some(Ok(message)) = ws.next().await {
5495 let Message::Text(text) = message else {
5496 continue;
5497 };
5498 subscribes.fetch_add(1, Ordering::SeqCst);
5499 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5500 let answer = subscribed(&request).to_string();
5501 if ws.send(Message::text(answer)).await.is_err() {
5502 return;
5503 }
5504 tokio::time::sleep(Duration::from_secs(1)).await;
5505 let _ = ws.close(None).await;
5506 return;
5507 }
5508 }
5509 }
5510 })
5511 .await;
5512 let client = client_with_ws_api(url);
5513 let mut stream = client.account_stream(&[], &[]).await.unwrap();
5514 client
5515 .rate_limiter
5516 .on_rate_limited(Some(Duration::from_secs(60)));
5517
5518 let event = tokio::time::timeout(Duration::from_secs(5), stream.next())
5519 .await
5520 .expect("the stream ends rate-limited within the timeout")
5521 .expect("it reports why before it ends");
5522
5523 match event.kind {
5524 AccountEventKind::StreamTerminated(StreamTerminationReason::RateLimited {
5525 retry_after,
5526 }) => assert!(
5527 retry_after <= Duration::from_secs(60) && retry_after > Duration::from_secs(55),
5528 "{retry_after:?}"
5529 ),
5530 other => panic!("expected a rate-limited termination, got {other:?}"),
5531 }
5532 assert!(stream.next().await.is_none(), "the stream has ended");
5533 assert_eq!(subscribes.load(Ordering::SeqCst), 1);
5534 }
5535
5536 #[tokio::test]
5539 async fn a_reconnect_waits_out_a_short_cooldown() {
5540 use futures::{SinkExt as _, StreamExt as _};
5541 use tokio_tungstenite::tungstenite::Message;
5542
5543 let subscribed_at = Arc::new(parking_lot::Mutex::new(Vec::new()));
5544 let url = ws_server({
5545 let subscribed_at = Arc::clone(&subscribed_at);
5546 move |stream| {
5547 let subscribed_at = Arc::clone(&subscribed_at);
5548 async move {
5549 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5550 return;
5551 };
5552 while let Some(Ok(message)) = ws.next().await {
5553 let Message::Text(text) = message else {
5554 continue;
5555 };
5556 subscribed_at.lock().push(tokio::time::Instant::now());
5557 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5558 if ws
5559 .send(Message::text(subscribed(&request).to_string()))
5560 .await
5561 .is_err()
5562 {
5563 return;
5564 }
5565 if subscribed_at.lock().len() == 1 {
5568 tokio::time::sleep(Duration::from_millis(500)).await;
5569 let _ = ws.close(None).await;
5570 return;
5571 }
5572 }
5573 }
5574 }
5575 })
5576 .await;
5577 let client = client_with_ws_api(url);
5578 let _stream = client.account_stream(&[], &[]).await.unwrap();
5579 let cooldown = Duration::from_secs(3);
5580 let cooled = tokio::time::Instant::now() + cooldown;
5582 client.rate_limiter.on_rate_limited(Some(cooldown));
5583
5584 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
5585 while subscribed_at.lock().len() < 2 {
5586 assert!(tokio::time::Instant::now() < deadline, "no reconnect");
5587 tokio::time::sleep(Duration::from_millis(20)).await;
5588 }
5589
5590 assert!(subscribed_at.lock()[1] >= cooled);
5592 }
5593
5594 #[tokio::test(start_paused = true)]
5599 async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
5600 async fn run(pinging: bool) {
5601 let lifetime = if pinging {
5602 test_ws_api::dropped_then_pinging
5603 } else {
5604 test_ws_api::dropped_then_silent
5605 };
5606 let (url, requests) = test_ws_api::serve(subscribed, lifetime).await;
5607 let client = client_with_ws_api(url);
5608 let _stream = client.account_stream(&[], &[]).await.unwrap();
5609 if pinging {
5610 test_ws_api::assert_reset_once_pinged(&requests, "spot").await;
5611 } else {
5612 test_ws_api::assert_no_reset_when_silent(&requests, "spot").await;
5613 }
5614 }
5615
5616 tokio::join!(run(true), run(false));
5617 }
5618
5619 #[tokio::test]
5622 async fn a_cancel_queued_behind_a_refused_handshake_reports_the_cooldown() {
5623 use tokio_tungstenite::tungstenite::http;
5624
5625 let handshakes = Arc::new(std::sync::atomic::AtomicU32::new(0));
5626 let url = ws_server({
5627 let handshakes = Arc::clone(&handshakes);
5628 move |stream| {
5629 let handshakes = Arc::clone(&handshakes);
5630 async move {
5631 handshakes.fetch_add(1, Ordering::SeqCst);
5632 tokio::time::sleep(Duration::from_millis(200)).await;
5634 #[allow(clippy::result_large_err)]
5637 let refuse = |_: &http::Request<()>, _| {
5638 Err(http::Response::builder()
5639 .status(http::StatusCode::TOO_MANY_REQUESTS)
5640 .body(None)
5641 .unwrap())
5642 };
5643 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
5644 }
5645 }
5646 })
5647 .await;
5648 let client = client_with_ws_api(url);
5649 let instrument = InstrumentNameExchange::new("BTCUSDT");
5650
5651 let (first, second) = tokio::join!(
5652 client.cancel_order(cancel_request(&instrument)),
5653 client.cancel_order(cancel_request(&instrument)),
5654 );
5655
5656 assert_eq!(handshakes.load(Ordering::SeqCst), 1);
5657 let waits: Vec<_> = [&first.state, &second.state]
5658 .into_iter()
5659 .map(|state| match state {
5660 Err(UnindexedOrderError::Rejected(api @ ApiError::RateLimit { .. })) => {
5661 api.retry_after()
5662 }
5663 other => panic!("expected a rate-limit rejection, got {other:?}"),
5664 })
5665 .collect();
5666 let default = Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS);
5668 assert!(
5669 waits.contains(&None)
5670 && waits
5671 .iter()
5672 .flatten()
5673 .any(|wait| { *wait <= default && *wait > default - Duration::from_secs(5) }),
5674 "{waits:?}"
5675 );
5676 }
5677
5678 fn rest_against(server: &wiremock::MockServer) -> (Arc<RestApi>, Arc<RateLimitTracker>) {
5680 let rest = Arc::new(SpotRestApi::from_config(
5681 ConfigurationRestApi::builder()
5682 .api_key("key")
5683 .api_secret("secret")
5684 .base_path(server.uri())
5685 .build()
5686 .unwrap(),
5687 ));
5688 (rest, Arc::new(RateLimitTracker::new(WeightPool::Spot)))
5689 }
5690
5691 async fn mount_refusal(
5694 server: &wiremock::MockServer,
5695 status: u16,
5696 retry_after: Option<&str>,
5697 times: u64,
5698 ) {
5699 let mut refusal = wiremock::ResponseTemplate::new(status).set_body_json(
5700 serde_json::json!({ "code": -1003, "msg": "Too much request weight used." }),
5701 );
5702 if let Some(retry_after) = retry_after {
5703 refusal = refusal.insert_header("retry-after", retry_after);
5704 }
5705 wiremock::Mock::given(wiremock::matchers::method("GET"))
5706 .and(wiremock::matchers::path("/api/v3/openOrders"))
5707 .respond_with(refusal)
5708 .up_to_n_times(times)
5709 .mount(server)
5710 .await;
5711 }
5712
5713 fn query_retry_after<T: std::fmt::Debug>(
5715 result: &Result<T, UnindexedClientError>,
5716 ) -> Option<Duration> {
5717 match result {
5718 Err(UnindexedClientError::Api(api @ ApiError::RateLimit { .. })) => api.retry_after(),
5719 other => panic!("expected a rate-limit refusal, got {other:?}"),
5720 }
5721 }
5722
5723 #[tokio::test]
5726 async fn a_rest_ban_is_not_retried_and_holds_back_later_requests() {
5727 let server = wiremock::MockServer::start().await;
5728 mount_refusal(&server, 418, Some("120"), u64::MAX).await;
5729 let (rest, tracker) = rest_against(&server);
5730
5731 let result = fetch_all_open_orders(Arc::clone(&rest), Arc::clone(&tracker)).await;
5732 assert_eq!(query_retry_after(&result), Some(Duration::from_secs(120)));
5733 assert!(
5734 tracker
5735 .cooldown_remaining()
5736 .is_some_and(|left| left > Duration::from_secs(115)),
5737 "{:?}",
5738 tracker.cooldown_remaining()
5739 );
5740
5741 let result = fetch_all_open_orders(rest, tracker).await;
5742 assert!(
5743 query_retry_after(&result).is_some_and(
5744 |left| left > Duration::from_secs(115) && left <= Duration::from_secs(120)
5745 ),
5746 "{result:?}"
5747 );
5748 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5749 }
5750
5751 #[tokio::test]
5754 async fn a_rest_ban_without_retry_after_is_not_retried() {
5755 let server = wiremock::MockServer::start().await;
5756 mount_refusal(&server, 418, None, u64::MAX).await;
5757 let (rest, tracker) = rest_against(&server);
5758
5759 let result = fetch_all_open_orders(rest, Arc::clone(&tracker)).await;
5760 assert_eq!(query_retry_after(&result), None);
5761 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5762 assert!(
5763 tracker
5764 .cooldown_remaining()
5765 .is_some_and(|left| left > Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS - 5)),
5766 "{:?}",
5767 tracker.cooldown_remaining()
5768 );
5769 }
5770
5771 #[tokio::test]
5773 async fn a_rest_throttle_with_a_long_retry_after_is_not_retried() {
5774 let server = wiremock::MockServer::start().await;
5775 mount_refusal(&server, 429, Some("30"), u64::MAX).await;
5776 let (rest, tracker) = rest_against(&server);
5777
5778 let result = fetch_all_open_orders(rest, tracker).await;
5779 assert_eq!(query_retry_after(&result), Some(Duration::from_secs(30)));
5780 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5781 }
5782
5783 #[tokio::test]
5786 async fn a_rest_throttle_with_a_short_retry_after_is_retried_after_it() {
5787 let server = wiremock::MockServer::start().await;
5788 mount_refusal(&server, 429, Some("2"), 1).await;
5789 wiremock::Mock::given(wiremock::matchers::method("GET"))
5790 .and(wiremock::matchers::path("/api/v3/openOrders"))
5791 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
5792 .mount(&server)
5793 .await;
5794 let (rest, tracker) = rest_against(&server);
5795
5796 let started = std::time::Instant::now();
5797 let orders = fetch_all_open_orders(rest, tracker).await.unwrap();
5798
5799 assert!(orders.is_empty());
5800 assert_eq!(server.received_requests().await.unwrap().len(), 2);
5801 assert!(
5802 started.elapsed() >= Duration::from_secs(2),
5803 "{:?}",
5804 started.elapsed()
5805 );
5806 }
5807
5808 #[tokio::test]
5811 async fn a_handshake_refused_with_429_starts_a_cooldown() {
5812 use tokio_tungstenite::tungstenite::http;
5813
5814 let url = ws_server(|stream| async move {
5815 #[allow(clippy::result_large_err)]
5817 let refuse = |_: &http::Request<()>, _| {
5818 Err(http::Response::builder()
5819 .status(http::StatusCode::TOO_MANY_REQUESTS)
5820 .body(None)
5821 .unwrap())
5822 };
5823 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
5824 })
5825 .await;
5826 let client = client_with_ws_api(url);
5827 let instrument = InstrumentNameExchange::new("BTCUSDT");
5828
5829 let response = client.cancel_order(cancel_request(&instrument)).await;
5830
5831 assert!(
5832 matches!(
5833 response.state,
5834 Err(UnindexedOrderError::Rejected(ApiError::RateLimit { .. }))
5835 ),
5836 "{:?}",
5837 response.state
5838 );
5839 assert!(client.rate_limiter.is_blocked());
5840 }
5841
5842 #[tokio::test]
5845 async fn a_handshake_refused_with_418_starts_the_shortest_ban() {
5846 use tokio_tungstenite::tungstenite::http;
5847
5848 let url = ws_server(|stream| async move {
5849 #[allow(clippy::result_large_err)]
5851 let refuse = |_: &http::Request<()>, _| {
5852 Err(http::Response::builder()
5853 .status(http::StatusCode::IM_A_TEAPOT)
5854 .body(None)
5855 .unwrap())
5856 };
5857 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
5858 })
5859 .await;
5860 let client = client_with_ws_api(url);
5861 let instrument = InstrumentNameExchange::new("BTCUSDT");
5862
5863 let response = client.cancel_order(cancel_request(&instrument)).await;
5864
5865 assert_rate_limited_for(&response.state, MIN_IP_BAN);
5866 assert_cooldown_about(&client, MIN_IP_BAN);
5867 }
5868
5869 #[tokio::test]
5872 async fn a_ws_api_response_near_the_weight_limit_pauses_queries() {
5873 use futures::{SinkExt as _, StreamExt as _};
5874 use tokio_tungstenite::tungstenite::Message;
5875
5876 let url = ws_server(|stream| async move {
5877 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5878 return;
5879 };
5880 while let Some(Ok(message)) = ws.next().await {
5881 let Message::Text(text) = message else {
5882 continue;
5883 };
5884 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5885 let response = serde_json::json!({
5886 "id": request["id"],
5887 "status": 200,
5888 "result": {
5889 "symbol": "BTCUSDT",
5890 "origClientOrderId": "cid",
5891 "orderId": 7,
5892 "transactTime": 1_700_000_000_000_i64,
5893 "executedQty": "0",
5894 },
5895 "rateLimits": [{
5896 "rateLimitType": "REQUEST_WEIGHT",
5897 "interval": "MINUTE",
5898 "intervalNum": 1,
5899 "limit": 1_000,
5900 "count": 950,
5901 }],
5902 });
5903 if ws.send(Message::text(response.to_string())).await.is_err() {
5904 return;
5905 }
5906 }
5907 })
5908 .await;
5909 let client = client_with_ws_api(url);
5910 let instrument = InstrumentNameExchange::new("BTCUSDT");
5911
5912 let response = client.cancel_order(cancel_request(&instrument)).await;
5913
5914 assert!(response.state.is_ok(), "{:?}", response.state);
5915 assert_eq!(client.rate_limiter.weight_limit(), 1_000);
5916 assert!(
5917 tokio::time::timeout(
5918 Duration::from_millis(50),
5919 client.rate_limiter.wait_if_blocked(RequestKind::Query),
5920 )
5921 .await
5922 .is_err(),
5923 "queries pause"
5924 );
5925 tokio::time::timeout(
5926 Duration::from_millis(50),
5927 client.rate_limiter.wait_if_blocked(RequestKind::Order),
5928 )
5929 .await
5930 .expect("orders never wait on the pause")
5931 .expect("no cooldown too long to sleep through");
5932 }
5933
5934 async fn ws_server_reporting_close(
5937 result: serde_json::Value,
5938 ) -> (String, mpsc::UnboundedReceiver<()>) {
5939 use futures::{SinkExt as _, StreamExt as _};
5940 use tokio_tungstenite::tungstenite::Message;
5941
5942 let (closed_tx, closed_rx) = mpsc::unbounded_channel();
5943 let url = ws_server(move |stream| {
5944 let result = result.clone();
5945 let closed_tx = closed_tx.clone();
5946 async move {
5947 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5948 return;
5949 };
5950 while let Some(Ok(message)) = ws.next().await {
5951 match message {
5952 Message::Text(text) => {
5953 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5954 let response = serde_json::json!({
5955 "id": request["id"],
5956 "status": 200,
5957 "result": result,
5958 });
5959 if ws.send(Message::text(response.to_string())).await.is_err() {
5960 return;
5961 }
5962 }
5963 Message::Close(_) => {
5964 let _ = closed_tx.send(());
5965 return;
5966 }
5967 _ => {}
5968 }
5969 }
5970 }
5971 })
5972 .await;
5973 (url, closed_rx)
5974 }
5975
5976 #[tokio::test]
5982 async fn dropping_the_account_stream_closes_its_connection() {
5983 for running in [Duration::ZERO, Duration::from_millis(100)] {
5984 let (url, mut closed) =
5985 ws_server_reporting_close(serde_json::json!({ "subscriptionId": 0 })).await;
5986 let client = client_with_ws_api(url);
5987 let holders = client.known_live.holders();
5988
5989 let stream = client.account_stream(&[], &[]).await.unwrap();
5990 tokio::time::sleep(running).await;
5991 drop(stream);
5992
5993 tokio::time::timeout(Duration::from_secs(5), closed.recv())
5994 .await
5995 .expect("the dropped stream closes its connection")
5996 .unwrap();
5997 for _ in 0..10 {
5999 tokio::task::yield_now().await;
6000 }
6001 assert_eq!(
6002 client.known_live.holders(),
6003 holders,
6004 "the stream's state is released"
6005 );
6006 }
6007 }
6008
6009 #[tokio::test]
6012 async fn dropping_the_last_client_closes_its_order_session() {
6013 let (url, mut closed) = ws_server_reporting_close(serde_json::json!({
6014 "symbol": "BTCUSDT",
6015 "origClientOrderId": "cid",
6016 "orderId": 7,
6017 "transactTime": 1_700_000_000_000_i64,
6018 "executedQty": "0",
6019 }))
6020 .await;
6021 let client = client_with_ws_api(url);
6022 let clone = client.clone();
6023 let instrument = InstrumentNameExchange::new("BTCUSDT");
6024 let response = client.cancel_order(cancel_request(&instrument)).await;
6025 assert!(response.state.is_ok(), "{:?}", response.state);
6026
6027 drop(client);
6028 assert!(
6029 tokio::time::timeout(Duration::from_millis(200), closed.recv())
6030 .await
6031 .is_err(),
6032 "a clone still holds the session"
6033 );
6034
6035 drop(clone);
6036 tokio::time::timeout(Duration::from_secs(5), closed.recv())
6037 .await
6038 .expect("the last client's drop closes the session")
6039 .unwrap();
6040 }
6041
6042 #[tokio::test]
6045 async fn fill_recovery_does_not_wait_for_the_weight_pause() {
6046 let server = wiremock::MockServer::start().await;
6047 wiremock::Mock::given(wiremock::matchers::method("GET"))
6048 .and(wiremock::matchers::path("/api/v3/myTrades"))
6049 .respond_with(
6051 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
6052 "symbol": "BTCUSDT",
6053 "id": 1,
6054 "orderId": 7,
6055 "price": "100",
6056 "qty": "1",
6057 "commission": "0",
6058 "commissionAsset": "USDT",
6059 "time": Utc::now().timestamp_millis(),
6060 "isBuyer": true,
6061 "isMaker": false,
6062 }])),
6063 )
6064 .expect(2)
6065 .mount(&server)
6066 .await;
6067 let rest = Arc::new(SpotRestApi::from_config(
6068 ConfigurationRestApi::builder()
6069 .api_key("key")
6070 .api_secret("secret")
6071 .base_path(server.uri())
6072 .build()
6073 .unwrap(),
6074 ));
6075 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6076 tracker.throttle(Duration::from_secs(60));
6077 let (tx, _rx) = mpsc::unbounded_channel();
6078 let mut unrecovered = UnrecoveredFills::default();
6079 unrecovered.open(
6080 &[InstrumentNameExchange::new("BTCUSDT")],
6081 Utc::now() - chrono::Duration::minutes(1),
6082 Utc::now(),
6083 );
6084
6085 tokio::time::timeout(
6087 Duration::from_millis(800),
6088 recover_fills(
6089 &rest,
6090 &tracker,
6091 &mut unrecovered,
6092 &tx,
6093 &new_dedup_cache(),
6094 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6095 ),
6096 )
6097 .await
6098 .expect("recovery does not wait for the pause");
6099 }
6100
6101 #[tokio::test]
6103 async fn a_gap_whose_every_read_fails_is_reported_when_given_up() {
6104 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6105 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6106 let server = wiremock::MockServer::start().await;
6107 wiremock::Mock::given(wiremock::matchers::method("GET"))
6108 .and(wiremock::matchers::path("/api/v3/myTrades"))
6109 .respond_with(
6110 wiremock::ResponseTemplate::new(400)
6111 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6112 )
6113 .mount(&server)
6114 .await;
6115 let rest = Arc::new(SpotRestApi::from_config(
6116 ConfigurationRestApi::builder()
6117 .api_key("key")
6118 .api_secret("secret")
6119 .base_path(server.uri())
6120 .build()
6121 .expect("valid config"),
6122 ));
6123 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6124 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6125 let dedup = new_dedup_cache();
6126 let btc = InstrumentNameExchange::new("BTCUSDT");
6127 let mut unrecovered = UnrecoveredFills::default();
6128 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
6129
6130 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
6131 recover_fills(
6132 &rest,
6133 &tracker,
6134 &mut unrecovered,
6135 &tx,
6136 &dedup,
6137 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6138 )
6139 .await;
6140 unrecovered.make_due();
6141 }
6142
6143 assert!(unrecovered.is_empty(), "the gap is given up");
6144 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6145 let [event] = forwarded.as_slice() else {
6146 panic!("only the give-up is sent: {forwarded:?}");
6147 };
6148 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
6149 panic!("expected FillRecoveryGaveUp, got {event:?}");
6150 };
6151 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
6152 assert_eq!(
6153 gave_up.scope,
6154 crate::FillRecoveryScope::Instruments(vec![btc])
6155 );
6156 assert_eq!(
6157 gave_up.start.timestamp_millis(),
6158 disconnect.timestamp_millis()
6159 );
6160 assert_eq!(
6161 gave_up.attempts,
6162 crate::client::order_recovery::MAX_GAP_RETRIES + 1
6163 );
6164 assert!(
6165 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
6166 "the last read's error: {:?}",
6167 gave_up.reason
6168 );
6169 }
6170
6171 #[tokio::test]
6174 async fn a_failed_recovery_keeps_its_closed_gap_for_the_retry() {
6175 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6176 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6177 let trade = |id: i64, time: DateTime<Utc>| {
6178 serde_json::json!({
6179 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
6180 "commission": "0", "commissionAsset": "USDT", "time": time.timestamp_millis(),
6181 "isBuyer": true, "isMaker": false, "isBestMatch": true,
6182 })
6183 };
6184 let server = wiremock::MockServer::start().await;
6185 wiremock::Mock::given(wiremock::matchers::method("GET"))
6186 .and(wiremock::matchers::path("/api/v3/myTrades"))
6187 .respond_with(
6188 wiremock::ResponseTemplate::new(400)
6189 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6190 )
6191 .up_to_n_times(1)
6192 .with_priority(1)
6193 .mount(&server)
6194 .await;
6195 wiremock::Mock::given(wiremock::matchers::method("GET"))
6196 .and(wiremock::matchers::path("/api/v3/myTrades"))
6197 .respond_with(
6198 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
6199 trade(1, disconnect + chrono::Duration::minutes(1)),
6200 trade(2, Utc::now() - chrono::Duration::minutes(1)),
6201 ])),
6202 )
6203 .mount(&server)
6204 .await;
6205 let rest = Arc::new(SpotRestApi::from_config(
6206 ConfigurationRestApi::builder()
6207 .api_key("key")
6208 .api_secret("secret")
6209 .base_path(server.uri())
6210 .build()
6211 .unwrap(),
6212 ));
6213 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6214 let (tx, mut rx) = mpsc::unbounded_channel();
6215 let dedup = new_dedup_cache();
6216 let mut unrecovered = UnrecoveredFills::default();
6217 unrecovered.open(
6218 &[InstrumentNameExchange::new("BTCUSDT")],
6219 disconnect,
6220 reconnect,
6221 );
6222
6223 recover_fills(
6224 &rest,
6225 &tracker,
6226 &mut unrecovered,
6227 &tx,
6228 &dedup,
6229 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6230 )
6231 .await;
6232 assert!(!unrecovered.is_empty(), "the failed gap is kept");
6233 assert!(rx.try_recv().is_err(), "nothing forwarded");
6234 assert!(
6235 unrecovered.due(tokio::time::Instant::now()).is_empty(),
6236 "and waits for its retry"
6237 );
6238
6239 unrecovered.make_due();
6240 recover_fills(
6241 &rest,
6242 &tracker,
6243 &mut unrecovered,
6244 &tx,
6245 &dedup,
6246 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6247 )
6248 .await;
6249 assert!(unrecovered.is_empty(), "the retry recovered it");
6250 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6251 assert_eq!(
6252 forwarded.len(),
6253 1,
6254 "only the fill inside the gap: {forwarded:?}"
6255 );
6256
6257 let start_times: Vec<String> = server
6258 .received_requests()
6259 .await
6260 .unwrap()
6261 .iter()
6262 .filter_map(|request| {
6263 request
6264 .url
6265 .query_pairs()
6266 .find(|(key, _)| key == "startTime")
6267 .map(|(_, value)| value.into_owned())
6268 })
6269 .collect();
6270 let start = disconnect.timestamp_millis().to_string();
6271 assert_eq!(start_times, [start.clone(), start]);
6272 }
6273
6274 #[tokio::test]
6277 async fn recovered_fills_carry_their_orders_client_order_ids() {
6278 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6279 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6280 let trade = |id: i64| {
6281 serde_json::json!({
6282 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
6283 "commission": "0", "commissionAsset": "USDT",
6284 "time": (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
6285 "isBuyer": true, "isMaker": false, "isBestMatch": true,
6286 })
6287 };
6288 let server = wiremock::MockServer::start().await;
6289 for id in [1, 2] {
6291 wiremock::Mock::given(wiremock::matchers::method("GET"))
6292 .and(wiremock::matchers::path("/api/v3/myTrades"))
6293 .and(wiremock::matchers::query_param("orderId", id.to_string()))
6294 .respond_with(
6295 wiremock::ResponseTemplate::new(200)
6296 .set_body_json(serde_json::json!([trade(id)])),
6297 )
6298 .with_priority(1)
6299 .mount(&server)
6300 .await;
6301 }
6302 wiremock::Mock::given(wiremock::matchers::method("GET"))
6303 .and(wiremock::matchers::path("/api/v3/myTrades"))
6304 .respond_with(
6305 wiremock::ResponseTemplate::new(200)
6306 .set_body_json(serde_json::json!([trade(1), trade(2)])),
6307 )
6308 .mount(&server)
6309 .await;
6310 wiremock::Mock::given(wiremock::matchers::method("GET"))
6311 .and(wiremock::matchers::path("/api/v3/order"))
6312 .and(wiremock::matchers::query_param("orderId", "1"))
6313 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6314 serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "sweep-1"}),
6315 ))
6316 .mount(&server)
6317 .await;
6318 wiremock::Mock::given(wiremock::matchers::method("GET"))
6319 .and(wiremock::matchers::path("/api/v3/order"))
6320 .and(wiremock::matchers::query_param("orderId", "2"))
6321 .respond_with(
6322 wiremock::ResponseTemplate::new(400).set_body_json(
6323 serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
6324 ),
6325 )
6326 .mount(&server)
6327 .await;
6328 let rest = Arc::new(SpotRestApi::from_config(
6329 ConfigurationRestApi::builder()
6330 .api_key("key")
6331 .api_secret("secret")
6332 .base_path(server.uri())
6333 .build()
6334 .unwrap(),
6335 ));
6336 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6337 let (tx, mut rx) = mpsc::unbounded_channel();
6338 let mut unrecovered = UnrecoveredFills::default();
6339 unrecovered.open(
6340 &[InstrumentNameExchange::new("BTCUSDT")],
6341 disconnect,
6342 reconnect,
6343 );
6344
6345 recover_fills(
6346 &rest,
6347 &tracker,
6348 &mut unrecovered,
6349 &tx,
6350 &new_dedup_cache(),
6351 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6352 )
6353 .await;
6354
6355 let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
6356 .filter_map(|event| match event.kind {
6357 AccountEventKind::Trade(trade) => Some((
6358 trade.id.0.to_string(),
6359 trade.client_order_id,
6360 trade.order_filled_quantity,
6361 )),
6362 _ => None,
6363 })
6364 .collect();
6365 cids.sort();
6366 assert_eq!(
6368 cids,
6369 [
6370 (
6371 "1".to_string(),
6372 Some(ClientOrderId::new("sweep-1")),
6373 Some(Decimal::ONE)
6374 ),
6375 ("2".to_string(), None, Some(Decimal::ONE)),
6376 ]
6377 );
6378 let order_reads = server
6379 .received_requests()
6380 .await
6381 .unwrap()
6382 .iter()
6383 .filter(|request| request.url.path() == "/api/v3/order")
6384 .count();
6385 assert_eq!(order_reads, 2, "one read per order");
6386 }
6387
6388 #[tokio::test]
6391 async fn a_span_walk_pages_by_id_and_stops_past_the_end() {
6392 let start = Utc::now().timestamp_millis() - 600_000;
6393 let end = start + 10_000;
6394 let trade = |id: i64, time: i64| {
6395 serde_json::json!({
6396 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
6397 "commission": "0", "commissionAsset": "USDT", "time": time,
6398 "isBuyer": true, "isMaker": false, "isBestMatch": true,
6399 })
6400 };
6401 let server = wiremock::MockServer::start().await;
6402 wiremock::Mock::given(wiremock::matchers::method("GET"))
6403 .and(wiremock::matchers::path("/api/v3/myTrades"))
6404 .and(wiremock::matchers::query_param("fromId", "1001"))
6405 .respond_with(
6407 wiremock::ResponseTemplate::new(200).set_body_json(
6408 (1_001..=2_000)
6409 .map(|id| trade(id, if id == 1_001 { end } else { end + 1 }))
6410 .collect::<Vec<_>>(),
6411 ),
6412 )
6413 .with_priority(1)
6414 .mount(&server)
6415 .await;
6416 let full_page: Vec<_> = (1..=1_000).map(|id| trade(id, start + id)).collect();
6417 wiremock::Mock::given(wiremock::matchers::method("GET"))
6418 .and(wiremock::matchers::path("/api/v3/myTrades"))
6419 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(full_page))
6420 .mount(&server)
6421 .await;
6422 let rest = Arc::new(SpotRestApi::from_config(
6423 ConfigurationRestApi::builder()
6424 .api_key("key")
6425 .api_secret("secret")
6426 .base_path(server.uri())
6427 .build()
6428 .unwrap(),
6429 ));
6430
6431 let read = paginate_my_trades(
6432 &rest,
6433 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6434 &InstrumentNameExchange::new("BTCUSDT"),
6435 MyTradesFrom::Span { start, end },
6436 RequestKind::Query,
6437 )
6438 .await
6439 .unwrap();
6440
6441 let ids: Vec<_> = read.iter().filter_map(|t| t.id).collect();
6442 assert_eq!(ids, (1..=1_001).collect::<Vec<_>>());
6443 assert_eq!(server.received_requests().await.unwrap().len(), 2);
6444 }
6445
6446 #[tokio::test]
6449 async fn fetch_trades_returns_only_the_span() {
6450 let start = DateTime::from_timestamp_millis(
6452 (Utc::now() - chrono::Duration::minutes(10)).timestamp_millis(),
6453 )
6454 .unwrap()
6455 + chrono::Duration::microseconds(500);
6456 let end = start + chrono::Duration::seconds(10);
6457 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
6458 let trade = |id: i64, time: i64| {
6459 serde_json::json!({
6460 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
6461 "commission": "0", "commissionAsset": "USDT", "time": time,
6462 "isBuyer": true, "isMaker": false, "isBestMatch": true,
6463 })
6464 };
6465 let server = wiremock::MockServer::start().await;
6466 wiremock::Mock::given(wiremock::matchers::method("GET"))
6467 .and(wiremock::matchers::path("/api/v3/myTrades"))
6468 .and(wiremock::matchers::query_param(
6469 "startTime",
6470 start_ms.to_string(),
6471 ))
6472 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(vec![
6473 trade(1, start_ms),
6474 trade(2, start_ms + 1),
6475 trade(3, end_ms),
6476 trade(4, end_ms + 1),
6477 ]))
6478 .mount(&server)
6479 .await;
6480 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
6481 "key".into(),
6482 "secret".into(),
6483 ));
6484 client.rest = Arc::new(SpotRestApi::from_config(
6485 ConfigurationRestApi::builder()
6486 .api_key("key")
6487 .api_secret("secret")
6488 .base_path(server.uri())
6489 .build()
6490 .unwrap(),
6491 ));
6492 let btc = [InstrumentNameExchange::new("BTCUSDT")];
6493
6494 let read = client.fetch_trades(start, end, &btc).await.unwrap();
6495 let ids: Vec<_> = read.trades.iter().map(|t| t.id.0.as_str()).collect();
6496 assert_eq!(ids, ["2", "3"]);
6497 assert_eq!(read.resume, None, "spot reads a span whole");
6498
6499 let empty = client.fetch_trades(end, start, &btc).await.unwrap();
6500 assert!(empty.trades.is_empty() && empty.resume.is_none());
6501 assert_eq!(server.received_requests().await.unwrap().len(), 1);
6502 }
6503
6504 #[tokio::test]
6506 async fn a_gap_whose_every_read_times_out_is_reported_when_given_up() {
6507 let server = wiremock::MockServer::start().await;
6508 wiremock::Mock::given(wiremock::matchers::method("GET"))
6509 .and(wiremock::matchers::path("/api/v3/myTrades"))
6510 .respond_with(
6511 wiremock::ResponseTemplate::new(200)
6512 .set_body_json(serde_json::json!([]))
6513 .set_delay(Duration::from_secs(3_600)),
6514 )
6515 .mount(&server)
6516 .await;
6517 let rest = Arc::new(SpotRestApi::from_config(
6518 ConfigurationRestApi::builder()
6519 .api_key("key")
6520 .api_secret("secret")
6521 .base_path(server.uri())
6522 .timeout(3_600_000_u64)
6523 .retries(0_u32)
6524 .build()
6525 .expect("valid config"),
6526 ));
6527 let btc = InstrumentNameExchange::new("BTCUSDT");
6528 let mut unrecovered = UnrecoveredFills::default();
6529 unrecovered.open(
6530 std::slice::from_ref(&btc),
6531 Utc::now() - chrono::Duration::minutes(10),
6532 Utc::now(),
6533 );
6534 let (tx, mut rx) = mpsc::unbounded_channel();
6535 tokio::time::pause();
6537
6538 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
6539 recover_fills(
6540 &rest,
6541 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6542 &mut unrecovered,
6543 &tx,
6544 &new_dedup_cache(),
6545 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6546 )
6547 .await;
6548 unrecovered.make_due();
6549 }
6550
6551 assert!(unrecovered.is_empty(), "the gap is given up");
6552 let Ok(event) = rx.try_recv() else {
6553 panic!("the given-up gap is reported");
6554 };
6555 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
6556 panic!("expected FillRecoveryGaveUp, got {event:?}");
6557 };
6558 assert_eq!(
6559 gave_up.scope,
6560 crate::FillRecoveryScope::Instruments(vec![btc])
6561 );
6562 assert_eq!(
6563 gave_up.reason,
6564 FillRecoveryFailure::TimedOut {
6565 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS
6566 }
6567 );
6568 assert!(rx.try_recv().is_err(), "reported once");
6569 }
6570
6571 #[tokio::test]
6574 async fn a_timed_out_recovery_charges_only_the_gaps_it_started() {
6575 let server = wiremock::MockServer::start().await;
6576 wiremock::Mock::given(wiremock::matchers::method("GET"))
6577 .and(wiremock::matchers::path("/api/v3/myTrades"))
6578 .respond_with(
6579 wiremock::ResponseTemplate::new(200)
6580 .set_body_json(serde_json::json!([]))
6581 .set_delay(Duration::from_secs(3_600)),
6582 )
6583 .mount(&server)
6584 .await;
6585 let rest = Arc::new(SpotRestApi::from_config(
6586 ConfigurationRestApi::builder()
6587 .api_key("key")
6588 .api_secret("secret")
6589 .base_path(server.uri())
6590 .timeout(3_600_000_u64)
6591 .retries(0_u32)
6592 .build()
6593 .unwrap(),
6594 ));
6595 let instruments: Vec<_> = (0..9)
6596 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
6597 .collect();
6598 let mut unrecovered = UnrecoveredFills::default();
6599 unrecovered.open(
6600 &instruments,
6601 Utc::now() - chrono::Duration::minutes(10),
6602 Utc::now(),
6603 );
6604 let (tx, _rx) = mpsc::unbounded_channel();
6605 tokio::time::pause();
6607
6608 recover_fills(
6609 &rest,
6610 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6611 &mut unrecovered,
6612 &tx,
6613 &new_dedup_cache(),
6614 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6615 )
6616 .await;
6617
6618 let now = tokio::time::Instant::now();
6619 assert_eq!(
6620 unrecovered.due(now).len(),
6621 1,
6622 "the gap never started stays due"
6623 );
6624 assert_eq!(
6625 unrecovered
6626 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
6627 .len(),
6628 9,
6629 "the eight started wait for their first retry"
6630 );
6631 }
6632
6633 fn order_row(cid: &str, status: &str) -> serde_json::Value {
6639 serde_json::json!({
6640 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
6641 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
6642 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "time": 1_700_000_000_000_i64,
6643 "updateTime": 1_700_000_060_000_i64,
6644 })
6645 }
6646
6647 fn get_order_row(row: serde_json::Value) -> binance_sdk::spot::rest_api::GetOrderResponse {
6648 serde_json::from_value(row).unwrap()
6649 }
6650
6651 fn spot_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
6652 OrderKey::new(
6653 ExchangeId::BinanceSpot,
6654 InstrumentNameExchange::new(instrument),
6655 StrategyId::new("strategy"),
6656 ClientOrderId::new(cid),
6657 )
6658 }
6659
6660 fn ended_state(
6661 key: &UnindexedOrderKey,
6662 row: serde_json::Value,
6663 ) -> Option<crate::order::state::UnindexedInactiveOrderState> {
6664 let order = convert_ended_order(&get_order_row(row), ExchangeId::BinanceSpot, key, None)?;
6665 assert_eq!(&order.key, key, "reported under the key asked");
6666 assert_eq!(order.quantity, Decimal::TWO);
6667 Some(order.state)
6668 }
6669
6670 #[test]
6673 fn an_ended_order_row_without_a_fill_reports_it_unknown() {
6674 use crate::order::state::{Expired, Filled, InactiveOrderState};
6675 let key = spot_key("BTCUSDT", "a");
6676 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
6677 let id = OrderId::new("7");
6678 let row = |status: &str, executed_qty: Option<&str>| {
6679 let mut row = order_row("a", status);
6680 row["executedQty"] = executed_qty.map_or(serde_json::Value::Null, Into::into);
6681 row
6682 };
6683
6684 for executed_qty in [None, Some("not a number")] {
6685 assert_eq!(
6686 ended_state(&key, row("CANCELED", executed_qty)),
6687 Some(InactiveOrderState::Cancelled(Cancelled::new(
6688 id.clone(),
6689 ended,
6690 None
6691 ))),
6692 "{executed_qty:?}"
6693 );
6694 assert_eq!(
6695 ended_state(&key, row("EXPIRED", executed_qty)),
6696 Some(InactiveOrderState::Expired(Expired::new(
6697 id.clone(),
6698 ended,
6699 None
6700 ))),
6701 "{executed_qty:?}"
6702 );
6703 assert_eq!(
6704 ended_state(&key, row("FILLED", executed_qty)),
6705 Some(InactiveOrderState::FullyFilled(Filled::new(
6706 id.clone(),
6707 ended,
6708 Decimal::TWO,
6709 Some(Decimal::new(525, 1)),
6710 ))),
6711 "the whole quantity, at the quote over it: {executed_qty:?}"
6712 );
6713 }
6714 }
6715
6716 #[test]
6717 fn an_ended_order_row_says_how_the_order_ended() {
6718 use crate::order::state::{Expired, Filled, InactiveOrderState};
6719 let key = spot_key("BTCUSDT", "a");
6720 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
6721 let id = OrderId::new("7");
6722
6723 let mut filled = order_row("a", "FILLED");
6724 filled["executedQty"] = "2".into();
6725 filled["cummulativeQuoteQty"] = "210".into();
6726 assert_eq!(
6727 ended_state(&key, filled),
6728 Some(InactiveOrderState::FullyFilled(Filled::new(
6729 id.clone(),
6730 ended,
6731 Decimal::TWO,
6732 Some(Decimal::from(105)),
6733 )))
6734 );
6735 assert_eq!(
6736 ended_state(&key, order_row("a", "CANCELED")),
6737 Some(InactiveOrderState::Cancelled(Cancelled::new(
6738 id.clone(),
6739 ended,
6740 Some(Decimal::ONE)
6741 )))
6742 );
6743 for status in ["EXPIRED", "EXPIRED_IN_MATCH"] {
6744 assert_eq!(
6745 ended_state(&key, order_row("a", status)),
6746 Some(InactiveOrderState::Expired(Expired::new(
6747 id.clone(),
6748 ended,
6749 Some(Decimal::ONE)
6750 ))),
6751 "{status}"
6752 );
6753 }
6754 assert!(matches!(
6755 ended_state(&key, order_row("a", "REJECTED")),
6756 Some(InactiveOrderState::OpenFailed(OrderError::Rejected(
6757 ApiError::OrderRejected(_)
6758 )))
6759 ));
6760 }
6761
6762 #[test]
6763 fn a_live_or_unreadable_order_row_is_not_ended() {
6764 let key = spot_key("BTCUSDT", "a");
6765 for status in [
6766 "NEW",
6767 "PARTIALLY_FILLED",
6768 "PENDING_NEW",
6769 "PENDING_CANCEL",
6770 "SOMETHING_NEW",
6771 ] {
6772 assert_eq!(ended_state(&key, order_row("a", status)), None, "{status}");
6773 }
6774 let mut no_status = order_row("a", "FILLED");
6775 no_status["status"] = serde_json::Value::Null;
6776 assert_eq!(ended_state(&key, no_status), None);
6777 let mut no_side = order_row("a", "FILLED");
6778 no_side["side"] = serde_json::Value::Null;
6779 assert_eq!(ended_state(&key, no_side), None);
6780 }
6781
6782 #[test]
6783 fn an_unknown_order_or_symbol_is_recognised_by_its_code() {
6784 use binance_sdk::common::errors::ConnectorError;
6785 let bad = |code| {
6786 anyhow::Error::new(ConnectorError::BadRequestError {
6787 msg: "no".into(),
6788 code: Some(code),
6789 })
6790 };
6791 assert!(is_unknown_order(&bad(-2013)));
6792 assert!(is_unknown_order(&bad(-1121)));
6793 assert!(!is_unknown_order(&bad(-1100)));
6794 assert!(is_unknown_order(&anyhow::Error::new(
6795 ConnectorError::NotFoundError {
6796 msg: "no".into(),
6797 code: Some(-2013),
6798 }
6799 )));
6800 assert!(!is_unknown_order(&anyhow::anyhow!("-2013 in text only")));
6801 }
6802
6803 fn rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
6804 Arc::new(SpotRestApi::from_config(
6805 ConfigurationRestApi::builder()
6806 .api_key("key")
6807 .api_secret("secret")
6808 .base_path(server.uri())
6809 .build()
6810 .unwrap(),
6811 ))
6812 }
6813
6814 async fn mount_order(
6816 server: &wiremock::MockServer,
6817 cid: &str,
6818 response: wiremock::ResponseTemplate,
6819 ) {
6820 wiremock::Mock::given(wiremock::matchers::method("GET"))
6821 .and(wiremock::matchers::path("/api/v3/order"))
6822 .and(wiremock::matchers::query_param("origClientOrderId", cid))
6823 .respond_with(response)
6824 .mount(server)
6825 .await;
6826 }
6827
6828 fn venue_error(code: i64) -> wiremock::ResponseTemplate {
6829 wiremock::ResponseTemplate::new(400)
6830 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
6831 }
6832
6833 fn row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
6834 wiremock::ResponseTemplate::new(200).set_body_json(order_row(cid, status))
6835 }
6836
6837 async fn looked_up(server: &wiremock::MockServer) -> Vec<String> {
6839 server
6840 .received_requests()
6841 .await
6842 .unwrap()
6843 .iter()
6844 .filter(|request| request.url.path() == "/api/v3/order")
6845 .filter_map(|request| {
6846 request
6847 .url
6848 .query_pairs()
6849 .find(|(key, _)| key == "origClientOrderId")
6850 .map(|(_, value)| value.into_owned())
6851 })
6852 .collect()
6853 }
6854
6855 #[tokio::test]
6856 async fn fetch_ended_orders_reports_each_ended_order_under_its_key_and_omits_the_rest() {
6857 let server = wiremock::MockServer::start().await;
6858 mount_order(&server, "filled", row_response("filled", "FILLED")).await;
6859 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
6860 mount_order(&server, "live", row_response("live", "PARTIALLY_FILLED")).await;
6861 mount_order(&server, "unknown", venue_error(-2013)).await;
6862 mount_order(&server, "no-symbol", venue_error(-1121)).await;
6863 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
6864 "key".into(),
6865 "secret".into(),
6866 ));
6867 client.rest = rest_at(&server);
6868 let keys = ["filled", "cancelled", "live", "unknown"].map(|cid| spot_key("BTCUSDT", cid));
6869
6870 let mut ended = client
6871 .fetch_ended_orders(&[keys.to_vec(), vec![spot_key("NOSUCH", "no-symbol")]].concat())
6872 .await
6873 .unwrap();
6874
6875 ended.sort_by(|a, b| a.key.cid.cmp(&b.key.cid));
6876 let reported: Vec<_> = ended.iter().map(|order| &order.key).collect();
6877 assert_eq!(
6878 reported,
6879 [&keys[1], &keys[0]],
6880 "the strategy asked survives"
6881 );
6882 assert!(matches!(
6883 ended[0].state,
6884 crate::order::state::InactiveOrderState::Cancelled(_)
6885 ));
6886 assert!(matches!(
6887 ended[1].state,
6888 crate::order::state::InactiveOrderState::FullyFilled(_)
6889 ));
6890 assert!(client.fetch_ended_orders(&[]).await.unwrap().is_empty());
6891 }
6892
6893 #[tokio::test]
6894 async fn fetch_ended_orders_fails_when_any_lookup_fails() {
6895 let server = wiremock::MockServer::start().await;
6896 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
6897 mount_order(&server, "refused", venue_error(-1100)).await;
6898 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
6899 "key".into(),
6900 "secret".into(),
6901 ));
6902 client.rest = rest_at(&server);
6903
6904 let result = client
6905 .fetch_ended_orders(&[
6906 spot_key("BTCUSDT", "cancelled"),
6907 spot_key("BTCUSDT", "refused"),
6908 ])
6909 .await;
6910
6911 assert!(
6912 matches!(
6913 result,
6914 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
6915 ),
6916 "{result:?}"
6917 );
6918 }
6919
6920 async fn venue_after_a_disconnect(delay: Duration) -> wiremock::MockServer {
6923 let server = wiremock::MockServer::start().await;
6924 wiremock::Mock::given(wiremock::matchers::method("GET"))
6925 .and(wiremock::matchers::path("/api/v3/openOrders"))
6926 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
6927 .respond_with(
6928 wiremock::ResponseTemplate::new(200)
6929 .set_body_json(serde_json::json!([order_row("live", "NEW")])),
6930 )
6931 .mount(&server)
6932 .await;
6933 mount_order(
6934 &server,
6935 "gone",
6936 row_response("gone", "CANCELED").set_delay(delay),
6937 )
6938 .await;
6939 mount_order(&server, "unknown", venue_error(-2013)).await;
6940 server
6941 }
6942
6943 fn held_orders() -> SharedKnownLiveOrders {
6945 let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
6946 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
6947 known.lock().live(
6948 &spot_key("BTCUSDT", cid),
6949 Decimal::TWO,
6950 OrderKind::Limit,
6951 &Open::new(
6952 VenueOrderId::Assigned(OrderId::new(n.to_string())),
6953 Utc::now(),
6954 Decimal::ZERO,
6955 ),
6956 );
6957 }
6958 known
6959 }
6960
6961 fn btcusdt_unchecked() -> UncheckedOrders {
6962 let mut unchecked = UncheckedOrders::default();
6963 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6964 unchecked
6965 }
6966
6967 #[tokio::test]
6968 async fn a_reconnect_reports_how_an_unlisted_order_ended_and_forgets_an_unknown_one() {
6969 let server = venue_after_a_disconnect(Duration::ZERO).await;
6970 let known = held_orders();
6971 let mut unchecked = btcusdt_unchecked();
6972 let (tx, mut rx) = mpsc::unbounded_channel();
6973
6974 recover_spot_ended_orders(
6975 &rest_at(&server),
6976 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6977 &known,
6978 &mut unchecked,
6979 &UnrecoveredFills::default(),
6980 &tx,
6981 )
6982 .await;
6983
6984 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6985 let [event] = sent.as_slice() else {
6986 panic!("one order ended: {sent:?}");
6987 };
6988 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6989 panic!("an order snapshot: {event:?}");
6990 };
6991 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
6992 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
6993 assert!(matches!(
6994 snapshot.0.state,
6995 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6996 ));
6997 let mut asked = looked_up(&server).await;
6998 asked.sort();
6999 assert_eq!(
7000 asked,
7001 ["gone", "unknown"],
7002 "a listed order is not looked up"
7003 );
7004 let known = known.lock();
7005 assert!(known.contains(&ClientOrderId::new("live")));
7006 assert!(!known.contains(&ClientOrderId::new("gone")));
7007 assert!(
7008 !known.contains(&ClientOrderId::new("unknown")),
7009 "asking again cannot help"
7010 );
7011 assert!(unchecked.is_empty());
7012 }
7013
7014 #[tokio::test]
7015 async fn an_order_the_stream_reports_ending_during_the_check_is_not_reported_again() {
7016 let server = venue_after_a_disconnect(Duration::from_millis(300)).await;
7017 let known = held_orders();
7018 let mut unchecked = btcusdt_unchecked();
7019 let (tx, mut rx) = mpsc::unbounded_channel();
7020 let (rest, tracker) = (
7021 rest_at(&server),
7022 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7023 );
7024 let unrecovered = UnrecoveredFills::default();
7025
7026 tokio::join!(
7027 recover_spot_ended_orders(&rest, &tracker, &known, &mut unchecked, &unrecovered, &tx,),
7028 async {
7029 tokio::time::sleep(Duration::from_millis(100)).await;
7030 known.lock().ended(&ClientOrderId::new("gone"));
7031 }
7032 );
7033
7034 assert!(rx.try_recv().is_err(), "the stream already reported it");
7035 assert!(unchecked.is_empty());
7036 }
7037
7038 #[tokio::test]
7039 async fn an_instrument_with_unrecovered_fills_is_not_checked_yet() {
7040 let server = venue_after_a_disconnect(Duration::ZERO).await;
7041 let known = held_orders();
7042 let mut unchecked = btcusdt_unchecked();
7043 let mut unrecovered = UnrecoveredFills::default();
7044 unrecovered.open(
7045 &[InstrumentNameExchange::new("BTCUSDT")],
7046 Utc::now() - chrono::Duration::minutes(1),
7047 Utc::now(),
7048 );
7049 let (tx, mut rx) = mpsc::unbounded_channel();
7050
7051 recover_spot_ended_orders(
7052 &rest_at(&server),
7053 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7054 &known,
7055 &mut unchecked,
7056 &unrecovered,
7057 &tx,
7058 )
7059 .await;
7060
7061 assert!(rx.try_recv().is_err());
7062 assert!(server.received_requests().await.unwrap().is_empty());
7063 assert!(!unchecked.is_empty(), "it waits for its fills");
7064 assert_eq!(unchecked.next_due(&unrecovered), None, "woken by the gap");
7065 }
7066
7067 #[tokio::test]
7068 async fn a_failed_check_sends_nothing_and_waits_for_its_retry() {
7069 let server = wiremock::MockServer::start().await;
7070 wiremock::Mock::given(wiremock::matchers::method("GET"))
7071 .and(wiremock::matchers::path("/api/v3/openOrders"))
7072 .respond_with(venue_error(-1100))
7073 .mount(&server)
7074 .await;
7075 let server_rest = rest_at(&server);
7076 let known = held_orders();
7077 let mut unchecked = btcusdt_unchecked();
7078 let (tx, mut rx) = mpsc::unbounded_channel();
7079
7080 recover_spot_ended_orders(
7081 &server_rest,
7082 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7083 &known,
7084 &mut unchecked,
7085 &UnrecoveredFills::default(),
7086 &tx,
7087 )
7088 .await;
7089
7090 assert!(rx.try_recv().is_err());
7091 assert!(!unchecked.is_empty());
7092 assert!(
7093 unchecked
7094 .next_due(&UnrecoveredFills::default())
7095 .is_some_and(|due| due > tokio::time::Instant::now()),
7096 "retried after a backoff"
7097 );
7098 assert!(
7099 known.lock().contains(&ClientOrderId::new("gone")),
7100 "still held"
7101 );
7102 }
7103
7104 #[tokio::test]
7105 async fn an_unlisted_order_the_lookup_finds_live_stays_held() {
7106 let server = wiremock::MockServer::start().await;
7107 wiremock::Mock::given(wiremock::matchers::method("GET"))
7108 .and(wiremock::matchers::path("/api/v3/openOrders"))
7109 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
7110 .mount(&server)
7111 .await;
7112 for (cid, status) in [
7113 ("live", "NEW"),
7114 ("gone", "PENDING_CANCEL"),
7115 ("unknown", "WHAT"),
7116 ] {
7117 mount_order(&server, cid, row_response(cid, status)).await;
7118 }
7119 let known = held_orders();
7120 let mut unchecked = btcusdt_unchecked();
7121 let (tx, mut rx) = mpsc::unbounded_channel();
7122
7123 recover_spot_ended_orders(
7124 &rest_at(&server),
7125 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7126 &known,
7127 &mut unchecked,
7128 &UnrecoveredFills::default(),
7129 &tx,
7130 )
7131 .await;
7132
7133 assert!(rx.try_recv().is_err(), "none has ended");
7134 let known = known.lock();
7135 for cid in ["live", "gone", "unknown"] {
7136 assert!(
7137 known.contains(&ClientOrderId::new(cid)),
7138 "{cid}: only an order Binance does not know is dropped"
7139 );
7140 }
7141 assert!(unchecked.is_empty());
7142 }
7143
7144 #[tokio::test]
7145 async fn one_instrument_failing_does_not_hold_up_another() {
7146 let server = venue_after_a_disconnect(Duration::ZERO).await;
7147 wiremock::Mock::given(wiremock::matchers::method("GET"))
7148 .and(wiremock::matchers::path("/api/v3/openOrders"))
7149 .and(wiremock::matchers::query_param("symbol", "ETHUSDT"))
7150 .respond_with(venue_error(-1100))
7151 .mount(&server)
7152 .await;
7153 let known = held_orders();
7154 let eth = spot_key("ETHUSDT", "eth");
7155 known.lock().live(
7156 ð,
7157 Decimal::TWO,
7158 OrderKind::Limit,
7159 &Open::new(
7160 VenueOrderId::Assigned(OrderId::new("9")),
7161 Utc::now(),
7162 Decimal::ZERO,
7163 ),
7164 );
7165 let mut unchecked = btcusdt_unchecked();
7166 unchecked.open([eth.instrument.clone()]);
7167 let (tx, mut rx) = mpsc::unbounded_channel();
7168
7169 recover_spot_ended_orders(
7170 &rest_at(&server),
7171 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7172 &known,
7173 &mut unchecked,
7174 &UnrecoveredFills::default(),
7175 &tx,
7176 )
7177 .await;
7178
7179 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7180 assert_eq!(sent.len(), 1, "BTCUSDT's ended order: {sent:?}");
7181 assert!(!unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
7182 assert!(
7183 unchecked.contains(ð.instrument),
7184 "ETHUSDT waits for its retry"
7185 );
7186 assert!(known.lock().contains(ð.cid));
7187 }
7188
7189 #[tokio::test]
7190 async fn a_check_dropped_part_way_changes_nothing() {
7191 let server = venue_after_a_disconnect(Duration::from_millis(500)).await;
7192 let known = held_orders();
7193 let mut unchecked = btcusdt_unchecked();
7194 let (tx, mut rx) = mpsc::unbounded_channel();
7195
7196 let dropped = tokio::time::timeout(
7197 Duration::from_millis(100),
7198 recover_spot_ended_orders(
7199 &rest_at(&server),
7200 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7201 &known,
7202 &mut unchecked,
7203 &UnrecoveredFills::default(),
7204 &tx,
7205 ),
7206 )
7207 .await;
7208
7209 assert!(dropped.is_err(), "the lookup of `gone` was still waiting");
7210 assert!(rx.try_recv().is_err());
7211 assert!(known.lock().contains(&ClientOrderId::new("gone")));
7212 assert!(unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
7213 assert!(
7214 unchecked
7215 .next_due(&UnrecoveredFills::default())
7216 .is_some_and(|due| due <= tokio::time::Instant::now()),
7217 "still due, not charged a failure"
7218 );
7219 }
7220}