1use super::shared::{
40 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
41 CONNECT_TIMEOUT_SECS, DisconnectOnDrop, ExecutionReport, ExponentialBackoff,
42 FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS, HandshakeRefusal, MyTradesFrom,
43 ORDER_EXECUTIONS_BUDGET, OpenOrderListing, PlacementResponse, RateLimitTracker, RequestKind,
44 SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, UnhandledEvents, UnrecoveredFills,
45 UnsubscribeOnDrop, UserDataFrame, WeightPool, binance_filled_qty, classify_order_kind_tif,
46 classify_rest_query_error, classify_ws_order_error, convert_ended_order,
47 convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
48 dedup_key_from_event, disconnect_error, drop_after, frame_excerpt, gap_failed, gap_time,
49 is_duplicate, is_unknown_order, log_unhandled_event, log_unrecognised_frame, new_dedup_cache,
50 parse_user_data_frame, placed_order_state, recovered_order_cids, recovered_order_totals,
51 response_decode_error, rest_call_with_retry, trailing_params, unix_ms,
52};
53use crate::{
54 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
55 UnindexedAccountEvent, UnindexedAccountSnapshot,
56 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
57 client::{
58 ExecutionClient, OrderStatusClient,
59 order_recovery::{
60 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
61 fetch_ended_by_key, recover_ended_orders,
62 },
63 },
64 emit_stream_terminated,
65 error::{ApiError, ConnectivityError, OrderError, UnindexedClientError, UnindexedOrderError},
66 order::{
67 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
68 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
69 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
70 state::{Cancelled, Open, OrderState, UnindexedOrderState},
71 },
72 parse_env_bool,
73 position::PositionReport,
74 trade::{AssetFees, Trade, TradeId, TradesRead},
75};
76use binance_sdk::{
77 common::{
78 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
79 models::WebsocketEvent,
80 },
81 spot::{
82 SpotRestApi, SpotWsApi,
83 rest_api::{
84 GetAccountParams, GetOpenOrdersParams, GetOrderParams, MyTradesParams, RestApi,
85 },
86 websocket_api::{
87 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
88 OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
89 WebsocketApiHandle,
90 },
91 },
92};
93use chrono::{DateTime, TimeZone, Utc};
94use fnv::FnvHashSet;
95use futures::stream::BoxStream;
96use rust_decimal::Decimal;
97use rustrade_instrument::{
98 Side,
99 asset::name::AssetNameExchange,
100 exchange::ExchangeId,
101 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
102};
103use serde::Deserialize;
104use smol_str::format_smolstr;
105use std::{
106 str::FromStr,
107 sync::{
108 Arc,
109 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
110 },
111 time::Duration,
112};
113use tokio::sync::{RwLock, mpsc, oneshot};
114use tracing::{debug, error, info, trace, warn};
115
116#[derive(Clone, Deserialize)]
123pub struct BinanceSpotConfig {
124 api_key: String,
127 secret_key: String,
128
129 #[serde(default = "default_testnet")]
131 pub testnet: bool,
132}
133
134fn default_testnet() -> bool {
140 true
141}
142
143impl std::fmt::Debug for BinanceSpotConfig {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.debug_struct("BinanceSpotConfig")
147 .field("api_key", &"***")
148 .field("secret_key", &"***")
149 .field("testnet", &self.testnet)
150 .finish()
151 }
152}
153
154impl BinanceSpotConfig {
155 pub fn new(api_key: String, secret_key: String) -> Self {
158 Self::testnet(api_key, secret_key)
159 }
160
161 pub fn testnet(api_key: String, secret_key: String) -> Self {
163 Self {
164 api_key,
165 secret_key,
166 testnet: true,
167 }
168 }
169
170 pub fn production(api_key: String, secret_key: String) -> Self {
175 Self {
176 api_key,
177 secret_key,
178 testnet: false,
179 }
180 }
181
182 pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
201 let api_key = match std::env::var("BINANCE_API_KEY") {
202 Ok(value) => value,
203 Err(std::env::VarError::NotPresent) => {
204 return Err(BinanceSpotConfigError::MissingApiKey);
205 }
206 Err(std::env::VarError::NotUnicode(_)) => {
207 return Err(BinanceSpotConfigError::InvalidApiKey);
208 }
209 };
210 let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
211 Ok(value) => value,
212 Err(std::env::VarError::NotPresent) => {
213 return Err(BinanceSpotConfigError::MissingSecretKey);
214 }
215 Err(std::env::VarError::NotUnicode(_)) => {
216 return Err(BinanceSpotConfigError::InvalidSecretKey);
217 }
218 };
219 let testnet = match std::env::var("BINANCE_TESTNET") {
220 Ok(value) => {
221 parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
222 }
223 Err(std::env::VarError::NotPresent) => true,
224 Err(std::env::VarError::NotUnicode(value)) => {
227 return Err(BinanceSpotConfigError::InvalidTestnet(
228 value.to_string_lossy().into_owned(),
229 ));
230 }
231 };
232
233 if testnet {
234 Ok(Self::testnet(api_key, secret_key))
235 } else {
236 Ok(Self::production(api_key, secret_key))
237 }
238 }
239
240 pub fn api_key(&self) -> &str {
242 &self.api_key
243 }
244}
245
246#[derive(Debug, PartialEq, thiserror::Error)]
247pub enum BinanceSpotConfigError {
248 #[error("BINANCE_API_KEY environment variable not set")]
249 MissingApiKey,
250
251 #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
254 InvalidApiKey,
255
256 #[error("BINANCE_SECRET_KEY environment variable not set")]
257 MissingSecretKey,
258
259 #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
260 InvalidSecretKey,
261
262 #[error("BINANCE_TESTNET must be true or false, got {0}")]
263 InvalidTestnet(String),
264}
265
266#[derive(Debug)]
273enum WsApiUnavailable {
274 RateLimited {
277 msg: String,
278 retry_after: Option<Duration>,
279 },
280 Connect(String),
282}
283
284impl WsApiUnavailable {
285 fn into_order_error(self) -> UnindexedOrderError {
288 match self {
289 Self::RateLimited { msg, retry_after } => {
290 debug!(%msg, "BinanceSpot WS-API rate-limited, request not sent");
293 UnindexedOrderError::Rejected(ApiError::rate_limited(retry_after))
294 }
295 Self::Connect(msg) => UnindexedOrderError::Connectivity(ConnectivityError::Socket(msg)),
296 }
297 }
298}
299
300#[derive(Clone)]
339pub struct BinanceSpot {
340 config: Arc<BinanceSpotConfig>,
341 rest: Arc<RestApi>,
342 ws_handle: WebsocketApiHandle,
344 ws_api: Arc<RwLock<Option<DisconnectOnDrop<WebsocketApi>>>>,
350 rate_limiter: Arc<RateLimitTracker>,
352 known_live: SharedKnownLiveOrders,
355}
356
357impl std::fmt::Debug for BinanceSpot {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 f.debug_struct("BinanceSpot")
360 .field("testnet", &self.config.testnet)
361 .finish_non_exhaustive()
362 }
363}
364
365impl BinanceSpot {
366 fn remember_live<'a>(
368 &self,
369 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
370 ) {
371 let mut known = self.known_live.lock();
372 for order in orders {
373 known.live(&order.key, order.quantity, order.kind, &order.state);
374 }
375 }
376
377 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
381 let rest_config = ConfigurationRestApi::builder()
382 .api_key(config.api_key.clone())
383 .api_secret(config.secret_key.clone())
384 .build()
385 .expect("failed to build Binance REST configuration");
386
387 Arc::new(if config.testnet {
388 SpotRestApi::testnet(rest_config)
389 } else {
390 SpotRestApi::production(rest_config)
391 })
392 }
393
394 #[allow(clippy::expect_used)] fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
398 let ws_config = ConfigurationWebsocketApi::builder()
399 .api_key(config.api_key.clone())
400 .api_secret(config.secret_key.clone())
401 .build()
402 .expect("failed to build Binance WebSocket configuration");
403
404 if config.testnet {
405 SpotWsApi::testnet(ws_config)
406 } else {
407 SpotWsApi::production(ws_config)
408 }
409 }
410
411 async fn get_ws_api(&self) -> Result<WebsocketApi, WsApiUnavailable> {
422 if let Err(cooldown) = self.rate_limiter.wait_if_blocked(RequestKind::Order).await {
423 return Err(WsApiUnavailable::RateLimited {
424 msg: cooldown.to_string(),
425 retry_after: Some(cooldown.remaining),
426 });
427 }
428 {
430 let guard = self.ws_api.read().await;
431 if let Some(ref ws) = *guard {
432 return Ok(WebsocketApi::clone(ws));
433 }
434 }
435 let mut guard = self.ws_api.write().await;
442 if let Some(ref ws) = *guard {
444 return Ok(WebsocketApi::clone(ws));
445 }
446 if let Some(remaining) = self.rate_limiter.cooldown_remaining() {
447 return Err(WsApiUnavailable::RateLimited {
448 msg: "rate-limit cooldown active, not connecting the WS-API session".into(),
449 retry_after: Some(remaining),
450 });
451 }
452 let ws = match tokio::time::timeout(
453 Duration::from_secs(CONNECT_TIMEOUT_SECS),
454 self.ws_handle.connect(),
455 )
456 .await
457 {
458 Ok(Ok(ws)) => ws,
459 Ok(Err(e)) => {
460 let Some(refusal) = HandshakeRefusal::of(&e) else {
461 return Err(WsApiUnavailable::Connect(format!("{e:#}")));
462 };
463 warn!(error = %format!("{e:#}"), "Binance refused the WS-API handshake");
465 let retry_after = refusal.retry_after();
466 self.rate_limiter.on_rate_limited(retry_after);
467 return Err(WsApiUnavailable::RateLimited {
468 msg: format!("{e:#}"),
469 retry_after,
470 });
471 }
472 Err(_) => {
473 return Err(WsApiUnavailable::Connect(format!(
474 "BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s"
475 )));
476 }
477 };
478 *guard = Some(DisconnectOnDrop::new(
479 ws.clone(),
480 "BinanceSpot order session",
481 ));
482 Ok(ws)
483 }
484
485 async fn clear_ws_api(&self) {
488 let ws = {
500 let mut guard = self.ws_api.write().await;
501 guard.take()
502 };
503 if let Some(ws) = ws {
504 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
506 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
507 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
508 Ok(Ok(())) => {}
509 }
510 }
511 }
512}
513
514async fn fetch_open_orders_for_instrument(
521 rest: Arc<RestApi>,
522 rate_limiter: Arc<RateLimitTracker>,
523 instrument: InstrumentNameExchange,
524 kind: RequestKind,
525) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
526 let symbol_str = instrument.name().to_string();
528 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
529 let sym = symbol_str.clone();
530 Box::pin(async move {
531 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
532 rest.get_open_orders(params).await
533 })
534 })
535 .await
536 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
537
538 let orders_data = response.data().await.map_err(response_decode_error)?;
539
540 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceSpot, &instrument);
541
542 Ok((instrument, listing))
543}
544
545async fn fetch_all_open_orders(
550 rest: Arc<RestApi>,
551 rate_limiter: Arc<RateLimitTracker>,
552) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
553 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
554 Box::pin(async move {
555 let params = GetOpenOrdersParams::builder().build()?;
556 rest.get_open_orders(params).await
557 })
558 })
559 .await
560 .map_err(|e| classify_rest_query_error(&e, None))?;
561
562 let orders_data = response.data().await.map_err(response_decode_error)?;
563
564 let orders = orders_data
565 .into_iter()
566 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot))
567 .collect();
568
569 Ok(orders)
570}
571
572async fn fetch_order_lookup(
574 rest: Arc<RestApi>,
575 rate_limiter: Arc<RateLimitTracker>,
576 key: UnindexedOrderKey,
577 kind: RequestKind,
578) -> Result<OrderLookup, UnindexedClientError> {
579 let symbol = key.instrument.name().to_string();
581 let cid = key.cid.0.to_string();
582 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
583 let (symbol, cid) = (symbol.clone(), cid.clone());
584 Box::pin(async move {
585 let params = GetOrderParams::builder(symbol)
586 .orig_client_order_id(cid)
587 .build()?;
588 rest.get_order(params).await
589 })
590 })
591 .await
592 {
593 Ok(response) => response,
594 Err(e) if is_unknown_order(&e) => {
595 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceSpot does not know this order");
596 return Ok(OrderLookup::Unknown);
597 }
598 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
599 };
600
601 let row = response.data().await.map_err(response_decode_error)?;
602
603 Ok(
605 convert_ended_order(&row, ExchangeId::BinanceSpot, &key, None)
606 .map_or(OrderLookup::NotEnded, |order| {
607 OrderLookup::Ended(Box::new(order))
608 }),
609 )
610}
611
612async fn fetch_order_cid(
616 rest: &Arc<RestApi>,
617 rate_limiter: &RateLimitTracker,
618 instrument: &InstrumentNameExchange,
619 order_id: i64,
620) -> Result<Option<ClientOrderId>, UnindexedClientError> {
621 let symbol = instrument.name().to_string();
622 let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
623 let symbol = symbol.clone();
624 Box::pin(async move {
625 let params = GetOrderParams::builder(symbol).order_id(order_id).build()?;
626 rest.get_order(params).await
627 })
628 })
629 .await
630 {
631 Ok(response) => response,
632 Err(e) if is_unknown_order(&e) => {
633 debug!(%instrument, order_id, error = %e, "BinanceSpot does not know this order");
634 return Ok(None);
635 }
636 Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
637 };
638 let row = response.data().await.map_err(response_decode_error)?;
639 Ok(row
640 .client_order_id
641 .filter(|cid| !cid.is_empty())
642 .map(ClientOrderId::new))
643}
644
645async fn listed_open_cids(
648 rest: Arc<RestApi>,
649 rate_limiter: Arc<RateLimitTracker>,
650 instruments: Vec<InstrumentNameExchange>,
651) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
652 let mut listed = FnvHashSet::default();
653 for instrument in instruments {
654 let (_, listing) = fetch_open_orders_for_instrument(
655 rest.clone(),
656 rate_limiter.clone(),
657 instrument,
658 RequestKind::Essential,
659 )
660 .await?;
661 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
662 }
663 Ok(listed)
664}
665
666async fn recover_spot_ended_orders(
669 rest: &Arc<RestApi>,
670 rate_limiter: &Arc<RateLimitTracker>,
671 known: &SharedKnownLiveOrders,
672 unchecked: &mut UncheckedOrders,
673 unrecovered: &UnrecoveredFills,
674 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
675) {
676 recover_ended_orders(
677 ExchangeId::BinanceSpot,
678 known,
679 unchecked,
680 unrecovered,
681 tx,
682 OpenListing::PerInstrument,
683 |instruments| listed_open_cids(rest.clone(), rate_limiter.clone(), instruments),
684 |key| {
685 fetch_order_lookup(
686 rest.clone(),
687 rate_limiter.clone(),
688 key,
689 RequestKind::Essential,
690 )
691 },
692 )
693 .await;
694}
695
696async fn paginate_my_trades(
707 rest: &Arc<RestApi>,
708 rate_limiter: &Arc<RateLimitTracker>,
709 instrument: &InstrumentNameExchange,
710 from: MyTradesFrom,
711 kind: RequestKind,
712) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
713 let symbol_str = instrument.name().to_string();
715 let mut all_pages = Vec::new();
720 let mut cursor: Option<i64> = None;
721 loop {
722 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
724 let sym = symbol_str.clone();
725 Box::pin(async move {
726 #[allow(clippy::cast_possible_truncation)]
728 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
729 let params = match (from, fid) {
730 (MyTradesFrom::Span { start, .. }, None) => builder.start_time(start),
731 (MyTradesFrom::Span { .. }, Some(id)) => builder.from_id(id),
732 (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
733 (MyTradesFrom::Order(order_id), Some(id)) => {
734 builder.order_id(order_id).from_id(id)
735 }
736 }
737 .build()?;
738 rest.my_trades(params).await
739 })
740 })
741 .await
742 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
743
744 let mut page = response.data().await.map_err(response_decode_error)?;
745
746 let page_len = page.len();
747 let last_id = page.last().and_then(|t| t.id);
748 let past_end = match from {
750 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
751 MyTradesFrom::Order(_) => false,
752 };
753 all_pages.extend(page);
754
755 if past_end || page_len < BINANCE_MAX_TRADES {
756 break;
757 }
758 match last_id {
759 Some(id) => {
760 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
761 match id.checked_add(1) {
762 Some(next) => cursor = Some(next),
763 None => break, }
765 }
766 None => {
767 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
768 break;
769 }
770 }
771 }
772 Ok(all_pages)
773}
774
775impl ExecutionClient for BinanceSpot {
780 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
781
782 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
785 &[InstrumentKindDiscriminant::Spot];
786 type Config = BinanceSpotConfig;
787 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
788
789 fn new(config: Self::Config) -> Self {
794 let rest = Self::build_rest(&config);
795 let ws_handle = Self::build_ws_handle(&config);
796 Self {
797 config: Arc::new(config),
798 rest,
799 ws_handle,
800 ws_api: Arc::new(RwLock::new(None)),
801 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Spot)),
802 known_live: KnownLiveOrders::shared(ExchangeId::BinanceSpot),
803 }
804 }
805
806 async fn account_snapshot(
807 &self,
808 assets: &[AssetNameExchange],
809 instruments: &[InstrumentNameExchange],
810 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
811 let response =
813 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
814 Box::pin(async move {
815 let params = GetAccountParams::builder().build()?;
816 rest.get_account(params).await
817 })
818 })
819 .await
820 .map_err(|e| classify_rest_query_error(&e, None))?;
821
822 let account = response.data().await.map_err(response_decode_error)?;
823
824 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
826
827 use futures::{StreamExt as _, TryStreamExt};
833 let instrument_snapshots: Vec<_> =
834 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
835 fetch_open_orders_for_instrument(
836 self.rest.clone(),
837 self.rate_limiter.clone(),
838 instrument,
839 RequestKind::Query,
840 )
841 }))
842 .buffer_unordered(8)
843 .map(|result| {
844 let (inst, listing) = result?;
845 self.remember_live(&listing.orders);
846 let wrapped = listing
847 .orders
848 .into_iter()
849 .map(|o| Order {
850 key: o.key,
851 side: o.side,
852 price: o.price,
853 quantity: o.quantity,
854 kind: o.kind,
855 time_in_force: o.time_in_force,
856 state: OrderState::active(o.state),
857 })
858 .collect();
859 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
860 inst,
861 wrapped,
862 listing.complete,
863 PositionReport::Unreported,
864 None,
865 ))
866 })
867 .try_collect()
868 .await?;
869
870 Ok(AccountSnapshot::new(
871 ExchangeId::BinanceSpot,
872 balances,
873 instrument_snapshots,
874 ))
875 }
876
877 async fn account_stream(
957 &self,
958 _assets: &[AssetNameExchange],
962 instruments: &[InstrumentNameExchange],
963 ) -> Result<Self::AccountStream, UnindexedClientError> {
964 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
979 let dedup = new_dedup_cache();
980 let ws_handle = self.ws_handle.clone();
981 let rest = self.rest.clone();
982 let rate_limiter = self.rate_limiter.clone();
983 let known_live = self.known_live.clone();
984 let instruments = instruments.to_vec();
985 debug_assert!(
989 instruments.iter().all(|i| i.name().len() <= 23),
990 "instrument name exceeds SmolStr inline capacity: {:?}",
991 instruments.iter().find(|i| i.name().len() > 23)
992 );
993
994 self.rate_limiter.before_stream_open().await?;
996
997 let initial_ws = ws_handle.connect().await.map_err(|e| {
1001 self.rate_limiter.stream_open_error(&e, |e| {
1002 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1003 })
1004 })?;
1005 let initial_ws = DisconnectOnDrop::new(initial_ws, ACCOUNT_STREAM_WS);
1007
1008 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1010 .build()
1011 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1012
1013 match initial_ws
1014 .user_data_stream_subscribe_signature(params)
1015 .await
1016 {
1017 Ok(_) => {}
1018 Err(e) => {
1019 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
1023 Ok(Err(de)) => {
1024 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
1025 }
1026 Err(_) => {
1027 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
1028 }
1029 Ok(Ok(())) => {}
1030 }
1031 return Err(self
1032 .rate_limiter
1033 .stream_open_error(&e, |e| UnindexedClientError::Internal(e.to_string())));
1034 }
1035 }
1036
1037 let cm_handle = tokio::spawn(connection_manager(
1049 tx,
1050 dedup,
1051 ws_handle,
1052 rest,
1053 rate_limiter,
1054 known_live,
1055 instruments,
1056 Some(initial_ws),
1057 ));
1058
1059 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1063 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1064 Ok(futures::StreamExt::boxed(guarded_stream))
1065 }
1066
1067 async fn cancel_order(
1068 &self,
1069 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1070 ) -> UnindexedOrderResponseCancel {
1071 let instrument = request.key.instrument.clone();
1072 let key = OrderKey {
1073 exchange: request.key.exchange,
1074 instrument: instrument.clone(),
1075 strategy: request.key.strategy.clone(),
1076 cid: request.key.cid.clone(),
1077 };
1078
1079 let ws = match self.get_ws_api().await {
1080 Ok(ws) => ws,
1081 Err(unavailable) => {
1082 return UnindexedOrderResponseCancel {
1083 key,
1084 state: Err(unavailable.into_order_error()),
1085 };
1086 }
1087 };
1088
1089 let mut params_builder =
1092 OrderCancelParams::builder(request.key.instrument.name().to_string());
1093
1094 if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1096 if let Ok(id) = order_id.0.parse::<i64>() {
1097 params_builder = params_builder.order_id(id);
1098 } else {
1099 error!(
1104 order_id = %order_id.0,
1105 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
1106 );
1107 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1108 }
1109 } else {
1110 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1111 }
1112
1113 let params = match params_builder.build() {
1114 Ok(p) => p,
1115 Err(e) => {
1116 error!(%e, "BinanceSpot failed to build cancel order params");
1117 return UnindexedOrderResponseCancel {
1118 key,
1119 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1120 e.to_string(),
1121 ))),
1122 };
1123 }
1124 };
1125
1126 let sent_ms = unix_ms();
1127 let result = ws.order_cancel(params).await;
1128 if let Ok(response) = &result {
1130 self.rate_limiter
1131 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1132 }
1133 match result {
1134 Ok(response) => match response.data() {
1135 Ok(data) => {
1136 let time_exchange = data
1137 .transact_time
1138 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1139 .unwrap_or_else(Utc::now);
1140
1141 let exchange_order_id = match data.order_id {
1142 Some(id) => OrderId(format_smolstr!("{id}")),
1143 None => {
1144 error!("BinanceSpot cancel response missing orderId");
1145 return UnindexedOrderResponseCancel {
1146 key,
1147 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1148 "cancel response missing orderId".into(),
1149 ))),
1150 };
1151 }
1152 };
1153
1154 let filled_qty = binance_filled_qty(
1155 ExchangeId::BinanceSpot,
1156 &exchange_order_id,
1157 data.executed_qty.as_deref(),
1158 );
1159
1160 self.known_live.lock().ended(&key.cid);
1161 UnindexedOrderResponseCancel {
1162 key,
1163 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1164 }
1165 }
1166 Err(e) => {
1167 UnindexedOrderResponseCancel {
1169 key,
1170 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1171 e.to_string(),
1172 ))),
1173 }
1174 }
1175 },
1176 Err(e) => {
1177 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1185 if let OrderError::Rejected(api @ ApiError::RateLimit { .. }) = &order_err {
1189 self.rate_limiter.on_rate_limited(api.retry_after());
1190 }
1191 UnindexedOrderResponseCancel {
1192 key,
1193 state: Err(order_err),
1194 }
1195 } else {
1196 self.clear_ws_api().await;
1199 UnindexedOrderResponseCancel {
1200 key,
1201 state: Err(UnindexedOrderError::Connectivity(
1202 ConnectivityError::Socket(format!("{e:#}")),
1203 )),
1204 }
1205 }
1206 }
1207 }
1208 }
1209
1210 async fn open_order(
1211 &self,
1212 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1213 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1214 let instrument = request.key.instrument.clone();
1215 let side = request.state.side;
1216 let price = request.state.price;
1217 let quantity = request.state.quantity;
1218 let kind = request.state.kind;
1219 let time_in_force = request.state.time_in_force;
1220 let cid = request.key.cid.clone();
1221
1222 let order_key = OrderKey::new(
1223 ExchangeId::BinanceSpot,
1224 instrument.clone(),
1225 request.key.strategy.clone(),
1226 cid.clone(),
1227 );
1228
1229 let ws = match self.get_ws_api().await {
1230 Ok(ws) => ws,
1231 Err(unavailable) => {
1232 return Order {
1233 key: order_key,
1234 side,
1235 price,
1236 quantity,
1237 kind,
1238 time_in_force,
1239 state: OrderState::inactive(unavailable.into_order_error()),
1240 };
1241 }
1242 };
1243
1244 let binance_side = match side {
1245 Side::Buy => OrderPlaceSideEnum::Buy,
1246 Side::Sell => OrderPlaceSideEnum::Sell,
1247 };
1248
1249 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
1250 Some(converted) => converted,
1251 None => {
1252 return Order {
1253 key: order_key,
1254 side,
1255 price,
1256 quantity,
1257 kind,
1258 time_in_force,
1259 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1260 "Binance Spot does not yet support OrderKind::{kind:?}"
1261 ))),
1262 };
1263 }
1264 };
1265
1266 let mut params_builder =
1273 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
1274 .quantity(quantity)
1275 .new_client_order_id(cid.0.to_string());
1276
1277 match trailing_params(&kind, price) {
1279 Ok(Some(trailing)) => {
1280 params_builder = params_builder.trailing_delta(trailing.trailing_delta);
1281 if let Some(limit) = trailing.price {
1282 params_builder = params_builder.price(limit);
1283 }
1284 if let Some(activation_price) = trailing.stop_price {
1285 params_builder = params_builder.stop_price(activation_price);
1287 }
1288 }
1289 Err(refused) => {
1290 return Order {
1291 key: order_key,
1292 side,
1293 price,
1294 quantity,
1295 kind,
1296 time_in_force,
1297 state: OrderState::inactive(refused.into_order_error()),
1298 };
1299 }
1300 Ok(None) => {}
1301 }
1302
1303 match kind {
1306 OrderKind::Limit => {
1307 params_builder = params_builder.price(price);
1308 }
1309 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
1310 params_builder = params_builder.stop_price(trigger_price);
1311 }
1312 OrderKind::StopLimit { trigger_price }
1313 | OrderKind::TakeProfitLimit { trigger_price } => {
1314 params_builder = params_builder.price(price).stop_price(trigger_price);
1315 }
1316 OrderKind::TrailingStop { .. }
1319 | OrderKind::TrailingStopFixedLimit { .. }
1320 | OrderKind::Market
1321 | OrderKind::TrailingStopLimit { .. } => {}
1322 }
1323
1324 if let Some(tif) = binance_tif {
1325 params_builder = params_builder.time_in_force(tif);
1326 }
1327
1328 let params = match params_builder.build() {
1329 Ok(p) => p,
1330 Err(e) => {
1331 error!(%e, "BinanceSpot failed to build new order params");
1332 return Order {
1333 key: order_key,
1334 side,
1335 price,
1336 quantity,
1337 kind,
1338 time_in_force,
1339 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1340 e.to_string(),
1341 ))),
1342 };
1343 }
1344 };
1345
1346 let sent_ms = unix_ms();
1347 let result = ws.order_place(params).await;
1348 if let Ok(response) = &result {
1350 self.rate_limiter
1351 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1352 }
1353 match result {
1354 Ok(response) => match response.data() {
1355 Ok(data) => {
1356 let time_exchange = data
1357 .transact_time
1358 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1359 .unwrap_or_else(Utc::now);
1360
1361 let exchange_order_id = match data.order_id {
1362 Some(id) => OrderId(format_smolstr!("{id}")),
1363 None => {
1364 error!("BinanceSpot open_order response missing orderId");
1365 return Order {
1366 key: order_key,
1367 side,
1368 price,
1369 quantity,
1370 kind,
1371 time_in_force,
1372 state: OrderState::inactive(OrderError::Rejected(
1373 ApiError::OrderRejected(
1374 "open_order response missing orderId".into(),
1375 ),
1376 )),
1377 };
1378 }
1379 };
1380
1381 let state = placed_order_state(
1386 ExchangeId::BinanceSpot,
1387 &instrument,
1388 quantity,
1389 PlacementResponse {
1390 status: data.status.as_deref(),
1391 order_id: exchange_order_id,
1392 time_exchange,
1393 executed_qty: data.executed_qty.as_deref(),
1394 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
1395 },
1396 );
1397 self.known_live
1398 .lock()
1399 .placed(&order_key, quantity, kind, &state);
1400
1401 Order {
1402 key: order_key,
1403 side,
1404 price,
1405 quantity,
1406 kind,
1407 time_in_force,
1408 state,
1409 }
1410 }
1411 Err(e) => {
1412 Order {
1414 key: order_key,
1415 side,
1416 price,
1417 quantity,
1418 kind,
1419 time_in_force,
1420 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1421 e.to_string(),
1422 ))),
1423 }
1424 }
1425 },
1426 Err(e) => {
1427 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1435 if let OrderError::Rejected(api @ ApiError::RateLimit { .. }) = &order_err {
1439 self.rate_limiter.on_rate_limited(api.retry_after());
1440 }
1441 Order {
1442 key: order_key,
1443 side,
1444 price,
1445 quantity,
1446 kind,
1447 time_in_force,
1448 state: OrderState::inactive(order_err),
1449 }
1450 } else {
1451 self.clear_ws_api().await;
1454 Order {
1455 key: order_key,
1456 side,
1457 price,
1458 quantity,
1459 kind,
1460 time_in_force,
1461 state: OrderState::inactive(OrderError::Connectivity(
1462 ConnectivityError::Socket(format!("{e:#}")),
1463 )),
1464 }
1465 }
1466 }
1467 }
1468 }
1469
1470 async fn fetch_balances(
1471 &self,
1472 assets: &[AssetNameExchange],
1473 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1474 let response =
1475 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1476 Box::pin(async move {
1477 let params = GetAccountParams::builder().build()?;
1478 rest.get_account(params).await
1479 })
1480 })
1481 .await
1482 .map_err(|e| classify_rest_query_error(&e, None))?;
1483
1484 let account = response.data().await.map_err(response_decode_error)?;
1485
1486 Ok(filter_and_convert_balances(
1487 account.balances.unwrap_or_default(),
1488 assets,
1489 ))
1490 }
1491
1492 async fn fetch_open_orders(
1497 &self,
1498 instruments: &[InstrumentNameExchange],
1499 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1500 if instruments.is_empty() {
1503 let orders =
1504 fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await?;
1505 self.remember_live(&orders);
1506 return Ok(orders);
1507 }
1508 use futures::{StreamExt as _, TryStreamExt as _};
1513 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1514 fetch_open_orders_for_instrument(
1515 self.rest.clone(),
1516 self.rate_limiter.clone(),
1517 instrument,
1518 RequestKind::Query,
1519 )
1520 }))
1521 .buffer_unordered(8)
1522 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1523 acc.extend(listing.orders);
1524 Ok(acc)
1525 })
1526 .await
1527 .inspect(|orders| self.remember_live(orders))
1528 }
1529
1530 #[allow(clippy::redundant_iter_cloned)]
1541 async fn fetch_trades(
1542 &self,
1543 start: DateTime<Utc>,
1544 end: DateTime<Utc>,
1545 instruments: &[InstrumentNameExchange],
1546 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1547 use futures::StreamExt;
1548
1549 if instruments.is_empty() {
1550 debug!(
1551 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1552 );
1553 return Ok(TradesRead::complete(Vec::new()));
1554 }
1555 if start > end {
1556 return Ok(TradesRead::complete(Vec::new()));
1557 }
1558 let span = MyTradesFrom::Span {
1560 start: start.timestamp_millis(),
1561 end: end.timestamp_millis(),
1562 };
1563 let mut all_trades = Vec::new();
1566
1567 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1571 let rest = self.rest.clone();
1572 let rate_limiter = self.rate_limiter.clone();
1573 async move {
1574 let pages =
1575 paginate_my_trades(&rest, &rate_limiter, &inst, span, RequestKind::Query)
1576 .await?;
1577 Ok::<_, UnindexedClientError>((inst, pages))
1578 }
1579 }))
1580 .buffer_unordered(8);
1581 while let Some(result) = stream.next().await {
1582 let (instrument, trades_data) = result?;
1583 for t in trades_data {
1584 if let Some(trade) = convert_my_trade(&t, &instrument)
1585 && (start..=end).contains(&trade.time_exchange)
1586 {
1587 all_trades.push(trade);
1588 }
1589 }
1590 }
1591
1592 Ok(TradesRead::complete(all_trades))
1593 }
1594}
1595
1596impl OrderStatusClient for BinanceSpot {
1611 async fn fetch_ended_orders(
1612 &self,
1613 orders: &[UnindexedOrderKey],
1614 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1615 fetch_ended_by_key(orders, |key| {
1616 fetch_order_lookup(
1617 self.rest.clone(),
1618 self.rate_limiter.clone(),
1619 key,
1620 RequestKind::Query,
1621 )
1622 })
1623 .await
1624 }
1625}
1626
1627const ACCOUNT_STREAM_WS: &str = "BinanceSpot account stream";
1629
1630async fn connect_and_subscribe(
1634 ws_handle: &WebsocketApiHandle,
1635) -> anyhow::Result<DisconnectOnDrop<WebsocketApi>> {
1636 let ws = DisconnectOnDrop::new(ws_handle.connect().await?, ACCOUNT_STREAM_WS);
1637 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1639 .build()
1640 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1641 match ws.user_data_stream_subscribe_signature(params).await {
1642 Ok(_) => Ok(ws),
1643 Err(e) => {
1644 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1647 Err(e)
1648 }
1649 }
1650}
1651
1652#[allow(clippy::cognitive_complexity)]
1676async fn connection_manager(
1677 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1678 dedup: SharedDedupCache,
1679 ws_handle: WebsocketApiHandle,
1680 rest: Arc<RestApi>,
1681 rate_limiter: Arc<RateLimitTracker>,
1682 known: SharedKnownLiveOrders,
1683 instruments: Vec<InstrumentNameExchange>,
1684 initial_ws: Option<DisconnectOnDrop<WebsocketApi>>,
1685) {
1686 let mut backoff = ExponentialBackoff::new();
1687 let mut disconnect_time: Option<DateTime<Utc>> = None;
1688 let mut unrecovered = UnrecoveredFills::default();
1690 let mut unchecked = UncheckedOrders::default();
1692 let mut current_ws = initial_ws;
1693
1694 loop {
1695 let ws = match current_ws.take() {
1697 Some(ws) => ws,
1698 None => {
1699 if let Err(reason) = rate_limiter.before_stream_connect().await {
1702 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
1703 break;
1704 }
1705 match connect_and_subscribe(&ws_handle).await {
1706 Ok(ws) => ws,
1712 Err(e) => {
1713 error!(%e, "BinanceSpot WS connect/subscribe failed");
1714 rate_limiter.on_stream_refused(&e);
1716 if let Err(reason) =
1717 backoff.retry_or_end(&rate_limiter, || e.to_string()).await
1718 {
1719 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
1721 break;
1722 }
1723 continue;
1724 }
1725 }
1726 }
1727 };
1728 info!("BinanceSpot account_stream connected and subscribed");
1729
1730 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1736 let mut signal_tx_opt = Some(signal_tx);
1737 let heartbeat_flag = Arc::new(AtomicBool::new(false));
1741 let hb_callback = heartbeat_flag.clone();
1742 let dedup_callback = dedup.clone();
1743 let known_callback = known.clone();
1744 let mut event_tx = Some(tx.clone());
1748 let mut event_buf = Vec::with_capacity(32);
1752
1753 let subscription = ws.subscribe_on_ws_events(move |event| {
1760 let Some(ref sender) = event_tx else { return };
1761 match event {
1762 WebsocketEvent::Message(json_str) => {
1763 hb_callback.store(true, Ordering::Release);
1766 let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1770 for ev in event_buf.drain(..) {
1771 if let Some(key) = dedup_key_from_event(&ev)
1773 && is_duplicate(&dedup_callback, key)
1774 {
1775 trace!("BinanceSpot dedup: skipping duplicate event");
1776 continue;
1777 }
1778 let known = KnownLiveOrders::observes(&ev.kind).then(|| {
1782 let mut known = known_callback.lock();
1783 known.observe(&ev);
1784 known
1785 });
1786 if sender.send(ev).is_err() {
1787 drop(known);
1788 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1789 event_tx.take();
1790 if let Some(s) = signal_tx_opt.take() {
1791 let _ = s.send(());
1792 }
1793 return;
1794 }
1795 }
1796 if stream_terminated {
1799 event_tx.take();
1800 if let Some(s) = signal_tx_opt.take() {
1801 let _ = s.send(());
1802 }
1803 }
1804 }
1805 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1806 hb_callback.store(true, Ordering::Release);
1809 }
1810 WebsocketEvent::Error(e) => {
1811 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1815 event_tx.take();
1816 if let Some(s) = signal_tx_opt.take() {
1817 let _ = s.send(());
1818 }
1819 }
1820 WebsocketEvent::Close(code, reason) => {
1821 warn!(code, %reason, "BinanceSpot WebSocket closed");
1822 event_tx.take();
1823 if let Some(s) = signal_tx_opt.take() {
1824 let _ = s.send(());
1825 }
1826 }
1827 _ => {}
1828 }
1829 });
1830 let subscription = UnsubscribeOnDrop::new(subscription);
1832
1833 if let Some(dt) = disconnect_time.take() {
1840 unrecovered.open(&instruments, dt, Utc::now());
1841 let held = known.lock().instruments_among(&instruments);
1843 unchecked.open(held);
1844 }
1845 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known).await;
1846 recover_spot_ended_orders(
1847 &rest,
1848 &rate_limiter,
1849 &known,
1850 &mut unchecked,
1851 &unrecovered,
1852 &tx,
1853 )
1854 .await;
1855
1856 enum DisconnectReason {
1858 Signal,
1859 HeartbeatTimeout,
1860 ConsumerDropped,
1861 }
1862 let reason = {
1863 let mut signal_rx = signal_rx;
1864 let retry_gaps = async {
1870 loop {
1871 let next_check = unchecked.next_due(&unrecovered);
1872 match unrecovered.next_due().into_iter().chain(next_check).min() {
1873 Some(due) => tokio::time::sleep_until(due).await,
1874 None => std::future::pending::<()>().await,
1875 }
1876 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known)
1877 .await;
1878 recover_spot_ended_orders(
1879 &rest,
1880 &rate_limiter,
1881 &known,
1882 &mut unchecked,
1883 &unrecovered,
1884 &tx,
1885 )
1886 .await;
1887 }
1888 };
1889 tokio::pin!(retry_gaps);
1890 let mut grace = true;
1892 loop {
1893 tokio::select! {
1894 biased;
1896 _ = tx.closed() => {
1897 debug!("BinanceSpot account_stream consumer dropped, terminating");
1898 break DisconnectReason::ConsumerDropped;
1899 }
1900 _ = &mut signal_rx => {
1901 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1902 break DisconnectReason::Signal;
1903 }
1904 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1905 let in_grace = std::mem::take(&mut grace);
1912 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1913 backoff.reset();
1917 continue;
1918 }
1919 if in_grace {
1920 continue;
1923 }
1924 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1925 break DisconnectReason::HeartbeatTimeout;
1926 }
1927 () = &mut retry_gaps => {}
1929 }
1930 }
1931 };
1932 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1933
1934 if should_reconnect {
1941 disconnect_time = Some(match reason {
1942 DisconnectReason::HeartbeatTimeout => {
1943 Utc::now()
1944 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1945 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1946 }
1947 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1948 });
1949 }
1950
1951 subscription.unsubscribe();
1959 if let Err(e) = ws.disconnect().await {
1962 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1963 }
1964
1965 if !should_reconnect || tx.is_closed() {
1966 debug!("BinanceSpot connection manager exiting");
1969 break;
1970 }
1971 let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
1974 let last_error = || disconnect_error(heartbeat_timed_out);
1975 if let Err(reason) = backoff.retry_or_end(&rate_limiter, last_error).await {
1976 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
1977 break;
1978 }
1979 }
1980}
1981
1982async fn recover_fills(
2001 rest: &Arc<RestApi>,
2002 rate_limiter: &Arc<RateLimitTracker>,
2003 unrecovered: &mut UnrecoveredFills,
2004 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2005 dedup: &SharedDedupCache,
2006 known: &SharedKnownLiveOrders,
2007) {
2008 use futures::StreamExt;
2009
2010 let due = unrecovered.due(tokio::time::Instant::now());
2011 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2012 return;
2013 };
2014 info!(
2015 gaps = due.len(),
2016 oldest = %gap_time(oldest),
2017 "BinanceSpot recovering fills missed while disconnected"
2018 );
2019
2020 let mut settled = vec![false; due.len()];
2023 let started = AtomicUsize::new(0);
2024 let recovery = async {
2025 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2026 let mut recovered = 0u32;
2027 let mut duplicates = 0u32;
2028
2029 let mut stream =
2035 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2036 started.store(index + 1, Ordering::Relaxed);
2037 let rest = rest.clone();
2038 let rl = rate_limiter.clone();
2039 async move {
2040 let span = MyTradesFrom::Span {
2041 start: gap.start_ms,
2042 end: gap.end_ms,
2043 };
2044 let raw =
2045 match paginate_my_trades(&rest, &rl, &inst, span, RequestKind::Essential)
2046 .await
2047 {
2048 Ok(pages) => pages,
2049 Err(e) => return (index, Err(e)),
2050 };
2051 let totals = recovered_order_totals(
2055 ExchangeId::BinanceSpot,
2056 &inst,
2057 &raw,
2058 order_executions_deadline,
2059 |order_id| {
2060 paginate_my_trades(
2061 &rest,
2062 &rl,
2063 &inst,
2064 MyTradesFrom::Order(order_id),
2065 RequestKind::Essential,
2066 )
2067 },
2068 )
2069 .await;
2070 let cids = recovered_order_cids(
2073 ExchangeId::BinanceSpot,
2074 &inst,
2075 &raw,
2076 order_executions_deadline,
2077 |order_id| fetch_order_cid(&rest, &rl, &inst, order_id),
2078 )
2079 .await;
2080 let trades: Vec<_> = raw
2081 .iter()
2082 .filter_map(|t| {
2083 let mut trade = convert_my_trade(t, &inst)?;
2084 trade.order_filled_quantity =
2085 t.id.and_then(|id| totals.get(&id).copied());
2086 trade.client_order_id =
2087 t.order_id.and_then(|id| cids.get(&id).cloned());
2088 Some(trade)
2089 })
2090 .collect();
2091 (index, Ok(trades))
2092 }
2093 }))
2094 .buffer_unordered(8);
2095 while let Some((index, result)) = stream.next().await {
2096 let (inst, gap) = &due[index];
2097 settled[index] = true;
2098 let trades = match result {
2099 Ok(trades) => trades,
2100 Err(e) => {
2101 gap_failed(
2102 unrecovered,
2103 tx,
2104 ExchangeId::BinanceSpot,
2105 inst,
2106 gap,
2107 FillRecoveryFailure::Request(e.to_string()),
2108 );
2109 continue;
2110 }
2111 };
2112 for trade in trades {
2113 let event = UnindexedAccountEvent::new(
2116 ExchangeId::BinanceSpot,
2117 AccountEventKind::Trade(trade),
2118 );
2119 if let Some(key) = dedup_key_from_event(&event)
2122 && is_duplicate(dedup, key)
2123 {
2124 duplicates += 1;
2125 continue;
2126 }
2127 let sent = {
2130 let mut known = known.lock();
2131 known.observe(&event);
2132 tx.send(event)
2133 };
2134 if sent.is_err() {
2135 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
2138 return;
2139 }
2140 recovered += 1;
2141 }
2142 unrecovered.recovered(inst, gap);
2143 }
2144 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
2145 };
2146 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2150 .await
2151 .is_err()
2152 {
2153 let started = started.load(Ordering::Relaxed);
2154 for ((inst, gap), _) in due[..started]
2155 .iter()
2156 .zip(&settled)
2157 .filter(|(_, settled)| !**settled)
2158 {
2159 gap_failed(
2160 unrecovered,
2161 tx,
2162 ExchangeId::BinanceSpot,
2163 inst,
2164 gap,
2165 FillRecoveryFailure::TimedOut {
2166 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2167 },
2168 );
2169 }
2170 }
2171}
2172
2173fn convert_balance_entry(
2187 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
2188 now: chrono::DateTime<Utc>,
2189) -> Option<AssetBalance<AssetNameExchange>> {
2190 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2191 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2192 Some(v) => v,
2193 None => {
2194 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
2195 return None;
2196 }
2197 };
2198 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2199 Some(v) => v,
2200 None => {
2201 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
2202 return None;
2203 }
2204 };
2205 Some(AssetBalance::new(
2206 asset_name,
2207 Balance::new(free + locked, free),
2208 now,
2209 ))
2210}
2211
2212fn filter_and_convert_balances(
2213 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
2214 assets: &[AssetNameExchange],
2215) -> Vec<AssetBalance<AssetNameExchange>> {
2216 let now = Utc::now();
2217 if assets.is_empty() {
2219 return balances
2220 .into_iter()
2221 .filter_map(|b| convert_balance_entry(b, now))
2222 .collect();
2223 }
2224 if assets.len() <= 16 {
2227 return balances
2228 .into_iter()
2229 .filter_map(|b| {
2230 let asset_name_str = b.asset.as_deref()?;
2231 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
2232 return None;
2233 }
2234 convert_balance_entry(b, now)
2235 })
2236 .collect();
2237 }
2238 use std::collections::HashSet;
2239 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
2240 balances
2241 .into_iter()
2242 .filter_map(|b| {
2243 let asset_name_str = b.asset.as_deref()?;
2244 if !asset_set.contains(asset_name_str) {
2245 return None;
2246 }
2247 convert_balance_entry(b, now)
2248 })
2249 .collect()
2250}
2251
2252fn convert_my_trade(
2254 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
2255 instrument: &InstrumentNameExchange,
2256) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
2257 let trade_id_raw = match t.id {
2258 Some(id) => id,
2259 None => {
2260 warn!(%instrument, "BinanceSpot trade missing id");
2261 return None;
2262 }
2263 };
2264 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
2265 let order_id = match t.order_id {
2266 Some(id) => OrderId(format_smolstr!("{}", id)),
2267 None => {
2268 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
2269 return None;
2270 }
2271 };
2272 let side = match t.is_buyer {
2273 Some(is_buyer) => {
2274 if is_buyer {
2275 Side::Buy
2276 } else {
2277 Side::Sell
2278 }
2279 }
2280 None => {
2281 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
2282 return None;
2283 }
2284 };
2285 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2286 Some(v) => v,
2287 None => {
2288 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
2289 return None;
2290 }
2291 };
2292 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2293 Some(v) => v,
2294 None => {
2295 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2296 return None;
2297 }
2298 };
2299 let commission = t
2300 .commission
2301 .as_deref()
2302 .and_then(|s| Decimal::from_str(s).ok())
2303 .unwrap_or(Decimal::ZERO);
2304 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
2305 Some(ts) => ts,
2306 None => {
2307 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2308 Utc::now()
2309 }
2310 };
2311
2312 let fee_asset = t
2318 .commission_asset
2319 .as_deref()
2320 .map(AssetNameExchange::from)
2321 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2322
2323 Some(Trade::new(
2324 trade_id,
2325 order_id,
2326 instrument.clone(),
2327 StrategyId::unknown(), time_exchange,
2329 side,
2330 price,
2331 quantity,
2332 None,
2334 AssetFees::new(fee_asset, commission, None),
2335 ))
2336}
2337
2338fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2363 let (event_type, event) = match parse_user_data_frame(frame) {
2364 UserDataFrame::Response => return false,
2365 UserDataFrame::Event {
2366 event_type, event, ..
2367 } => (event_type, event),
2368 UserDataFrame::Unrecognised => {
2369 static SEEN: AtomicU64 = AtomicU64::new(0);
2370 log_unrecognised_frame("BinanceSpot", &SEEN, frame);
2371 return false;
2372 }
2373 };
2374 match event_type {
2376 "executionReport" => {
2377 match serde_json::from_str::<ExecutionReport>(event) {
2380 Ok(report) => {
2381 convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
2382 }
2383 Err(e) => {
2384 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable executionReport, dropping")
2385 }
2386 }
2387 false
2388 }
2389 "outboundAccountPosition" => {
2390 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2391 event,
2392 ) {
2393 Ok(position) => convert_account_position(position, buf),
2394 Err(e) => {
2395 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2396 }
2397 }
2398 false
2399 }
2400 "balanceUpdate" => {
2401 false
2408 }
2409 "eventStreamTerminated" => {
2410 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2414 true
2415 }
2416 "listStatus" | "externalLockUpdate" => {
2417 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2421 false
2422 }
2423 _ => {
2424 static SEEN: UnhandledEvents = UnhandledEvents::new();
2425 log_unhandled_event("BinanceSpot", &SEEN, event_type, event);
2426 false
2427 }
2428 }
2429}
2430
2431fn convert_account_position(
2436 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2437 buf: &mut Vec<UnindexedAccountEvent>,
2438) {
2439 let time_exchange = position
2442 .u
2443 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2444 .unwrap_or_else(Utc::now);
2445
2446 for b in position.b_uppercase.unwrap_or_default() {
2447 let asset = match b.a {
2448 Some(a) => AssetNameExchange::new(a),
2449 None => {
2450 warn!("BinanceSpot account position entry missing asset name");
2451 continue;
2452 }
2453 };
2454 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2455 Some(v) => v,
2456 None => {
2457 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2458 continue;
2459 }
2460 };
2461 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2462 Some(v) => v,
2463 None => {
2464 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2465 continue;
2466 }
2467 };
2468 let update =
2473 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2474 buf.push(UnindexedAccountEvent::new(
2475 ExchangeId::BinanceSpot,
2476 AccountEventKind::BalanceStreamUpdate(
2477 rustrade_integration::collection::snapshot::Snapshot::new(update),
2478 ),
2479 ));
2480 }
2481}
2482
2483fn convert_order_kind_tif(
2514 kind: OrderKind,
2515 tif: TimeInForce,
2516) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2517 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2521 let type_enum = match binance_type {
2522 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2523 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2524 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2525 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2526 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2527 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2528 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2529 };
2530 let tif_enum = binance_tif.map(|t| match t {
2531 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2532 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2533 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2534 });
2535 Some((type_enum, tif_enum))
2536}
2537
2538#[cfg(test)]
2539#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2541 use super::*;
2542 use crate::client::binance::shared::*;
2543 use crate::client::dedup::{DedupEventKind, DedupKey};
2544 use crate::error::StreamTerminationReason;
2545 use crate::order::{TrailingOffsetType, id::ClientOrderId};
2546 use binance_sdk::common::errors::WebsocketError;
2547 use smol_str::SmolStr;
2548
2549 #[test]
2550 fn test_binance_spot_config_new_uses_testnet_by_default() {
2551 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2552 assert!(cfg.testnet);
2553 assert_eq!(cfg.api_key(), "my_key");
2554 }
2555
2556 #[test]
2557 fn test_binance_spot_config_production_is_explicit() {
2558 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2559 assert!(!cfg.testnet);
2560 }
2561
2562 #[test]
2563 fn test_binance_spot_config_debug_redacts_credentials() {
2564 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2565 let debug = format!("{cfg:?}");
2566
2567 assert!(!debug.contains("my_key"), "api_key should be redacted");
2568 assert!(
2569 !debug.contains("my_secret"),
2570 "secret_key should be redacted"
2571 );
2572 assert!(debug.contains("testnet: true"));
2573 }
2574
2575 #[test]
2576 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2577 let cfg: BinanceSpotConfig = serde_json::from_str(
2578 r#"{
2579 "api_key": "my_key",
2580 "secret_key": "my_secret"
2581 }"#,
2582 )
2583 .unwrap();
2584
2585 assert!(cfg.testnet);
2586 assert_eq!(cfg.api_key(), "my_key");
2587 }
2588
2589 #[test]
2590 fn test_binance_spot_config_deserialize_testnet_true() {
2591 let cfg: BinanceSpotConfig = serde_json::from_str(
2592 r#"{
2593 "api_key": "my_key",
2594 "secret_key": "my_secret",
2595 "testnet": true
2596 }"#,
2597 )
2598 .unwrap();
2599
2600 assert!(cfg.testnet);
2601 }
2602
2603 #[test]
2604 fn test_binance_spot_config_deserialize_testnet_false() {
2605 let cfg: BinanceSpotConfig = serde_json::from_str(
2606 r#"{
2607 "api_key": "my_key",
2608 "secret_key": "my_secret",
2609 "testnet": false
2610 }"#,
2611 )
2612 .unwrap();
2613
2614 assert!(!cfg.testnet);
2615 }
2616
2617 #[test]
2618 #[serial_test::serial]
2619 fn test_binance_spot_config_from_env_defaults_to_testnet() {
2620 temp_env::with_vars(
2621 [
2622 ("BINANCE_API_KEY", Some("my_key")),
2623 ("BINANCE_SECRET_KEY", Some("my_secret")),
2624 ("BINANCE_TESTNET", None),
2625 ],
2626 || {
2627 let cfg = BinanceSpotConfig::from_env().unwrap();
2628 assert!(cfg.testnet);
2629 assert_eq!(cfg.api_key(), "my_key");
2630 },
2631 );
2632 }
2633
2634 #[test]
2635 #[serial_test::serial]
2636 fn test_binance_spot_config_from_env_accepts_explicit_production() {
2637 temp_env::with_vars(
2638 [
2639 ("BINANCE_API_KEY", Some("my_key")),
2640 ("BINANCE_SECRET_KEY", Some("my_secret")),
2641 ("BINANCE_TESTNET", Some("false")),
2642 ],
2643 || {
2644 let cfg = BinanceSpotConfig::from_env().unwrap();
2645 assert!(!cfg.testnet);
2646 },
2647 );
2648 }
2649
2650 #[test]
2651 #[serial_test::serial]
2652 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2653 temp_env::with_vars(
2654 [
2655 ("BINANCE_API_KEY", Some("my_key")),
2656 ("BINANCE_SECRET_KEY", Some("my_secret")),
2657 ("BINANCE_TESTNET", Some("maybe")),
2658 ],
2659 || {
2660 let err = BinanceSpotConfig::from_env().unwrap_err();
2661 assert!(
2662 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2663 );
2664 },
2665 );
2666 }
2667
2668 #[test]
2669 #[serial_test::serial]
2670 fn test_binance_spot_config_from_env_requires_credentials() {
2671 temp_env::with_vars(
2672 [
2673 ("BINANCE_API_KEY", None),
2674 ("BINANCE_SECRET_KEY", Some("my_secret")),
2675 ("BINANCE_TESTNET", None),
2676 ],
2677 || {
2678 let err = BinanceSpotConfig::from_env().unwrap_err();
2679 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2680 },
2681 );
2682 }
2683
2684 #[test]
2685 fn test_parse_side() {
2686 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2687 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2688 assert_eq!(parse_side("buy"), None);
2689 assert_eq!(parse_side("UNKNOWN"), None);
2690 }
2691
2692 #[test]
2693 fn test_parse_time_in_force() {
2694 assert_eq!(
2695 parse_time_in_force("GTC"),
2696 TimeInForce::GoodUntilCancelled { post_only: false }
2697 );
2698 assert_eq!(
2699 parse_time_in_force("GTX"),
2700 TimeInForce::GoodUntilCancelled { post_only: true }
2701 );
2702 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2703 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2704 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2705 assert_eq!(
2707 parse_time_in_force("UNKNOWN"),
2708 TimeInForce::GoodUntilCancelled { post_only: false }
2709 );
2710 }
2711
2712 #[test]
2713 fn test_convert_order_kind_tif() {
2714 use rust_decimal::Decimal;
2715
2716 assert!(matches!(
2718 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2719 Some((OrderPlaceTypeEnum::Market, None))
2720 ));
2721 assert!(matches!(
2722 convert_order_kind_tif(
2723 OrderKind::Limit,
2724 TimeInForce::GoodUntilCancelled { post_only: false }
2725 ),
2726 Some((
2727 OrderPlaceTypeEnum::Limit,
2728 Some(OrderPlaceTimeInForceEnum::Gtc)
2729 ))
2730 ));
2731 assert!(matches!(
2732 convert_order_kind_tif(
2733 OrderKind::Limit,
2734 TimeInForce::GoodUntilCancelled { post_only: true }
2735 ),
2736 Some((OrderPlaceTypeEnum::LimitMaker, None))
2737 ));
2738 assert!(matches!(
2739 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2740 Some((
2741 OrderPlaceTypeEnum::Limit,
2742 Some(OrderPlaceTimeInForceEnum::Fok)
2743 ))
2744 ));
2745 assert!(matches!(
2746 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2747 Some((
2748 OrderPlaceTypeEnum::Limit,
2749 Some(OrderPlaceTimeInForceEnum::Ioc)
2750 ))
2751 ));
2752 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2755
2756 assert!(matches!(
2758 convert_order_kind_tif(
2759 OrderKind::Stop {
2760 trigger_price: Decimal::from(100)
2761 },
2762 TimeInForce::GoodUntilCancelled { post_only: false }
2763 ),
2764 Some((OrderPlaceTypeEnum::StopLoss, None))
2765 ));
2766 assert!(matches!(
2767 convert_order_kind_tif(
2768 OrderKind::StopLimit {
2769 trigger_price: Decimal::from(100)
2770 },
2771 TimeInForce::GoodUntilCancelled { post_only: false }
2772 ),
2773 Some((
2774 OrderPlaceTypeEnum::StopLossLimit,
2775 Some(OrderPlaceTimeInForceEnum::Gtc)
2776 ))
2777 ));
2778 assert!(matches!(
2779 convert_order_kind_tif(
2780 OrderKind::TakeProfit {
2781 trigger_price: Decimal::from(150)
2782 },
2783 TimeInForce::ImmediateOrCancel
2784 ),
2785 Some((OrderPlaceTypeEnum::TakeProfit, None))
2786 ));
2787 assert!(matches!(
2788 convert_order_kind_tif(
2789 OrderKind::TakeProfitLimit {
2790 trigger_price: Decimal::from(150)
2791 },
2792 TimeInForce::FillOrKill
2793 ),
2794 Some((
2795 OrderPlaceTypeEnum::TakeProfitLimit,
2796 Some(OrderPlaceTimeInForceEnum::Fok)
2797 ))
2798 ));
2799
2800 assert!(matches!(
2802 convert_order_kind_tif(
2803 OrderKind::TrailingStop {
2804 offset: Decimal::from(100),
2805 offset_type: TrailingOffsetType::BasisPoints,
2806 activation_price: None,
2807 },
2808 TimeInForce::GoodUntilCancelled { post_only: false }
2809 ),
2810 Some((OrderPlaceTypeEnum::StopLoss, None))
2811 ));
2812 assert!(matches!(
2813 convert_order_kind_tif(
2814 OrderKind::TrailingStop {
2815 offset: Decimal::from(5),
2816 offset_type: TrailingOffsetType::Percentage,
2817 activation_price: None,
2818 },
2819 TimeInForce::GoodUntilCancelled { post_only: false }
2820 ),
2821 Some((OrderPlaceTypeEnum::StopLoss, None))
2822 ));
2823
2824 assert!(
2826 convert_order_kind_tif(
2827 OrderKind::TrailingStop {
2828 offset: Decimal::from(10),
2829 offset_type: TrailingOffsetType::Absolute,
2830 activation_price: None,
2831 },
2832 TimeInForce::GoodUntilCancelled { post_only: false }
2833 )
2834 .is_none()
2835 );
2836
2837 assert!(
2839 convert_order_kind_tif(
2840 OrderKind::TrailingStopLimit {
2841 offset: Decimal::from(100),
2842 offset_type: TrailingOffsetType::BasisPoints,
2843 stop_price: Decimal::from(95),
2844 limit_offset: Decimal::from(10),
2845 },
2846 TimeInForce::GoodUntilCancelled { post_only: false }
2847 )
2848 .is_none()
2849 );
2850 }
2851
2852 #[test]
2855 fn a_fixed_limit_trailing_stop_converts_to_stop_loss_limit() {
2856 let fixed_limit = |offset_type| OrderKind::TrailingStopFixedLimit {
2857 offset: Decimal::from(100),
2858 offset_type,
2859 activation_price: Some(Decimal::from(50_000)),
2860 };
2861 assert!(matches!(
2862 convert_order_kind_tif(
2863 fixed_limit(TrailingOffsetType::BasisPoints),
2864 TimeInForce::GoodUntilCancelled { post_only: false }
2865 ),
2866 Some((
2867 OrderPlaceTypeEnum::StopLossLimit,
2868 Some(OrderPlaceTimeInForceEnum::Gtc)
2869 ))
2870 ));
2871 assert!(
2872 convert_order_kind_tif(
2873 fixed_limit(TrailingOffsetType::Absolute),
2874 TimeInForce::GoodUntilCancelled { post_only: false }
2875 )
2876 .is_none()
2877 );
2878 }
2879
2880 #[test]
2881 fn test_parse_binance_api_error() {
2882 let instrument = InstrumentNameExchange::new("BTCUSDT");
2883
2884 assert!(matches!(
2885 parse_binance_api_error("Insufficient balance".into(), &instrument),
2886 ApiError::BalanceInsufficient(None, _)
2887 ));
2888 assert!(matches!(
2889 parse_binance_api_error("Not enough funds".into(), &instrument),
2890 ApiError::BalanceInsufficient(None, _)
2891 ));
2892 assert_eq!(
2893 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2894 ApiError::rate_limited(None)
2895 );
2896 assert_eq!(
2897 parse_binance_api_error("Error code -1015".into(), &instrument),
2898 ApiError::rate_limited(None)
2899 );
2900 assert_eq!(
2902 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2903 ApiError::rate_limited(None)
2904 );
2905 assert_eq!(
2907 parse_binance_api_error("Error code -2011".into(), &instrument),
2908 ApiError::OrderAlreadyCancelled
2909 );
2910 assert!(matches!(
2912 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2913 ApiError::OrderAlreadyCancelled
2914 ));
2915 assert!(matches!(
2917 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2918 ApiError::OrderAlreadyCancelled
2919 ));
2920 assert!(matches!(
2922 parse_binance_api_error("Unknown order encountered".into(), &instrument),
2923 ApiError::OrderRejected(_)
2924 ));
2925 assert!(matches!(
2926 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2927 ApiError::InstrumentInvalid(_, _)
2928 ));
2929 assert!(matches!(
2931 parse_binance_api_error(
2932 "Server-side response error (code -2010): Account has insufficient balance".into(),
2933 &instrument
2934 ),
2935 ApiError::BalanceInsufficient(None, _)
2936 ));
2937 assert!(matches!(
2938 parse_binance_api_error(
2939 "Server-side response error (code -2010): Order would trigger immediately.".into(),
2940 &instrument
2941 ),
2942 ApiError::OrderRejected(_)
2943 ));
2944 assert!(matches!(
2945 parse_binance_api_error("Some other error".into(), &instrument),
2946 ApiError::OrderRejected(_)
2947 ));
2948 }
2949
2950 #[test]
2951 fn test_contains_error_code_suffix_guard() {
2952 assert!(
2954 !contains_error_code("-20130", "-2013"),
2955 "-20130 should not match -2013"
2956 );
2957 assert!(
2958 !contains_error_code("-20131", "-2013"),
2959 "-20131 should not match -2013"
2960 );
2961 assert!(contains_error_code("-2013", "-2013"), "exact match");
2963 assert!(
2964 contains_error_code("Error -2013: text", "-2013"),
2965 "match with trailing text"
2966 );
2967 }
2968
2969 #[test]
2970 fn test_contains_error_code_prefix_guard() {
2971 assert!(
2974 !contains_error_code("1-2013", "-2013"),
2975 "1-2013 should not match -2013"
2976 );
2977 assert!(
2978 !contains_error_code("error 1-2013 text", "-2013"),
2979 "embedded 1-2013 should not match"
2980 );
2981 assert!(
2983 contains_error_code("code=-2013,", "-2013"),
2984 "=-2013 prefix should match"
2985 );
2986 assert!(
2987 contains_error_code(" -2013 ", "-2013"),
2988 "space prefix should match"
2989 );
2990 }
2991
2992 #[test]
2993 fn test_contains_error_code_second_occurrence_valid() {
2994 assert!(
2997 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
2998 "second valid occurrence must match when first fails digit guard"
2999 );
3000 assert!(
3002 contains_error_code("-2013 or -20130", "-2013"),
3003 "first valid occurrence must match when second is a longer code"
3004 );
3005 }
3006
3007 #[test]
3008 fn test_dedup_cache() {
3009 let cache = new_dedup_cache();
3010 let key = DedupKey {
3011 instrument: SmolStr::from("BTCUSDT"),
3012 id: SmolStr::from("12345"),
3013 kind: DedupEventKind::Trade,
3014 };
3015
3016 assert!(!is_duplicate(&cache, key));
3018 let key = DedupKey {
3020 instrument: SmolStr::from("BTCUSDT"),
3021 id: SmolStr::from("12345"),
3022 kind: DedupEventKind::Trade,
3023 };
3024 assert!(is_duplicate(&cache, key));
3025
3026 let key2 = DedupKey {
3028 instrument: SmolStr::from("BTCUSDT"),
3029 id: SmolStr::from("12345"),
3030 kind: DedupEventKind::OrderState {
3031 filled_quantity: Decimal::ZERO,
3032 },
3033 };
3034 assert!(!is_duplicate(&cache, key2));
3035 }
3036
3037 #[test]
3041 fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
3042 let cache = new_dedup_cache();
3043 let state = |filled: &str| DedupKey {
3044 instrument: SmolStr::from("BTCUSDT"),
3045 id: SmolStr::from("12345"),
3046 kind: DedupEventKind::OrderState {
3047 filled_quantity: Decimal::from_str(filled).unwrap(),
3048 },
3049 };
3050
3051 assert!(!is_duplicate(&cache, state("0")), "ack is new");
3053 assert!(
3054 !is_duplicate(&cache, state("1")),
3055 "first fill is not the ack"
3056 );
3057 assert!(
3058 !is_duplicate(&cache, state("2")),
3059 "second fill is not the first"
3060 );
3061
3062 assert!(
3065 is_duplicate(&cache, state("0")),
3066 "replayed ack is a duplicate"
3067 );
3068 assert!(
3069 is_duplicate(&cache, state("2")),
3070 "replayed fill is a duplicate"
3071 );
3072
3073 assert!(
3075 is_duplicate(&cache, state("2.00")),
3076 "2.00 must be the same key as 2"
3077 );
3078 }
3079
3080 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
3087 use crate::client::dedup::dedup_key_from_event;
3088
3089 let cache = new_dedup_cache();
3090 let mut delivered = Vec::new();
3091 let mut buf = Vec::new();
3092 for frame in frames {
3093 let _ = convert_user_data_events(frame, &mut buf);
3094 for ev in buf.drain(..) {
3095 if let Some(key) = dedup_key_from_event(&ev)
3096 && is_duplicate(&cache, key)
3097 {
3098 continue;
3099 }
3100 delivered.push(ev);
3101 }
3102 }
3103 delivered
3104 }
3105
3106 const WS_NEW: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport","s":"BTCUSDT",
3108 "i":12345,"c":"client-1","x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2",
3109 "p":"100","z":"0","T":1700000000000}}"#;
3110
3111 const WS_PARTIAL_FILL: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport",
3112 "s":"BTCUSDT","i":12345,"c":"client-1","x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY",
3113 "o":"LIMIT","f":"GTC","q":"2","p":"100","z":"1","l":"1","L":"100","t":555,"n":"0.1",
3114 "N":"USDT","T":1700000001000}}"#;
3115
3116 #[test]
3124 fn ws_fill_snapshot_survives_the_dedup_gate() {
3125 use crate::order::state::ActiveOrderState;
3126
3127 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
3128
3129 assert_eq!(
3130 delivered.len(),
3131 3,
3132 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
3133 );
3134
3135 let snapshots: Vec<Decimal> = delivered
3136 .iter()
3137 .filter_map(|ev| match &ev.kind {
3138 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
3139 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
3140 _ => None,
3141 },
3142 _ => None,
3143 })
3144 .collect();
3145
3146 assert_eq!(
3147 snapshots,
3148 vec![Decimal::ZERO, Decimal::ONE],
3149 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
3150 );
3151 }
3152
3153 #[test]
3156 fn ws_replayed_frame_is_still_deduplicated() {
3157 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
3158
3159 assert_eq!(
3160 delivered.len(),
3161 3,
3162 "re-delivering both frames must add nothing, got: {delivered:?}"
3163 );
3164 }
3165
3166 #[test]
3167 fn test_is_rate_limit_error() {
3168 assert!(is_rate_limit_error(&anyhow::anyhow!(
3170 "Too many requests. You are being rate-limited. Please slow down."
3171 )));
3172 assert!(is_rate_limit_error(&anyhow::anyhow!(
3174 "The IP address has been banned for exceeding rate limits. Contact support."
3175 )));
3176 assert!(is_rate_limit_error(&anyhow::anyhow!(
3178 "Error -1015: too many new orders"
3179 )));
3180 assert!(is_rate_limit_error(&anyhow::anyhow!(
3181 "Error -1003: too many requests"
3182 )));
3183 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
3185 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
3186 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
3187 assert!(
3189 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
3190 "-10150 should not match -1015"
3191 );
3192 assert!(
3193 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
3194 "-10030 should not match -1003"
3195 );
3196 }
3197
3198 #[test]
3199 fn test_classify_ws_order_error_recognises_venue_responses_only() {
3200 let instrument = InstrumentNameExchange::new("BTCUSDT");
3201
3202 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
3204 code: -2010,
3205 message: "Account has insufficient balance for requested action.".into(),
3206 retry_after: None,
3207 });
3208 assert!(
3209 matches!(
3210 classify_ws_order_error(&rejection, &instrument),
3211 Some(OrderError::Rejected(ApiError::BalanceInsufficient(None, _)))
3212 ),
3213 "ResponseError should be classified as a venue response"
3214 );
3215
3216 let transport = anyhow::anyhow!("connection reset by peer");
3218 assert!(
3219 classify_ws_order_error(&transport, &instrument).is_none(),
3220 "plain transport error should not be classified as a venue response"
3221 );
3222
3223 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
3225 assert!(
3226 classify_ws_order_error(&rate_limit, &instrument).is_none(),
3227 "rate-limit string error should not be classified as a venue response"
3228 );
3229 }
3230
3231 #[test]
3232 fn test_dedup_key_from_event_trade_includes_instrument() {
3233 use crate::order::id::{OrderId, StrategyId};
3234 use crate::trade::{AssetFees, Trade, TradeId};
3235 use chrono::Utc;
3236 use rust_decimal::Decimal;
3237 use rustrade_instrument::Side;
3238
3239 let instrument = InstrumentNameExchange::new("BTCUSDT");
3240 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3241 TradeId::new("9001"),
3242 OrderId::new("4242"),
3243 instrument.clone(),
3244 StrategyId::unknown(),
3245 Utc::now(),
3246 Side::Buy,
3247 Decimal::ZERO,
3248 Decimal::ZERO,
3249 None,
3250 AssetFees::new(
3251 AssetNameExchange::from("USDT"),
3252 Decimal::ZERO,
3253 Some(Decimal::ZERO),
3254 ),
3255 );
3256 let event =
3257 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
3258
3259 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
3260 assert_eq!(key.kind, DedupEventKind::Trade);
3261 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
3262 assert_eq!(
3263 key.instrument.as_str(),
3264 "BTCUSDT",
3265 "key.instrument should be the symbol"
3266 );
3267
3268 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
3270 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3271 TradeId::new("9001"),
3272 OrderId::new("7777"),
3273 instrument2,
3274 StrategyId::unknown(),
3275 Utc::now(),
3276 Side::Buy,
3277 Decimal::ZERO,
3278 Decimal::ZERO,
3279 None,
3280 AssetFees::new(
3281 AssetNameExchange::from("USDT"),
3282 Decimal::ZERO,
3283 Some(Decimal::ZERO),
3284 ),
3285 );
3286 let event2 =
3287 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
3288 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
3289 assert_ne!(
3290 key, key2,
3291 "same trade ID on different symbols must produce distinct keys"
3292 );
3293 }
3294
3295 fn make_balance_inner(
3300 asset: &str,
3301 free: &str,
3302 locked: &str,
3303 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3304 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3305 a: Some(asset.to_string()),
3306 f: Some(free.to_string()),
3307 l: Some(locked.to_string()),
3308 }
3309 }
3310
3311 #[test]
3312 fn test_convert_account_position_happy_path() {
3313 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3314 u: Some(1_700_000_000_000),
3315 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
3316 ..Default::default()
3317 };
3318 let mut buf = Vec::new();
3319 convert_account_position(position, &mut buf);
3320
3321 assert_eq!(buf.len(), 1);
3322 match &buf[0].kind {
3323 AccountEventKind::BalanceStreamUpdate(snap) => {
3324 let update = &snap.0;
3325 assert_eq!(update.asset.as_ref(), "BTC");
3326 let expected_free = Decimal::from_str("1.5").unwrap();
3328 let expected_locked = Decimal::from_str("0.5").unwrap();
3329 assert_eq!(update.update.free, expected_free);
3330 assert_eq!(update.update.locked, expected_locked);
3331 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3332 }
3333 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3334 }
3335 }
3336
3337 #[test]
3338 fn test_convert_account_position_u_field_none_uses_now() {
3339 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3341 u: None,
3342 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3343 ..Default::default()
3344 };
3345 let mut buf = Vec::new();
3346 convert_account_position(position, &mut buf);
3347 assert_eq!(buf.len(), 1);
3348 }
3349
3350 #[test]
3351 fn test_convert_account_position_missing_asset_name_skipped() {
3352 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3353 u: Some(1_700_000_000_000),
3354 b_uppercase: Some(vec![
3355 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3356 a: None, f: Some("1.0".to_string()),
3358 l: Some("0.0".to_string()),
3359 },
3360 make_balance_inner("USDT", "100.0", "0.0"), ]),
3362 ..Default::default()
3363 };
3364 let mut buf = Vec::new();
3365 convert_account_position(position, &mut buf);
3366 assert_eq!(buf.len(), 1);
3368 match &buf[0].kind {
3369 AccountEventKind::BalanceStreamUpdate(snap) => {
3370 assert_eq!(snap.0.asset.as_ref(), "USDT");
3371 }
3372 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3373 }
3374 }
3375
3376 #[test]
3377 fn test_convert_account_position_unparseable_free_skipped() {
3378 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3379 u: Some(1_700_000_000_000),
3380 b_uppercase: Some(vec![
3381 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3382 a: Some("BTC".to_string()),
3383 f: Some("not-a-number".to_string()),
3384 l: Some("0.0".to_string()),
3385 },
3386 make_balance_inner("ETH", "1.0", "0.0"), ]),
3388 ..Default::default()
3389 };
3390 let mut buf = Vec::new();
3391 convert_account_position(position, &mut buf);
3392 assert_eq!(buf.len(), 1);
3394 match &buf[0].kind {
3395 AccountEventKind::BalanceStreamUpdate(snap) => {
3396 assert_eq!(snap.0.asset.as_ref(), "ETH");
3397 }
3398 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3399 }
3400 }
3401
3402 #[test]
3403 fn test_convert_account_position_empty_balances() {
3404 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3405 u: Some(1_700_000_000_000),
3406 b_uppercase: Some(vec![]),
3407 ..Default::default()
3408 };
3409 let mut buf = Vec::new();
3410 convert_account_position(position, &mut buf);
3411 assert!(
3412 buf.is_empty(),
3413 "empty balance list should produce no events"
3414 );
3415 }
3416
3417 #[test]
3418 fn test_convert_account_position_b_field_none() {
3419 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3420 u: Some(1_700_000_000_000),
3421 b_uppercase: None, ..Default::default()
3423 };
3424 let mut buf = Vec::new();
3425 convert_account_position(position, &mut buf);
3426 assert!(buf.is_empty(), "None B field should produce no events");
3427 }
3428
3429 #[tokio::test]
3431 async fn test_exponential_backoff_exhaustion() {
3432 tokio::time::pause(); let mut backoff = ExponentialBackoff::new();
3434
3435 for i in 0..MAX_RECONNECT_ATTEMPTS {
3437 assert!(
3438 backoff.wait().await,
3439 "expected true on attempt {i} (before exhaustion)"
3440 );
3441 }
3442
3443 assert!(!backoff.wait().await, "expected false after max attempts");
3445 }
3446
3447 #[tokio::test]
3448 async fn test_exponential_backoff_reset() {
3449 tokio::time::pause();
3450 let mut backoff = ExponentialBackoff::new();
3451
3452 for _ in 0..MAX_RECONNECT_ATTEMPTS {
3453 backoff.wait().await;
3454 }
3455 assert!(!backoff.wait().await, "should be exhausted");
3456
3457 backoff.reset();
3458 assert!(backoff.wait().await, "should succeed again after reset");
3459 }
3460
3461 fn make_base_report() -> ExecutionReport {
3465 ExecutionReport {
3466 symbol: Some("BTCUSDT".to_string()),
3467 order_id: Some(12345),
3468 client_order_id: Some("client-1".to_string()),
3469 transaction_time: Some(1_700_000_000_000),
3470 ..Default::default()
3471 }
3472 }
3473
3474 fn convert(report: ExecutionReport) -> Vec<UnindexedAccountEvent> {
3476 let mut buf = Vec::new();
3477 convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
3478 buf
3479 }
3480
3481 fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
3486 assert!(
3487 events.len() <= 1,
3488 "expected at most a single event, got: {events:?}"
3489 );
3490 events.pop()
3491 }
3492
3493 #[test]
3494 fn test_convert_execution_report_new() {
3495 let report = ExecutionReport {
3496 execution_type: Some("NEW".to_string()),
3497 side: Some("BUY".to_string()),
3498 order_type: Some("LIMIT".to_string()),
3499 price: Some("50000.00".to_string()),
3500 order_quantity: Some("0.01".to_string()),
3501 time_in_force: Some("GTC".to_string()),
3502 cumulative_filled_quantity: Some("0".to_string()),
3503 ..make_base_report()
3504 };
3505
3506 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3507 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3508 match &event.kind {
3509 AccountEventKind::OrderSnapshot(snap) => {
3510 let order = &snap.0;
3511 assert_eq!(order.side, Side::Buy);
3512 assert_eq!(order.kind, OrderKind::Limit);
3513 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3514 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3515 assert_eq!(order.key.cid.0.as_str(), "client-1");
3516 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3517 }
3518 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3519 }
3520 }
3521
3522 #[test]
3525 fn a_trailing_stop_loss_limit_report_keeps_its_limit_and_activation_price() {
3526 let report = ExecutionReport {
3527 execution_type: Some("NEW".to_string()),
3528 side: Some("SELL".to_string()),
3529 order_type: Some("STOP_LOSS_LIMIT".to_string()),
3530 price: Some("49000.00".to_string()),
3531 stop_price: Some("50000.00".to_string()),
3532 trailing_delta: Some(WireInt::Int(25)),
3533 order_quantity: Some("0.01".to_string()),
3534 time_in_force: Some("GTC".to_string()),
3535 cumulative_filled_quantity: Some("0".to_string()),
3536 ..make_base_report()
3537 };
3538
3539 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3540 let AccountEventKind::OrderSnapshot(snap) = &event.kind else {
3541 panic!("NEW should yield OrderSnapshot, got {:?}", event.kind);
3542 };
3543 assert_eq!(
3544 snap.0.kind,
3545 OrderKind::TrailingStopFixedLimit {
3546 offset: Decimal::from(25),
3547 offset_type: TrailingOffsetType::BasisPoints,
3548 activation_price: Some(Decimal::from(50_000)),
3549 }
3550 );
3551 assert_eq!(snap.0.price, Some(Decimal::from(49_000)));
3552 }
3553
3554 #[test]
3558 fn a_report_without_an_order_type_has_no_order_snapshot() {
3559 let new = ExecutionReport {
3560 execution_type: Some("NEW".to_string()),
3561 side: Some("BUY".to_string()),
3562 price: Some("50000.00".to_string()),
3563 order_quantity: Some("0.01".to_string()),
3564 ..make_base_report()
3565 };
3566 assert!(convert(new).is_empty());
3567
3568 let fill = ExecutionReport {
3569 execution_type: Some("TRADE".to_string()),
3570 order_status: Some("PARTIALLY_FILLED".to_string()),
3571 side: Some("BUY".to_string()),
3572 trade_id: Some(9999),
3573 last_executed_price: Some("50000.00".to_string()),
3574 last_executed_quantity: Some("0.005".to_string()),
3575 commission_amount: Some("0".to_string()),
3576 cumulative_filled_quantity: Some("0.005".to_string()),
3577 order_type: Some("LIMIT".to_string()),
3578 price: Some("50000.00".to_string()),
3579 order_quantity: Some("0.01".to_string()),
3580 ..make_base_report()
3581 };
3582 let kinds = |report| {
3583 convert(report)
3584 .into_iter()
3585 .map(|event| match event.kind {
3586 AccountEventKind::Trade(_) => "trade",
3587 AccountEventKind::OrderSnapshot(_) => "snapshot",
3588 _ => "other",
3589 })
3590 .collect::<Vec<_>>()
3591 };
3592 assert_eq!(kinds(fill.clone()), ["trade", "snapshot"]);
3593 let untyped = ExecutionReport {
3594 order_type: None,
3595 ..fill
3596 };
3597 assert_eq!(kinds(untyped), ["trade"]);
3598 }
3599
3600 #[test]
3601 fn test_convert_execution_report_trade() {
3602 let report = ExecutionReport {
3605 execution_type: Some("TRADE".to_string()),
3606 side: Some("BUY".to_string()),
3607 trade_id: Some(9999),
3608 last_executed_price: Some("50000.00".to_string()),
3609 last_executed_quantity: Some("0.01".to_string()),
3610 commission_amount: Some("0.000001".to_string()),
3611 ..make_base_report()
3612 };
3613
3614 let event = sole_event(convert(report)).expect("TRADE event should produce Some");
3615 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3616 match &event.kind {
3617 AccountEventKind::Trade(trade) => {
3618 assert_eq!(trade.side, Side::Buy);
3619 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3620 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3621 assert_eq!(trade.id.0.as_str(), "9999");
3622 assert_eq!(trade.order_id.0.as_str(), "12345");
3623 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3624 }
3625 other => panic!("TRADE should yield Trade, got {other:?}"),
3626 }
3627 }
3628
3629 fn trade_events(
3631 events: Vec<UnindexedAccountEvent>,
3632 ) -> (
3633 Trade<AssetNameExchange, InstrumentNameExchange>,
3634 crate::order::Order<
3635 ExchangeId,
3636 InstrumentNameExchange,
3637 OrderState<AssetNameExchange, InstrumentNameExchange>,
3638 >,
3639 ) {
3640 let [first, second]: [UnindexedAccountEvent; 2] = events
3641 .try_into()
3642 .expect("a fill report must produce an execution and an order snapshot");
3643 let AccountEventKind::Trade(trade) = first.kind else {
3644 panic!("first event must be the Trade, got {:?}", first.kind);
3645 };
3646 let AccountEventKind::OrderSnapshot(snap) = second.kind else {
3647 panic!(
3648 "second event must be the OrderSnapshot, got {:?}",
3649 second.kind
3650 );
3651 };
3652 (trade, snap.0)
3653 }
3654
3655 #[test]
3659 fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
3660 let report = ExecutionReport {
3661 execution_type: Some("TRADE".to_string()),
3662 order_status: Some("PARTIALLY_FILLED".to_string()),
3663 side: Some("BUY".to_string()),
3664 order_type: Some("LIMIT".to_string()),
3665 price: Some("50000.00".to_string()),
3666 order_quantity: Some("0.01".to_string()),
3667 time_in_force: Some("GTC".to_string()),
3668 trade_id: Some(9999),
3669 last_executed_price: Some("50000.00".to_string()),
3670 last_executed_quantity: Some("0.004".to_string()),
3671 cumulative_filled_quantity: Some("0.004".to_string()),
3672 ..make_base_report()
3673 };
3674
3675 let (trade, order) = trade_events(convert(report));
3676 assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
3677
3678 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3679 else {
3680 panic!("expected an Open snapshot, got {:?}", order.state);
3681 };
3682 assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
3683 assert_eq!(
3684 open.quantity_remaining(order.quantity),
3685 Decimal::from_str("0.006").unwrap()
3686 );
3687 }
3688
3689 #[test]
3692 fn a_filled_report_reports_nothing_left_to_fill() {
3693 let report = ExecutionReport {
3694 execution_type: Some("TRADE".to_string()),
3695 order_status: Some("FILLED".to_string()),
3696 side: Some("BUY".to_string()),
3697 order_type: Some("LIMIT".to_string()),
3698 price: Some("50000.00".to_string()),
3699 order_quantity: Some("0.01".to_string()),
3700 time_in_force: Some("GTC".to_string()),
3701 trade_id: Some(10_000),
3702 last_executed_price: Some("50000.00".to_string()),
3703 last_executed_quantity: Some("0.006".to_string()),
3704 cumulative_filled_quantity: Some("0.01".to_string()),
3705 ..make_base_report()
3706 };
3707
3708 let (_trade, order) = trade_events(convert(report));
3709 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3710 else {
3711 panic!("expected an Open snapshot, got {:?}", order.state);
3712 };
3713 assert!(
3714 open.quantity_remaining(order.quantity).is_zero(),
3715 "a completed order must report nothing left to fill so the engine retires it"
3716 );
3717 }
3718
3719 #[test]
3723 fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
3724 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
3725 let report = ExecutionReport {
3726 execution_type: Some("TRADE".to_string()),
3727 order_status: Some(status.to_string()),
3728 side: Some("BUY".to_string()),
3729 order_type: Some("LIMIT".to_string()),
3730 price: Some("50000.00".to_string()),
3731 order_quantity: Some("0.01".to_string()),
3732 time_in_force: Some("GTC".to_string()),
3733 trade_id: Some(9999),
3734 last_executed_price: Some("50000.00".to_string()),
3735 last_executed_quantity: Some("0.004".to_string()),
3736 cumulative_filled_quantity: Some("0.004".to_string()),
3737 ..make_base_report()
3738 };
3739 let events = convert(report);
3740 assert_eq!(
3741 events.len(),
3742 1,
3743 "status {status} must produce the execution alone, got: {events:?}"
3744 );
3745 assert!(
3746 matches!(events[0].kind, AccountEventKind::Trade(_)),
3747 "the execution must still be reported for status {status}"
3748 );
3749 }
3750 }
3751
3752 #[test]
3753 fn test_convert_execution_report_trade_missing_last_price() {
3754 let report = ExecutionReport {
3756 execution_type: Some("TRADE".to_string()),
3757 side: Some("BUY".to_string()),
3758 trade_id: Some(9999),
3759 last_executed_price: None, last_executed_quantity: Some("0.01".to_string()),
3761 ..make_base_report()
3762 };
3763 assert!(
3764 sole_event(convert(report)).is_none(),
3765 "missing last price (L) should return None"
3766 );
3767 }
3768
3769 #[test]
3770 fn test_convert_execution_report_trade_missing_last_qty() {
3771 let report = ExecutionReport {
3773 execution_type: Some("TRADE".to_string()),
3774 side: Some("BUY".to_string()),
3775 trade_id: Some(9999),
3776 last_executed_price: Some("50000.00".to_string()),
3777 last_executed_quantity: None, ..make_base_report()
3779 };
3780 assert!(
3781 sole_event(convert(report)).is_none(),
3782 "missing last qty (l) should return None"
3783 );
3784 }
3785
3786 #[test]
3787 fn test_convert_execution_report_canceled() {
3788 let report = ExecutionReport {
3789 execution_type: Some("CANCELED".to_string()),
3790 ..make_base_report()
3791 };
3792
3793 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3794 assert!(
3795 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3796 "CANCELED should yield OrderCancelled with Ok state"
3797 );
3798 }
3799
3800 #[test]
3802 fn a_cancel_report_carries_its_fill_or_none() {
3803 let cancelled = |z: Option<&str>| {
3804 let report = ExecutionReport {
3805 execution_type: Some("CANCELED".to_string()),
3806 cumulative_filled_quantity: z.map(str::to_string),
3807 ..make_base_report()
3808 };
3809 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3810 let AccountEventKind::OrderCancelled(response) = event.kind else {
3811 panic!("expected OrderCancelled, got {:?}", event.kind);
3812 };
3813 let Ok(cancelled) = response.state else {
3814 panic!("expected Ok, got {:?}", response.state);
3815 };
3816 cancelled.filled_quantity
3817 };
3818
3819 assert_eq!(cancelled(Some("0.5")), Some(Decimal::new(5, 1)));
3820 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
3821 assert_eq!(cancelled(None), None);
3822 assert_eq!(cancelled(Some("not a number")), None);
3823 }
3824
3825 #[test]
3826 fn test_convert_execution_report_expired() {
3827 let report = ExecutionReport {
3828 execution_type: Some("EXPIRED".to_string()),
3829 ..make_base_report()
3830 };
3831 let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
3832 assert!(
3833 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3834 "EXPIRED should yield OrderCancelled with Ok state"
3835 );
3836 }
3837
3838 #[test]
3839 fn test_convert_execution_report_expired_in_match() {
3840 let report = ExecutionReport {
3841 execution_type: Some("EXPIRED_IN_MATCH".to_string()),
3842 ..make_base_report()
3843 };
3844 let event = sole_event(convert(report)).expect("EXPIRED_IN_MATCH should produce Some");
3845 assert!(
3846 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3847 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3848 );
3849 }
3850
3851 #[test]
3852 fn test_convert_execution_report_rejected() {
3853 let report = ExecutionReport {
3854 execution_type: Some("REJECTED".to_string()),
3855 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
3856 ..make_base_report()
3857 };
3858
3859 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
3860 assert!(
3861 matches!(
3862 event.kind,
3863 AccountEventKind::OrderCancelled(ref r) if matches!(
3864 &r.state,
3865 Err(OrderError::Rejected(ApiError::BalanceInsufficient(None, reason)))
3866 if reason == "INSUFFICIENT_FUNDS"
3867 )
3868 ),
3869 "a REJECTED shortfall should go through the classifier, got {:?}",
3870 event.kind
3871 );
3872 }
3873
3874 #[test]
3875 fn test_convert_execution_report_rejected_for_another_reason() {
3876 let report = ExecutionReport {
3877 execution_type: Some("REJECTED".to_string()),
3878 reject_reason: Some("WOULD_MATCH_IMMEDIATELY".to_string()),
3879 ..make_base_report()
3880 };
3881
3882 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
3883 assert!(
3884 matches!(
3885 event.kind,
3886 AccountEventKind::OrderCancelled(ref r) if matches!(
3887 &r.state,
3888 Err(OrderError::Rejected(ApiError::OrderRejected(reason)))
3889 if reason == "WOULD_MATCH_IMMEDIATELY"
3890 )
3891 ),
3892 "an unrecognised REJECTED reason should stay OrderRejected, got {:?}",
3893 event.kind
3894 );
3895 }
3896
3897 #[test]
3898 fn test_convert_execution_report_missing_exec_type() {
3899 let report = ExecutionReport {
3901 symbol: Some("BTCUSDT".to_string()),
3902 ..Default::default()
3903 };
3904 assert!(
3905 sole_event(convert(report)).is_none(),
3906 "missing execution type should return None"
3907 );
3908 }
3909
3910 #[test]
3911 fn test_convert_execution_report_missing_symbol() {
3912 let report = ExecutionReport {
3914 execution_type: Some("NEW".to_string()),
3915 ..Default::default()
3916 };
3917 assert!(
3918 sole_event(convert(report)).is_none(),
3919 "missing symbol should return None"
3920 );
3921 }
3922
3923 #[test]
3924 fn test_convert_execution_report_missing_order_id() {
3925 let report = ExecutionReport {
3927 execution_type: Some("NEW".to_string()),
3928 symbol: Some("BTCUSDT".to_string()),
3929 order_id: None,
3930 ..Default::default()
3931 };
3932 assert!(
3933 sole_event(convert(report)).is_none(),
3934 "missing orderId should return None"
3935 );
3936 }
3937
3938 #[test]
3939 fn test_convert_execution_report_trade_missing_trade_id() {
3940 let report = ExecutionReport {
3942 execution_type: Some("TRADE".to_string()),
3943 trade_id: None,
3944 ..make_base_report()
3945 };
3946 assert!(
3947 sole_event(convert(report)).is_none(),
3948 "TRADE event missing tradeId should return None"
3949 );
3950 }
3951
3952 #[test]
3953 fn test_convert_execution_report_replace_yields_cancelled() {
3954 let report = ExecutionReport {
3955 execution_type: Some("REPLACE".to_string()),
3956 ..make_base_report()
3957 };
3958 let event = sole_event(convert(report)).expect("REPLACE should produce Some");
3961 assert!(
3962 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3963 "REPLACE should yield OrderCancelled with Ok state"
3964 );
3965 }
3966
3967 fn make_balance(
3972 asset: &str,
3973 free: &str,
3974 locked: &str,
3975 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3976 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3977 asset: Some(asset.to_string()),
3978 free: Some(free.to_string()),
3979 locked: Some(locked.to_string()),
3980 }
3981 }
3982
3983 #[test]
3984 fn test_filter_balances_empty_assets_returns_all() {
3985 let balances = vec![
3986 make_balance("BTC", "1.0", "0.0"),
3987 make_balance("USDT", "500.0", "50.0"),
3988 ];
3989 let result = filter_and_convert_balances(balances, &[]);
3990 assert_eq!(result.len(), 2, "empty filter should return all balances");
3991 }
3992
3993 #[test]
3994 fn test_filter_balances_matching_asset_returned() {
3995 let balances = vec![
3996 make_balance("BTC", "1.5", "0.5"),
3997 make_balance("ETH", "10.0", "0.0"),
3998 ];
3999 let assets = vec![AssetNameExchange::new("BTC")];
4000 let result = filter_and_convert_balances(balances, &assets);
4001 assert_eq!(result.len(), 1);
4002 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
4003 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
4005 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
4006 }
4007
4008 #[test]
4009 fn test_filter_balances_non_matching_asset_filtered_out() {
4010 let balances = vec![
4011 make_balance("BTC", "1.0", "0.0"),
4012 make_balance("ETH", "2.0", "0.0"),
4013 ];
4014 let assets = vec![AssetNameExchange::new("USDT")];
4015 let result = filter_and_convert_balances(balances, &assets);
4016 assert!(
4017 result.is_empty(),
4018 "non-matching asset should be filtered out"
4019 );
4020 }
4021
4022 #[test]
4023 fn test_filter_balances_missing_asset_field_skipped() {
4024 let balances = vec![
4025 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4026 asset: None,
4027 free: Some("1.0".to_string()),
4028 locked: Some("0.0".to_string()),
4029 },
4030 make_balance("USDT", "100.0", "0.0"),
4031 ];
4032 let result = filter_and_convert_balances(balances, &[]);
4033 assert_eq!(
4034 result.len(),
4035 1,
4036 "entry with missing asset should be skipped"
4037 );
4038 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
4039 }
4040
4041 #[test]
4042 fn test_filter_balances_unparseable_free_skipped() {
4043 let balances = vec![
4044 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4045 asset: Some("BTC".to_string()),
4046 free: Some("not-a-number".to_string()),
4047 locked: Some("0.0".to_string()),
4048 },
4049 ];
4050 let result = filter_and_convert_balances(balances, &[]);
4051 assert!(
4052 result.is_empty(),
4053 "unparseable free field should be skipped"
4054 );
4055 }
4056
4057 #[test]
4058 fn test_filter_balances_zero_balance_included() {
4059 let balances = vec![
4062 make_balance("BTC", "0.00000000", "0.00000000"),
4063 make_balance("USDT", "100.0", "0.0"),
4064 ];
4065 let result = filter_and_convert_balances(balances, &[]);
4066 assert_eq!(result.len(), 2, "zero-balance entries must be included");
4067 let btc = result
4068 .iter()
4069 .find(|b| b.asset == AssetNameExchange::new("BTC"))
4070 .unwrap();
4071 assert_eq!(btc.balance.total, Decimal::ZERO);
4072 assert_eq!(btc.balance.free, Decimal::ZERO);
4073 }
4074
4075 #[test]
4076 fn test_filter_balances_duplicate_assets_in_response() {
4077 let balances = vec![
4081 make_balance("BTC", "1.0", "0.0"),
4082 make_balance("BTC", "2.0", "0.0"),
4083 ];
4084 let result = filter_and_convert_balances(balances, &[]);
4085 assert_eq!(
4086 result.len(),
4087 2,
4088 "duplicate asset entries produce two AssetBalance entries"
4089 );
4090 }
4091
4092 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
4097 binance_sdk::spot::rest_api::MyTradesResponseInner {
4098 id: Some(9001),
4099 order_id: Some(4242),
4100 price: Some("50000.00".to_string()),
4101 qty: Some("0.01".to_string()),
4102 commission: Some("0.05".to_string()),
4103 time: Some(1_700_000_000_000),
4104 is_buyer: Some(true),
4105 ..Default::default()
4106 }
4107 }
4108
4109 #[test]
4110 fn test_convert_my_trade_happy_path() {
4111 let instrument = InstrumentNameExchange::new("BTCUSDT");
4112 let trade =
4113 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
4114 assert_eq!(trade.instrument, instrument);
4115 assert_eq!(trade.side, Side::Buy);
4116 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
4117 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
4118 }
4119
4120 #[test]
4121 fn test_convert_my_trade_sell_side() {
4122 let instrument = InstrumentNameExchange::new("BTCUSDT");
4123 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4124 is_buyer: Some(false),
4125 ..make_base_trade()
4126 };
4127 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
4128 assert_eq!(trade.side, Side::Sell);
4129 }
4130
4131 #[test]
4132 fn test_convert_my_trade_missing_id_returns_none() {
4133 let instrument = InstrumentNameExchange::new("BTCUSDT");
4134 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4135 id: None,
4136 ..make_base_trade()
4137 };
4138 assert!(
4139 convert_my_trade(&t, &instrument).is_none(),
4140 "missing id should return None"
4141 );
4142 }
4143
4144 #[test]
4145 fn test_convert_my_trade_missing_order_id_returns_none() {
4146 let instrument = InstrumentNameExchange::new("BTCUSDT");
4147 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4148 order_id: None,
4149 ..make_base_trade()
4150 };
4151 assert!(
4152 convert_my_trade(&t, &instrument).is_none(),
4153 "missing orderId should return None"
4154 );
4155 }
4156
4157 #[test]
4158 fn test_convert_my_trade_missing_is_buyer_returns_none() {
4159 let instrument = InstrumentNameExchange::new("BTCUSDT");
4160 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4161 is_buyer: None,
4162 ..make_base_trade()
4163 };
4164 assert!(
4165 convert_my_trade(&t, &instrument).is_none(),
4166 "missing isBuyer should return None"
4167 );
4168 }
4169
4170 #[test]
4171 fn test_convert_my_trade_commission_none_defaults_to_zero() {
4172 let instrument = InstrumentNameExchange::new("BTCUSDT");
4173 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4174 commission: None,
4175 ..make_base_trade()
4176 };
4177 let trade =
4178 convert_my_trade(&t, &instrument).expect("None commission should still convert");
4179 assert_eq!(trade.fees.fees, Decimal::ZERO);
4180 }
4181
4182 fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4187 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4188 order_id: Some(12345),
4189 client_order_id: Some("cid-abc".to_string()),
4190 side: Some("BUY".to_string()),
4191 r#type: Some("LIMIT".to_string()),
4192 price: Some("50000.00".to_string()),
4193 orig_qty: Some("0.01".to_string()),
4194 executed_qty: Some("0.0".to_string()),
4195 time_in_force: Some("GTC".to_string()),
4196 time: Some(1_700_000_000_000),
4197 status: Some("NEW".to_string()),
4198 ..Default::default()
4199 }
4200 }
4201
4202 #[test]
4203 fn test_convert_open_order_all_orders_response_converts_identically() {
4204 let instrument = InstrumentNameExchange::new("BTCUSDT");
4211 let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4212 order_id: Some(12345),
4213 client_order_id: Some("cid-abc".to_string()),
4214 side: Some("BUY".to_string()),
4215 r#type: Some("LIMIT".to_string()),
4216 price: Some("50000.00".to_string()),
4217 orig_qty: Some("0.01".to_string()),
4218 executed_qty: Some("0.0".to_string()),
4219 time_in_force: Some("GTC".to_string()),
4220 time: Some(1_700_000_000_000),
4221 status: Some("NEW".to_string()),
4222 ..Default::default()
4223 };
4224 let from_all_orders = convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument)
4225 .expect("valid order should convert");
4226 let from_open_orders = convert_open_order(
4227 &make_base_open_order(),
4228 ExchangeId::BinanceSpot,
4229 &instrument,
4230 )
4231 .expect("valid order should convert");
4232 assert_eq!(from_all_orders, from_open_orders);
4233 }
4234
4235 #[test]
4241 fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
4242 let instrument = InstrumentNameExchange::new("BTCUSDT");
4243 for status in [
4244 "CANCELED",
4245 "FILLED",
4246 "EXPIRED",
4247 "EXPIRED_IN_MATCH",
4248 "REJECTED",
4249 "PENDING_CANCEL",
4250 "SOME_FUTURE_STATUS",
4251 ] {
4252 let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4253 order_id: Some(12345),
4254 client_order_id: Some("cid-abc".to_string()),
4255 side: Some("BUY".to_string()),
4256 r#type: Some("LIMIT".to_string()),
4257 price: Some("50000.00".to_string()),
4258 orig_qty: Some("0.01".to_string()),
4259 executed_qty: Some("0.004".to_string()),
4260 time_in_force: Some("GTC".to_string()),
4261 time: Some(1_700_000_000_000),
4262 status: Some(status.to_string()),
4263 ..Default::default()
4264 };
4265 assert_eq!(
4266 convert_open_order(&row, ExchangeId::BinanceSpot, &instrument),
4267 None,
4268 "{status}"
4269 );
4270 }
4271 }
4272
4273 #[test]
4274 fn test_convert_open_order_admits_every_live_status() {
4275 let instrument = InstrumentNameExchange::new("BTCUSDT");
4276 for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
4277 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4278 status: Some(status.to_string()),
4279 ..make_base_open_order()
4280 };
4281 assert!(
4282 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_some(),
4283 "{status}"
4284 );
4285 }
4286 }
4287
4288 #[test]
4293 fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
4294 let instrument = InstrumentNameExchange::new("BTCUSDT");
4295 let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4296 order_id: Some(67890),
4297 client_order_id: Some("cid-def".to_string()),
4298 ..make_base_open_order()
4299 };
4300
4301 let listing = convert_open_order_listing(
4302 &[make_base_open_order(), second()],
4303 ExchangeId::BinanceSpot,
4304 &instrument,
4305 );
4306 assert_eq!(listing.orders.len(), 2);
4307 assert!(listing.complete);
4308
4309 let empty = convert_open_order_listing::<
4310 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
4311 >(&[], ExchangeId::BinanceSpot, &instrument);
4312 assert!(empty.orders.is_empty());
4313 assert!(empty.complete, "no open orders is a complete answer");
4314
4315 let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4316 side: None,
4317 ..second()
4318 };
4319 let listing = convert_open_order_listing(
4320 &[make_base_open_order(), dropped],
4321 ExchangeId::BinanceSpot,
4322 &instrument,
4323 );
4324 assert_eq!(listing.orders.len(), 1);
4325 assert!(!listing.complete, "a dropped row may be a live order");
4326
4327 let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4328 client_order_id: None,
4329 ..second()
4330 };
4331 let listing = convert_open_order_listing(
4332 &[make_base_open_order(), without_cid],
4333 ExchangeId::BinanceSpot,
4334 &instrument,
4335 );
4336 assert_eq!(listing.orders.len(), 2, "the row is still converted");
4337 assert!(
4338 !listing.complete,
4339 "an order kept under its orderId cannot be found under the cid it was placed with"
4340 );
4341 }
4342
4343 #[test]
4344 fn test_convert_open_order_missing_status_returns_none() {
4345 let instrument = InstrumentNameExchange::new("BTCUSDT");
4346 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4347 status: None,
4348 ..make_base_open_order()
4349 };
4350 assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none());
4351 }
4352
4353 #[test]
4359 fn test_convert_open_order_prefers_update_time_over_creation_time() {
4360 let instrument = InstrumentNameExchange::new("BTCUSDT");
4361
4362 let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4363 update_time: Some(1_700_000_005_000),
4364 ..make_base_open_order()
4365 };
4366 let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument)
4367 .expect("valid order should convert");
4368 assert_eq!(
4369 order.state.time_exchange,
4370 Utc.timestamp_millis_opt(1_700_000_005_000)
4371 .single()
4372 .unwrap(),
4373 "update_time must win over time"
4374 );
4375
4376 let order = convert_open_order(
4379 &make_base_open_order(),
4380 ExchangeId::BinanceSpot,
4381 &instrument,
4382 )
4383 .expect("valid order should convert");
4384 assert_eq!(
4385 order.state.time_exchange,
4386 Utc.timestamp_millis_opt(1_700_000_000_000)
4387 .single()
4388 .unwrap(),
4389 "absent update_time falls back to time"
4390 );
4391 }
4392
4393 #[test]
4394 fn test_convert_open_order_happy_path() {
4395 let instrument = InstrumentNameExchange::new("BTCUSDT");
4396 let order = convert_open_order(
4397 &make_base_open_order(),
4398 ExchangeId::BinanceSpot,
4399 &instrument,
4400 )
4401 .expect("valid order should convert");
4402 assert_eq!(order.key.instrument, instrument);
4403 assert_eq!(order.side, Side::Buy);
4404 assert_eq!(order.kind, OrderKind::Limit);
4405 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
4406 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
4407 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
4408 }
4409
4410 #[test]
4411 fn test_convert_open_order_owned_symbol_recovers_instrument() {
4412 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4414 symbol: Some("ETHUSDT".to_string()),
4415 ..make_base_open_order()
4416 };
4417 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot)
4418 .expect("valid order should convert");
4419 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4420 assert_eq!(order.side, Side::Buy);
4421 }
4422
4423 #[test]
4424 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
4425 let o = make_base_open_order();
4427 assert!(o.symbol.is_none());
4428 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot).is_none());
4429 }
4430
4431 #[test]
4432 fn test_convert_open_order_missing_order_id_returns_none() {
4433 let instrument = InstrumentNameExchange::new("BTCUSDT");
4434 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4435 order_id: None,
4436 ..make_base_open_order()
4437 };
4438 assert!(
4439 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4440 "missing orderId should return None"
4441 );
4442 }
4443
4444 #[test]
4445 fn test_convert_open_order_missing_side_returns_none() {
4446 let instrument = InstrumentNameExchange::new("BTCUSDT");
4447 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4448 side: None,
4449 ..make_base_open_order()
4450 };
4451 assert!(
4452 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4453 "missing side should return None"
4454 );
4455 }
4456
4457 #[test]
4458 fn test_convert_open_order_missing_type_returns_none() {
4459 let instrument = InstrumentNameExchange::new("BTCUSDT");
4460 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4461 r#type: None,
4462 ..make_base_open_order()
4463 };
4464 assert!(
4465 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4466 "missing type should return None"
4467 );
4468 }
4469
4470 #[test]
4471 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
4472 let instrument = InstrumentNameExchange::new("BTCUSDT");
4473 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4474 executed_qty: None,
4475 ..make_base_open_order()
4476 };
4477 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4478 .expect("None executedQty should still convert");
4479 assert_eq!(
4480 order.state.filled_quantity,
4481 Decimal::ZERO,
4482 "None executedQty should default to zero"
4483 );
4484 }
4485
4486 #[test]
4487 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
4488 let instrument = InstrumentNameExchange::new("BTCUSDT");
4489 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4490 executed_qty: Some("bad-value".to_string()),
4491 ..make_base_open_order()
4492 };
4493 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4494 .expect("unparseable executedQty should still convert");
4495 assert_eq!(
4496 order.state.filled_quantity,
4497 Decimal::ZERO,
4498 "unparseable executedQty should default to zero"
4499 );
4500 }
4501
4502 #[test]
4507 fn test_dedup_key_from_event_non_open_states_return_none() {
4508 use crate::order::state::{
4509 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
4510 };
4511 use rustrade_integration::collection::snapshot::Snapshot;
4512
4513 let key = OrderKey::new(
4514 ExchangeId::BinanceSpot,
4515 InstrumentNameExchange::new("BTCUSDT"),
4516 StrategyId::unknown(),
4517 ClientOrderId::new("cid1"),
4518 );
4519
4520 let event = UnindexedAccountEvent::new(
4522 ExchangeId::BinanceSpot,
4523 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4524 key.clone(),
4525 Side::Buy,
4526 None, Decimal::ZERO,
4528 OrderKind::Market,
4529 TimeInForce::ImmediateOrCancel,
4530 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4531 ActiveOrderState::OpenInFlight(OpenInFlight::new(Utc::now())),
4532 ),
4533 ))),
4534 );
4535 assert!(
4536 dedup_key_from_event(&event).is_none(),
4537 "OpenInFlight should return None — dedup not meaningful before exchange ack"
4538 );
4539
4540 let event = UnindexedAccountEvent::new(
4542 ExchangeId::BinanceSpot,
4543 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4544 key.clone(),
4545 Side::Buy,
4546 None, Decimal::ZERO,
4548 OrderKind::Market,
4549 TimeInForce::ImmediateOrCancel,
4550 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4551 ActiveOrderState::CancelInFlight(CancelInFlight::new(None, Utc::now())),
4552 ),
4553 ))),
4554 );
4555 assert!(
4556 dedup_key_from_event(&event).is_none(),
4557 "CancelInFlight should return None"
4558 );
4559
4560 let event = UnindexedAccountEvent::new(
4562 ExchangeId::BinanceSpot,
4563 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4564 key,
4565 Side::Buy,
4566 None, Decimal::ONE,
4568 OrderKind::Market,
4569 TimeInForce::ImmediateOrCancel,
4570 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
4571 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
4572 OrderId::new("123"),
4573 Utc::now(),
4574 Decimal::ONE, None,
4576 )),
4577 ),
4578 ))),
4579 );
4580 assert!(
4581 dedup_key_from_event(&event).is_none(),
4582 "Inactive state should return None"
4583 );
4584 }
4585
4586 #[test]
4587 fn test_dedup_key_from_event_cancelled_error_returns_none() {
4588 let report = ExecutionReport {
4591 execution_type: Some("REJECTED".to_string()),
4592 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
4593 ..make_base_report()
4594 };
4595 let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
4596 assert!(
4597 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
4598 "prerequisite: event is OrderCancelled with Err"
4599 );
4600 assert!(
4601 dedup_key_from_event(&event).is_none(),
4602 "OrderCancelled with Err state should return None"
4603 );
4604 }
4605
4606 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
4616 let mut value = serde_json::to_value(event).expect("event serializes to Value");
4617 value
4618 .as_object_mut()
4619 .expect("event serializes to a JSON object")
4620 .insert(
4621 "e".to_string(),
4622 serde_json::Value::String(event_type.to_string()),
4623 );
4624 serde_json::json!({ "subscriptionId": 0, "event": value }).to_string()
4625 }
4626
4627 #[test]
4628 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
4629 let report = ExecutionReport {
4630 execution_type: Some("NEW".to_string()),
4631 side: Some("BUY".to_string()),
4632 order_type: Some("LIMIT".to_string()),
4633 price: Some("50000.00".to_string()),
4634 order_quantity: Some("0.01".to_string()),
4635 time_in_force: Some("GTC".to_string()),
4636 cumulative_filled_quantity: Some("0".to_string()),
4637 ..make_base_report()
4638 };
4639 let frame = user_data_frame("executionReport", &report);
4640 let mut buf = Vec::new();
4641 let terminated = convert_user_data_events(&frame, &mut buf);
4642 assert!(
4643 !terminated,
4644 "ExecutionReport should not signal stream termination"
4645 );
4646 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
4647 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
4648 }
4649
4650 #[test]
4651 fn test_convert_user_data_events_account_position_pushes_to_buf() {
4652 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4653 u: Some(1_700_000_000_000),
4654 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
4655 ..Default::default()
4656 };
4657 let frame = user_data_frame("outboundAccountPosition", &position);
4658 let mut buf = Vec::new();
4659 let terminated = convert_user_data_events(&frame, &mut buf);
4660 assert!(
4661 !terminated,
4662 "OutboundAccountPosition should not signal stream termination"
4663 );
4664 assert_eq!(
4665 buf.len(),
4666 1,
4667 "OutboundAccountPosition should push one balance event"
4668 );
4669 }
4670
4671 #[test]
4672 fn test_convert_user_data_events_balance_update_ignored() {
4673 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4674 ..Default::default()
4675 };
4676 let frame = user_data_frame("balanceUpdate", &update);
4677 let mut buf = Vec::new();
4678 let terminated = convert_user_data_events(&frame, &mut buf);
4679 assert!(
4680 !terminated,
4681 "BalanceUpdate should not signal stream termination"
4682 );
4683 assert!(buf.is_empty(), "BalanceUpdate should push no events");
4684 }
4685
4686 #[test]
4687 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4688 let frame =
4690 r#"{"subscriptionId":0,"event":{"e":"eventStreamTerminated","E":1700000000000}}"#;
4691 let mut buf = Vec::new();
4692 let terminated = convert_user_data_events(frame, &mut buf);
4693 assert!(
4694 terminated,
4695 "eventStreamTerminated must signal stream termination"
4696 );
4697 assert!(
4698 buf.is_empty(),
4699 "eventStreamTerminated should push no events"
4700 );
4701 }
4702
4703 #[test]
4704 fn test_convert_user_data_events_unknown_event_ignored() {
4705 for e in ["listStatus", "externalLockUpdate", "someFutureEvent"] {
4708 let frame = format!(
4709 r#"{{"subscriptionId":0,"event":{{"e":"{e}","E":1700000000000,"s":"BTCUSDT"}}}}"#
4710 );
4711 let mut buf = Vec::new();
4712 let terminated = convert_user_data_events(&frame, &mut buf);
4713 assert!(!terminated, "{e} must not signal termination");
4714 assert!(buf.is_empty(), "{e} should push no events");
4715 }
4716 }
4717
4718 #[test]
4723 fn test_convert_user_data_events_reads_the_ws_api_envelope() {
4724 let fill = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000001000,
4725 "s":"BTCUSDT","c":"client-1","S":"SELL","o":"MARKET","f":"GTC","q":"0.5","p":"0",
4726 "x":"TRADE","X":"FILLED","i":777,"l":"0.5","z":"0.5","L":"40000","n":"0.02",
4727 "N":"USDT","T":1700000001000,"t":888}}"#;
4728 let mut buf = Vec::new();
4729 assert!(!convert_user_data_events(fill, &mut buf));
4730 assert!(
4731 buf.iter()
4732 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
4733 "the enveloped fill must produce its trade, got: {buf:?}"
4734 );
4735
4736 let bare = r#"{"e":"executionReport","s":"BTCUSDT","i":777,"x":"NEW","X":"NEW"}"#;
4738 buf.clear();
4739 assert!(!convert_user_data_events(bare, &mut buf));
4740 assert!(buf.is_empty());
4741 }
4742
4743 #[test]
4744 fn test_convert_user_data_events_non_user_data_frame_ignored() {
4745 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4747 let mut buf = Vec::new();
4748 let terminated = convert_user_data_events(frame, &mut buf);
4749 assert!(
4750 !terminated,
4751 "non-user-data frame must not signal termination"
4752 );
4753 assert!(buf.is_empty(), "non-user-data frame should push no events");
4754 }
4755
4756 #[tokio::test]
4761 async fn test_rate_limit_tracker_not_blocked_initially() {
4762 tokio::time::pause();
4763 let tracker = RateLimitTracker::new(WeightPool::Spot);
4764 tokio::time::timeout(
4766 std::time::Duration::from_millis(1),
4767 tracker.wait_if_blocked(RequestKind::Order),
4768 )
4769 .await
4770 .expect("wait_if_blocked should return immediately when not blocked")
4771 .expect("no cooldown too long to sleep through");
4772 }
4773
4774 #[tokio::test]
4775 async fn test_rate_limit_tracker_blocks_until_deadline() {
4776 tokio::time::pause();
4777 let tracker = RateLimitTracker::new(WeightPool::Spot);
4778 let delay = Duration::from_secs(5);
4779 tracker.on_rate_limited(Some(delay));
4780
4781 assert!(
4783 tokio::time::timeout(
4784 Duration::from_millis(1),
4785 tracker.wait_if_blocked(RequestKind::Order)
4786 )
4787 .await
4788 .is_err(),
4789 "wait_if_blocked should block while cooldown is active"
4790 );
4791
4792 tokio::time::advance(delay + Duration::from_millis(1)).await;
4794 tokio::time::timeout(
4795 Duration::from_millis(1),
4796 tracker.wait_if_blocked(RequestKind::Order),
4797 )
4798 .await
4799 .expect("wait_if_blocked should return after cooldown expires")
4800 .expect("no cooldown too long to sleep through");
4801 }
4802
4803 #[tokio::test]
4804 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
4805 tokio::time::pause();
4806 let tracker = RateLimitTracker::new(WeightPool::Spot);
4807
4808 tracker.on_rate_limited(Some(Duration::from_secs(5)));
4810 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4812
4813 tokio::time::advance(Duration::from_secs(6)).await;
4815 assert!(
4816 tokio::time::timeout(
4817 Duration::from_millis(1),
4818 tracker.wait_if_blocked(RequestKind::Order)
4819 )
4820 .await
4821 .is_err(),
4822 "cooldown should have been extended to 10s"
4823 );
4824
4825 tokio::time::advance(Duration::from_secs(5)).await;
4827 tokio::time::timeout(
4828 Duration::from_millis(1),
4829 tracker.wait_if_blocked(RequestKind::Order),
4830 )
4831 .await
4832 .expect("wait_if_blocked should return after extended cooldown expires")
4833 .expect("no cooldown too long to sleep through");
4834 }
4835
4836 #[tokio::test]
4837 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
4838 tokio::time::pause();
4839 let tracker = RateLimitTracker::new(WeightPool::Spot);
4840
4841 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4843 tracker.on_rate_limited(Some(Duration::from_secs(2)));
4844
4845 tokio::time::advance(Duration::from_secs(3)).await;
4847 assert!(
4848 tokio::time::timeout(
4849 Duration::from_millis(1),
4850 tracker.wait_if_blocked(RequestKind::Order)
4851 )
4852 .await
4853 .is_err(),
4854 "shorter on_rate_limited must not shorten existing cooldown"
4855 );
4856 }
4857
4858 #[test]
4863 fn test_classify_ws_order_error_with_wrapped_error_chain() {
4864 let instrument = InstrumentNameExchange::new("BTCUSDT");
4869 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
4870 code: -2010,
4871 message: "insufficient balance".into(),
4872 retry_after: None,
4873 });
4874 assert!(
4876 classify_ws_order_error(&raw, &instrument).is_some(),
4877 "unwrapped ResponseError at root must be detected"
4878 );
4879 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
4881 assert!(
4882 classify_ws_order_error(&wrapped, &instrument).is_some(),
4883 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
4884 );
4885 }
4886
4887 #[tokio::test]
4890 async fn rest_used_weight_near_the_limit_pauses_queries() {
4891 for (used, pauses) in [(5_399, false), (5_400, true)] {
4892 let server = wiremock::MockServer::start().await;
4893 wiremock::Mock::given(wiremock::matchers::method("GET"))
4894 .and(wiremock::matchers::path("/api/v3/openOrders"))
4895 .respond_with(
4896 wiremock::ResponseTemplate::new(200)
4897 .insert_header("x-mbx-used-weight-1m", used.to_string())
4898 .set_body_json(serde_json::json!([])),
4899 )
4900 .mount(&server)
4901 .await;
4902 let rest = Arc::new(SpotRestApi::from_config(
4903 ConfigurationRestApi::builder()
4904 .api_key("key")
4905 .api_secret("secret")
4906 .base_path(server.uri())
4907 .build()
4908 .unwrap(),
4909 ));
4910 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4911
4912 fetch_all_open_orders(rest, Arc::clone(&tracker))
4913 .await
4914 .unwrap();
4915
4916 let query_waits = tokio::time::timeout(
4917 Duration::from_millis(50),
4918 tracker.wait_if_blocked(RequestKind::Query),
4919 )
4920 .await
4921 .is_err();
4922 assert_eq!(query_waits, pauses, "used weight {used}");
4923 tokio::time::timeout(
4924 Duration::from_millis(50),
4925 tracker.wait_if_blocked(RequestKind::Order),
4926 )
4927 .await
4928 .expect("orders never wait on the pause")
4929 .expect("no cooldown too long to sleep through");
4930 }
4931 }
4932
4933 fn client_with_ws_api(ws_url: String) -> BinanceSpot {
4935 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4936 "key".into(),
4937 "secret".into(),
4938 ));
4939 client.ws_handle = SpotWsApi::from_config(
4940 ConfigurationWebsocketApi::builder()
4941 .api_key("key")
4942 .api_secret("secret")
4943 .ws_url(ws_url)
4944 .build()
4945 .unwrap(),
4946 );
4947 client
4948 }
4949
4950 fn cancel_request(
4952 instrument: &InstrumentNameExchange,
4953 ) -> OrderRequestCancel<ExchangeId, &InstrumentNameExchange> {
4954 crate::order::OrderEvent {
4955 key: OrderKey {
4956 exchange: ExchangeId::BinanceSpot,
4957 instrument,
4958 strategy: StrategyId::new("strategy"),
4959 cid: ClientOrderId::new("cid"),
4960 },
4961 state: crate::order::request::RequestCancel { id: None },
4962 }
4963 }
4964
4965 async fn ws_server<F, Fut>(serve: F) -> String
4967 where
4968 F: Fn(tokio::net::TcpStream) -> Fut + Send + 'static,
4969 Fut: std::future::Future<Output = ()> + Send + 'static,
4970 {
4971 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4972 let url = format!("ws://{}", listener.local_addr().unwrap());
4973 tokio::spawn(async move {
4974 while let Ok((stream, _)) = listener.accept().await {
4975 tokio::spawn(serve(stream));
4976 }
4977 });
4978 url
4979 }
4980
4981 #[tokio::test]
4984 async fn cancel_without_a_session_during_a_cooldown_is_rejected_unsent() {
4985 let client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4986 "key".into(),
4987 "secret".into(),
4988 ));
4989 client
4990 .rate_limiter
4991 .on_rate_limited(Some(Duration::from_secs(60)));
4992 let instrument = InstrumentNameExchange::new("BTCUSDT");
4993 let response = client.cancel_order(cancel_request(&instrument)).await;
4994
4995 assert_rate_limited_for(&response.state, Duration::from_secs(60));
4996 assert!(
4997 client.ws_api.read().await.is_none(),
4998 "no session was opened"
4999 );
5000 }
5001
5002 fn assert_rate_limited_for<T: std::fmt::Debug>(
5005 state: &Result<T, UnindexedOrderError>,
5006 wait: Duration,
5007 ) {
5008 let retry_after = match state {
5009 Err(UnindexedOrderError::Rejected(api @ ApiError::RateLimit { .. })) => {
5010 api.retry_after()
5011 }
5012 other => panic!("expected a rate-limit rejection, got {other:?}"),
5013 };
5014 assert!(
5015 retry_after.is_some_and(|got| got <= wait && got > wait - Duration::from_secs(5)),
5016 "expected a wait of about {wait:?}, got {retry_after:?}"
5017 );
5018 }
5019
5020 async fn ws_api_answering(
5023 answer: fn(&serde_json::Value) -> serde_json::Value,
5024 ) -> (String, Arc<std::sync::atomic::AtomicU32>) {
5025 use futures::{SinkExt as _, StreamExt as _};
5026 use tokio_tungstenite::tungstenite::Message;
5027
5028 let requests = Arc::new(std::sync::atomic::AtomicU32::new(0));
5029 let url = ws_server({
5030 let requests = Arc::clone(&requests);
5031 move |stream| {
5032 let requests = Arc::clone(&requests);
5033 async move {
5034 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5035 return;
5036 };
5037 while let Some(Ok(message)) = ws.next().await {
5038 let Message::Text(text) = message else {
5039 continue;
5040 };
5041 requests.fetch_add(1, Ordering::SeqCst);
5042 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5043 let response = answer(&request);
5044 if ws.send(Message::text(response.to_string())).await.is_err() {
5045 return;
5046 }
5047 }
5048 }
5049 }
5050 })
5051 .await;
5052 (url, requests)
5053 }
5054
5055 fn cancel_accepted(request: &serde_json::Value) -> serde_json::Value {
5057 serde_json::json!({
5058 "id": request["id"],
5059 "status": 200,
5060 "result": {
5061 "symbol": "BTCUSDT",
5062 "origClientOrderId": "cid",
5063 "orderId": 7,
5064 "transactTime": 1_700_000_000_000_i64,
5065 "executedQty": "0",
5066 },
5067 })
5068 }
5069
5070 #[tokio::test]
5073 async fn cancel_on_an_open_session_during_a_long_cooldown_is_rejected_unsent() {
5074 let (url, requests) = ws_api_answering(cancel_accepted).await;
5075 let client = client_with_ws_api(url);
5076 let instrument = InstrumentNameExchange::new("BTCUSDT");
5077 let response = client.cancel_order(cancel_request(&instrument)).await;
5078 assert!(response.state.is_ok(), "{:?}", response.state);
5079
5080 client
5081 .rate_limiter
5082 .on_rate_limited(Some(Duration::from_secs(60)));
5083 let response = client.cancel_order(cancel_request(&instrument)).await;
5084
5085 assert_rate_limited_for(&response.state, Duration::from_secs(60));
5086 assert_eq!(
5087 requests.load(Ordering::SeqCst),
5088 1,
5089 "the second was not sent"
5090 );
5091 assert!(
5092 client.ws_api.read().await.is_some(),
5093 "the session stays open"
5094 );
5095 }
5096
5097 #[tokio::test]
5101 async fn a_ws_api_ban_reports_its_wait_and_holds_back_later_requests() {
5102 let (url, requests) = ws_api_answering(banned_for_two_minutes).await;
5103 let client = client_with_ws_api(url);
5104 let instrument = InstrumentNameExchange::new("BTCUSDT");
5105
5106 let response = client.cancel_order(cancel_request(&instrument)).await;
5107 assert_rate_limited_for(&response.state, TWO_MINUTE_BAN);
5108 assert_cooldown_about(&client, TWO_MINUTE_BAN);
5109
5110 let response = client.cancel_order(cancel_request(&instrument)).await;
5111 assert_rate_limited_for(&response.state, TWO_MINUTE_BAN);
5112 assert_eq!(
5113 requests.load(Ordering::SeqCst),
5114 1,
5115 "the second was not sent"
5116 );
5117 }
5118
5119 const TWO_MINUTE_BAN: Duration = Duration::from_millis(120_000 + CLOCK_SKEW_SLACK_MS);
5121
5122 fn banned_for_two_minutes(request: &serde_json::Value) -> serde_json::Value {
5124 let until = unix_ms() + 120_000;
5125 serde_json::json!({
5126 "id": request["id"],
5127 "status": 418,
5128 "error": {
5129 "code": -1003,
5130 "msg": format!("Way too much request weight used; IP banned until {until}."),
5131 "data": { "serverTime": unix_ms(), "retryAfter": until },
5132 },
5133 })
5134 }
5135
5136 fn assert_cooldown_about(client: &BinanceSpot, wait: Duration) {
5139 let left = client.rate_limiter.cooldown_remaining();
5140 assert!(
5141 left.is_some_and(|left| left <= wait && left > wait - Duration::from_secs(5)),
5142 "expected a cooldown of about {wait:?}, got {left:?}"
5143 );
5144 }
5145
5146 #[tokio::test]
5149 async fn a_ws_api_ban_on_an_order_holds_back_later_requests() {
5150 let (url, requests) = ws_api_answering(banned_for_two_minutes).await;
5151 let client = client_with_ws_api(url);
5152 let instrument = InstrumentNameExchange::new("BTCUSDT");
5153 let request = crate::order::OrderEvent {
5154 key: OrderKey {
5155 exchange: ExchangeId::BinanceSpot,
5156 instrument: &instrument,
5157 strategy: StrategyId::new("strategy"),
5158 cid: ClientOrderId::new("cid"),
5159 },
5160 state: crate::order::request::RequestOpen {
5161 side: Side::Buy,
5162 price: Some(Decimal::new(50_000, 0)),
5163 quantity: Decimal::new(1, 3),
5164 kind: OrderKind::Limit,
5165 time_in_force: TimeInForce::GoodUntilCancelled { post_only: false },
5166 position_id: None,
5167 reduce_only: false,
5168 market: None,
5169 },
5170 };
5171
5172 let order = client.open_order(request).await;
5173
5174 let state = match order.state {
5175 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(error)) => {
5176 Err::<(), _>(error)
5177 }
5178 other => panic!("expected the open to fail, got {other:?}"),
5179 };
5180 assert_rate_limited_for(&state, TWO_MINUTE_BAN);
5181 assert_cooldown_about(&client, TWO_MINUTE_BAN);
5182 assert_eq!(requests.load(Ordering::SeqCst), 1);
5183 }
5184
5185 fn subscribed(request: &serde_json::Value) -> serde_json::Value {
5187 serde_json::json!({
5188 "id": request["id"],
5189 "status": 200,
5190 "result": { "subscriptionId": 0 },
5191 })
5192 }
5193
5194 fn rate_limited_wait<T>(opened: Result<T, UnindexedClientError>) -> Option<Duration> {
5196 match opened {
5197 Err(UnindexedClientError::Api(api @ ApiError::RateLimit { .. })) => api.retry_after(),
5198 Err(other) => panic!("expected a rate limit, got {other:?}"),
5199 Ok(_) => panic!("expected the open to fail"),
5200 }
5201 }
5202
5203 #[tokio::test]
5206 async fn opening_the_account_stream_during_a_long_cooldown_fails_unsent() {
5207 let (url, requests) = ws_api_answering(subscribed).await;
5208 let client = client_with_ws_api(url);
5209 client
5210 .rate_limiter
5211 .on_rate_limited(Some(Duration::from_secs(60)));
5212
5213 let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
5214
5215 assert!(
5216 wait.is_some_and(
5217 |wait| wait <= Duration::from_secs(60) && wait > Duration::from_secs(55)
5218 ),
5219 "{wait:?}"
5220 );
5221 assert_eq!(requests.load(Ordering::SeqCst), 0);
5222 }
5223
5224 #[tokio::test]
5227 async fn a_ban_on_the_account_stream_subscribe_starts_its_cooldown() {
5228 let (url, _) = ws_api_answering(banned_for_two_minutes).await;
5229 let client = client_with_ws_api(url);
5230
5231 let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
5232
5233 assert!(
5234 wait.is_some_and(
5235 |wait| wait <= TWO_MINUTE_BAN && wait > TWO_MINUTE_BAN - Duration::from_secs(5)
5236 ),
5237 "{wait:?}"
5238 );
5239 assert_cooldown_about(&client, TWO_MINUTE_BAN);
5240 }
5241
5242 #[tokio::test]
5245 async fn a_refused_account_stream_handshake_starts_a_cooldown() {
5246 use tokio_tungstenite::tungstenite::http;
5247
5248 let url = ws_server(|stream| async move {
5249 #[allow(clippy::result_large_err)]
5251 let refuse = |_: &http::Request<()>, _| {
5252 Err(http::Response::builder()
5253 .status(http::StatusCode::IM_A_TEAPOT)
5254 .body(None)
5255 .unwrap())
5256 };
5257 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
5258 })
5259 .await;
5260 let client = client_with_ws_api(url);
5261
5262 let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
5263
5264 assert!(
5266 wait.is_some_and(
5267 |wait| wait <= MIN_IP_BAN && wait > MIN_IP_BAN - Duration::from_secs(5)
5268 ),
5269 "{wait:?}"
5270 );
5271 assert_cooldown_about(&client, MIN_IP_BAN);
5272 }
5273
5274 #[tokio::test]
5277 async fn a_drop_during_a_long_cooldown_ends_the_account_stream_rate_limited() {
5278 use futures::{SinkExt as _, StreamExt as _};
5279 use tokio_tungstenite::tungstenite::Message;
5280
5281 let subscribes = Arc::new(std::sync::atomic::AtomicU32::new(0));
5282 let url = ws_server({
5283 let subscribes = Arc::clone(&subscribes);
5284 move |stream| {
5285 let subscribes = Arc::clone(&subscribes);
5286 async move {
5287 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5288 return;
5289 };
5290 while let Some(Ok(message)) = ws.next().await {
5293 let Message::Text(text) = message else {
5294 continue;
5295 };
5296 subscribes.fetch_add(1, Ordering::SeqCst);
5297 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5298 let answer = subscribed(&request).to_string();
5299 if ws.send(Message::text(answer)).await.is_err() {
5300 return;
5301 }
5302 tokio::time::sleep(Duration::from_secs(1)).await;
5303 let _ = ws.close(None).await;
5304 return;
5305 }
5306 }
5307 }
5308 })
5309 .await;
5310 let client = client_with_ws_api(url);
5311 let mut stream = client.account_stream(&[], &[]).await.unwrap();
5312 client
5313 .rate_limiter
5314 .on_rate_limited(Some(Duration::from_secs(60)));
5315
5316 let event = tokio::time::timeout(Duration::from_secs(5), stream.next())
5317 .await
5318 .expect("the stream ends rate-limited within the timeout")
5319 .expect("it reports why before it ends");
5320
5321 match event.kind {
5322 AccountEventKind::StreamTerminated(StreamTerminationReason::RateLimited {
5323 retry_after,
5324 }) => assert!(
5325 retry_after <= Duration::from_secs(60) && retry_after > Duration::from_secs(55),
5326 "{retry_after:?}"
5327 ),
5328 other => panic!("expected a rate-limited termination, got {other:?}"),
5329 }
5330 assert!(stream.next().await.is_none(), "the stream has ended");
5331 assert_eq!(subscribes.load(Ordering::SeqCst), 1);
5332 }
5333
5334 #[tokio::test]
5337 async fn a_reconnect_waits_out_a_short_cooldown() {
5338 use futures::{SinkExt as _, StreamExt as _};
5339 use tokio_tungstenite::tungstenite::Message;
5340
5341 let subscribed_at = Arc::new(parking_lot::Mutex::new(Vec::new()));
5342 let url = ws_server({
5343 let subscribed_at = Arc::clone(&subscribed_at);
5344 move |stream| {
5345 let subscribed_at = Arc::clone(&subscribed_at);
5346 async move {
5347 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5348 return;
5349 };
5350 while let Some(Ok(message)) = ws.next().await {
5351 let Message::Text(text) = message else {
5352 continue;
5353 };
5354 subscribed_at.lock().push(tokio::time::Instant::now());
5355 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5356 if ws
5357 .send(Message::text(subscribed(&request).to_string()))
5358 .await
5359 .is_err()
5360 {
5361 return;
5362 }
5363 if subscribed_at.lock().len() == 1 {
5366 tokio::time::sleep(Duration::from_millis(500)).await;
5367 let _ = ws.close(None).await;
5368 return;
5369 }
5370 }
5371 }
5372 }
5373 })
5374 .await;
5375 let client = client_with_ws_api(url);
5376 let _stream = client.account_stream(&[], &[]).await.unwrap();
5377 let cooldown = Duration::from_secs(3);
5378 let cooled = tokio::time::Instant::now() + cooldown;
5380 client.rate_limiter.on_rate_limited(Some(cooldown));
5381
5382 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
5383 while subscribed_at.lock().len() < 2 {
5384 assert!(tokio::time::Instant::now() < deadline, "no reconnect");
5385 tokio::time::sleep(Duration::from_millis(20)).await;
5386 }
5387
5388 assert!(subscribed_at.lock()[1] >= cooled);
5390 }
5391
5392 #[tokio::test]
5397 async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
5398 async fn run(pinging: bool) {
5399 let lifetime = if pinging {
5400 test_ws_api::dropped_then_pinging
5401 } else {
5402 test_ws_api::dropped_then_silent
5403 };
5404 let (url, requests) = test_ws_api::serve(subscribed, lifetime).await;
5405 let client = client_with_ws_api(url);
5406 let _stream = client.account_stream(&[], &[]).await.unwrap();
5407 if pinging {
5408 test_ws_api::assert_reset_once_pinged(&requests, "spot").await;
5409 } else {
5410 test_ws_api::assert_no_reset_when_silent(&requests, "spot").await;
5411 }
5412 }
5413
5414 tokio::join!(run(true), run(false));
5415 }
5416
5417 #[tokio::test]
5420 async fn a_cancel_queued_behind_a_refused_handshake_reports_the_cooldown() {
5421 use tokio_tungstenite::tungstenite::http;
5422
5423 let handshakes = Arc::new(std::sync::atomic::AtomicU32::new(0));
5424 let url = ws_server({
5425 let handshakes = Arc::clone(&handshakes);
5426 move |stream| {
5427 let handshakes = Arc::clone(&handshakes);
5428 async move {
5429 handshakes.fetch_add(1, Ordering::SeqCst);
5430 tokio::time::sleep(Duration::from_millis(200)).await;
5432 #[allow(clippy::result_large_err)]
5435 let refuse = |_: &http::Request<()>, _| {
5436 Err(http::Response::builder()
5437 .status(http::StatusCode::TOO_MANY_REQUESTS)
5438 .body(None)
5439 .unwrap())
5440 };
5441 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
5442 }
5443 }
5444 })
5445 .await;
5446 let client = client_with_ws_api(url);
5447 let instrument = InstrumentNameExchange::new("BTCUSDT");
5448
5449 let (first, second) = tokio::join!(
5450 client.cancel_order(cancel_request(&instrument)),
5451 client.cancel_order(cancel_request(&instrument)),
5452 );
5453
5454 assert_eq!(handshakes.load(Ordering::SeqCst), 1);
5455 let waits: Vec<_> = [&first.state, &second.state]
5456 .into_iter()
5457 .map(|state| match state {
5458 Err(UnindexedOrderError::Rejected(api @ ApiError::RateLimit { .. })) => {
5459 api.retry_after()
5460 }
5461 other => panic!("expected a rate-limit rejection, got {other:?}"),
5462 })
5463 .collect();
5464 let default = Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS);
5466 assert!(
5467 waits.contains(&None)
5468 && waits
5469 .iter()
5470 .flatten()
5471 .any(|wait| { *wait <= default && *wait > default - Duration::from_secs(5) }),
5472 "{waits:?}"
5473 );
5474 }
5475
5476 fn rest_against(server: &wiremock::MockServer) -> (Arc<RestApi>, Arc<RateLimitTracker>) {
5478 let rest = Arc::new(SpotRestApi::from_config(
5479 ConfigurationRestApi::builder()
5480 .api_key("key")
5481 .api_secret("secret")
5482 .base_path(server.uri())
5483 .build()
5484 .unwrap(),
5485 ));
5486 (rest, Arc::new(RateLimitTracker::new(WeightPool::Spot)))
5487 }
5488
5489 async fn mount_refusal(
5492 server: &wiremock::MockServer,
5493 status: u16,
5494 retry_after: Option<&str>,
5495 times: u64,
5496 ) {
5497 let mut refusal = wiremock::ResponseTemplate::new(status).set_body_json(
5498 serde_json::json!({ "code": -1003, "msg": "Too much request weight used." }),
5499 );
5500 if let Some(retry_after) = retry_after {
5501 refusal = refusal.insert_header("retry-after", retry_after);
5502 }
5503 wiremock::Mock::given(wiremock::matchers::method("GET"))
5504 .and(wiremock::matchers::path("/api/v3/openOrders"))
5505 .respond_with(refusal)
5506 .up_to_n_times(times)
5507 .mount(server)
5508 .await;
5509 }
5510
5511 fn query_retry_after<T: std::fmt::Debug>(
5513 result: &Result<T, UnindexedClientError>,
5514 ) -> Option<Duration> {
5515 match result {
5516 Err(UnindexedClientError::Api(api @ ApiError::RateLimit { .. })) => api.retry_after(),
5517 other => panic!("expected a rate-limit refusal, got {other:?}"),
5518 }
5519 }
5520
5521 #[tokio::test]
5524 async fn a_rest_ban_is_not_retried_and_holds_back_later_requests() {
5525 let server = wiremock::MockServer::start().await;
5526 mount_refusal(&server, 418, Some("120"), u64::MAX).await;
5527 let (rest, tracker) = rest_against(&server);
5528
5529 let result = fetch_all_open_orders(Arc::clone(&rest), Arc::clone(&tracker)).await;
5530 assert_eq!(query_retry_after(&result), Some(Duration::from_secs(120)));
5531 assert!(
5532 tracker
5533 .cooldown_remaining()
5534 .is_some_and(|left| left > Duration::from_secs(115)),
5535 "{:?}",
5536 tracker.cooldown_remaining()
5537 );
5538
5539 let result = fetch_all_open_orders(rest, tracker).await;
5540 assert!(
5541 query_retry_after(&result).is_some_and(
5542 |left| left > Duration::from_secs(115) && left <= Duration::from_secs(120)
5543 ),
5544 "{result:?}"
5545 );
5546 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5547 }
5548
5549 #[tokio::test]
5552 async fn a_rest_ban_without_retry_after_is_not_retried() {
5553 let server = wiremock::MockServer::start().await;
5554 mount_refusal(&server, 418, None, u64::MAX).await;
5555 let (rest, tracker) = rest_against(&server);
5556
5557 let result = fetch_all_open_orders(rest, Arc::clone(&tracker)).await;
5558 assert_eq!(query_retry_after(&result), None);
5559 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5560 assert!(
5561 tracker
5562 .cooldown_remaining()
5563 .is_some_and(|left| left > Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS - 5)),
5564 "{:?}",
5565 tracker.cooldown_remaining()
5566 );
5567 }
5568
5569 #[tokio::test]
5571 async fn a_rest_throttle_with_a_long_retry_after_is_not_retried() {
5572 let server = wiremock::MockServer::start().await;
5573 mount_refusal(&server, 429, Some("30"), u64::MAX).await;
5574 let (rest, tracker) = rest_against(&server);
5575
5576 let result = fetch_all_open_orders(rest, tracker).await;
5577 assert_eq!(query_retry_after(&result), Some(Duration::from_secs(30)));
5578 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5579 }
5580
5581 #[tokio::test]
5584 async fn a_rest_throttle_with_a_short_retry_after_is_retried_after_it() {
5585 let server = wiremock::MockServer::start().await;
5586 mount_refusal(&server, 429, Some("2"), 1).await;
5587 wiremock::Mock::given(wiremock::matchers::method("GET"))
5588 .and(wiremock::matchers::path("/api/v3/openOrders"))
5589 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
5590 .mount(&server)
5591 .await;
5592 let (rest, tracker) = rest_against(&server);
5593
5594 let started = std::time::Instant::now();
5595 let orders = fetch_all_open_orders(rest, tracker).await.unwrap();
5596
5597 assert!(orders.is_empty());
5598 assert_eq!(server.received_requests().await.unwrap().len(), 2);
5599 assert!(
5600 started.elapsed() >= Duration::from_secs(2),
5601 "{:?}",
5602 started.elapsed()
5603 );
5604 }
5605
5606 #[tokio::test]
5609 async fn a_handshake_refused_with_429_starts_a_cooldown() {
5610 use tokio_tungstenite::tungstenite::http;
5611
5612 let url = ws_server(|stream| async move {
5613 #[allow(clippy::result_large_err)]
5615 let refuse = |_: &http::Request<()>, _| {
5616 Err(http::Response::builder()
5617 .status(http::StatusCode::TOO_MANY_REQUESTS)
5618 .body(None)
5619 .unwrap())
5620 };
5621 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
5622 })
5623 .await;
5624 let client = client_with_ws_api(url);
5625 let instrument = InstrumentNameExchange::new("BTCUSDT");
5626
5627 let response = client.cancel_order(cancel_request(&instrument)).await;
5628
5629 assert!(
5630 matches!(
5631 response.state,
5632 Err(UnindexedOrderError::Rejected(ApiError::RateLimit { .. }))
5633 ),
5634 "{:?}",
5635 response.state
5636 );
5637 assert!(client.rate_limiter.is_blocked());
5638 }
5639
5640 #[tokio::test]
5643 async fn a_handshake_refused_with_418_starts_the_shortest_ban() {
5644 use tokio_tungstenite::tungstenite::http;
5645
5646 let url = ws_server(|stream| async move {
5647 #[allow(clippy::result_large_err)]
5649 let refuse = |_: &http::Request<()>, _| {
5650 Err(http::Response::builder()
5651 .status(http::StatusCode::IM_A_TEAPOT)
5652 .body(None)
5653 .unwrap())
5654 };
5655 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
5656 })
5657 .await;
5658 let client = client_with_ws_api(url);
5659 let instrument = InstrumentNameExchange::new("BTCUSDT");
5660
5661 let response = client.cancel_order(cancel_request(&instrument)).await;
5662
5663 assert_rate_limited_for(&response.state, MIN_IP_BAN);
5664 assert_cooldown_about(&client, MIN_IP_BAN);
5665 }
5666
5667 #[tokio::test]
5670 async fn a_ws_api_response_near_the_weight_limit_pauses_queries() {
5671 use futures::{SinkExt as _, StreamExt as _};
5672 use tokio_tungstenite::tungstenite::Message;
5673
5674 let url = ws_server(|stream| async move {
5675 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5676 return;
5677 };
5678 while let Some(Ok(message)) = ws.next().await {
5679 let Message::Text(text) = message else {
5680 continue;
5681 };
5682 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5683 let response = serde_json::json!({
5684 "id": request["id"],
5685 "status": 200,
5686 "result": {
5687 "symbol": "BTCUSDT",
5688 "origClientOrderId": "cid",
5689 "orderId": 7,
5690 "transactTime": 1_700_000_000_000_i64,
5691 "executedQty": "0",
5692 },
5693 "rateLimits": [{
5694 "rateLimitType": "REQUEST_WEIGHT",
5695 "interval": "MINUTE",
5696 "intervalNum": 1,
5697 "limit": 1_000,
5698 "count": 950,
5699 }],
5700 });
5701 if ws.send(Message::text(response.to_string())).await.is_err() {
5702 return;
5703 }
5704 }
5705 })
5706 .await;
5707 let client = client_with_ws_api(url);
5708 let instrument = InstrumentNameExchange::new("BTCUSDT");
5709
5710 let response = client.cancel_order(cancel_request(&instrument)).await;
5711
5712 assert!(response.state.is_ok(), "{:?}", response.state);
5713 assert_eq!(client.rate_limiter.weight_limit(), 1_000);
5714 assert!(
5715 tokio::time::timeout(
5716 Duration::from_millis(50),
5717 client.rate_limiter.wait_if_blocked(RequestKind::Query),
5718 )
5719 .await
5720 .is_err(),
5721 "queries pause"
5722 );
5723 tokio::time::timeout(
5724 Duration::from_millis(50),
5725 client.rate_limiter.wait_if_blocked(RequestKind::Order),
5726 )
5727 .await
5728 .expect("orders never wait on the pause")
5729 .expect("no cooldown too long to sleep through");
5730 }
5731
5732 async fn ws_server_reporting_close(
5735 result: serde_json::Value,
5736 ) -> (String, mpsc::UnboundedReceiver<()>) {
5737 use futures::{SinkExt as _, StreamExt as _};
5738 use tokio_tungstenite::tungstenite::Message;
5739
5740 let (closed_tx, closed_rx) = mpsc::unbounded_channel();
5741 let url = ws_server(move |stream| {
5742 let result = result.clone();
5743 let closed_tx = closed_tx.clone();
5744 async move {
5745 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5746 return;
5747 };
5748 while let Some(Ok(message)) = ws.next().await {
5749 match message {
5750 Message::Text(text) => {
5751 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5752 let response = serde_json::json!({
5753 "id": request["id"],
5754 "status": 200,
5755 "result": result,
5756 });
5757 if ws.send(Message::text(response.to_string())).await.is_err() {
5758 return;
5759 }
5760 }
5761 Message::Close(_) => {
5762 let _ = closed_tx.send(());
5763 return;
5764 }
5765 _ => {}
5766 }
5767 }
5768 }
5769 })
5770 .await;
5771 (url, closed_rx)
5772 }
5773
5774 #[tokio::test]
5781 async fn dropping_the_account_stream_closes_its_connection() {
5782 for running in [Duration::ZERO, Duration::from_millis(100)] {
5783 let (url, mut closed) =
5784 ws_server_reporting_close(serde_json::json!({ "subscriptionId": 0 })).await;
5785 let client = client_with_ws_api(url);
5786 let holders = client.known_live.holders();
5787
5788 let stream = client.account_stream(&[], &[]).await.unwrap();
5789 tokio::time::sleep(running).await;
5790 drop(stream);
5791
5792 tokio::time::timeout(Duration::from_secs(5), closed.recv())
5793 .await
5794 .expect("the dropped stream closes its connection")
5795 .unwrap();
5796 for _ in 0..10 {
5798 tokio::task::yield_now().await;
5799 }
5800 assert_eq!(
5801 client.known_live.holders(),
5802 holders,
5803 "the stream's state is released"
5804 );
5805 }
5806 }
5807
5808 #[tokio::test]
5811 async fn dropping_the_last_client_closes_its_order_session() {
5812 let (url, mut closed) = ws_server_reporting_close(serde_json::json!({
5813 "symbol": "BTCUSDT",
5814 "origClientOrderId": "cid",
5815 "orderId": 7,
5816 "transactTime": 1_700_000_000_000_i64,
5817 "executedQty": "0",
5818 }))
5819 .await;
5820 let client = client_with_ws_api(url);
5821 let clone = client.clone();
5822 let instrument = InstrumentNameExchange::new("BTCUSDT");
5823 let response = client.cancel_order(cancel_request(&instrument)).await;
5824 assert!(response.state.is_ok(), "{:?}", response.state);
5825
5826 drop(client);
5827 assert!(
5828 tokio::time::timeout(Duration::from_millis(200), closed.recv())
5829 .await
5830 .is_err(),
5831 "a clone still holds the session"
5832 );
5833
5834 drop(clone);
5835 tokio::time::timeout(Duration::from_secs(5), closed.recv())
5836 .await
5837 .expect("the last client's drop closes the session")
5838 .unwrap();
5839 }
5840
5841 #[tokio::test]
5844 async fn fill_recovery_does_not_wait_for_the_weight_pause() {
5845 let server = wiremock::MockServer::start().await;
5846 wiremock::Mock::given(wiremock::matchers::method("GET"))
5847 .and(wiremock::matchers::path("/api/v3/myTrades"))
5848 .respond_with(
5850 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
5851 "symbol": "BTCUSDT",
5852 "id": 1,
5853 "orderId": 7,
5854 "price": "100",
5855 "qty": "1",
5856 "commission": "0",
5857 "commissionAsset": "USDT",
5858 "time": Utc::now().timestamp_millis(),
5859 "isBuyer": true,
5860 "isMaker": false,
5861 }])),
5862 )
5863 .expect(2)
5864 .mount(&server)
5865 .await;
5866 let rest = Arc::new(SpotRestApi::from_config(
5867 ConfigurationRestApi::builder()
5868 .api_key("key")
5869 .api_secret("secret")
5870 .base_path(server.uri())
5871 .build()
5872 .unwrap(),
5873 ));
5874 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5875 tracker.throttle(Duration::from_secs(60));
5876 let (tx, _rx) = mpsc::unbounded_channel();
5877 let mut unrecovered = UnrecoveredFills::default();
5878 unrecovered.open(
5879 &[InstrumentNameExchange::new("BTCUSDT")],
5880 Utc::now() - chrono::Duration::minutes(1),
5881 Utc::now(),
5882 );
5883
5884 tokio::time::timeout(
5886 Duration::from_millis(800),
5887 recover_fills(
5888 &rest,
5889 &tracker,
5890 &mut unrecovered,
5891 &tx,
5892 &new_dedup_cache(),
5893 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5894 ),
5895 )
5896 .await
5897 .expect("recovery does not wait for the pause");
5898 }
5899
5900 #[tokio::test]
5902 async fn a_gap_whose_every_read_fails_is_reported_when_given_up() {
5903 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5904 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5905 let server = wiremock::MockServer::start().await;
5906 wiremock::Mock::given(wiremock::matchers::method("GET"))
5907 .and(wiremock::matchers::path("/api/v3/myTrades"))
5908 .respond_with(
5909 wiremock::ResponseTemplate::new(400)
5910 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5911 )
5912 .mount(&server)
5913 .await;
5914 let rest = Arc::new(SpotRestApi::from_config(
5915 ConfigurationRestApi::builder()
5916 .api_key("key")
5917 .api_secret("secret")
5918 .base_path(server.uri())
5919 .build()
5920 .expect("valid config"),
5921 ));
5922 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5923 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5924 let dedup = new_dedup_cache();
5925 let btc = InstrumentNameExchange::new("BTCUSDT");
5926 let mut unrecovered = UnrecoveredFills::default();
5927 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
5928
5929 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
5930 recover_fills(
5931 &rest,
5932 &tracker,
5933 &mut unrecovered,
5934 &tx,
5935 &dedup,
5936 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5937 )
5938 .await;
5939 unrecovered.make_due();
5940 }
5941
5942 assert!(unrecovered.is_empty(), "the gap is given up");
5943 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5944 let [event] = forwarded.as_slice() else {
5945 panic!("only the give-up is sent: {forwarded:?}");
5946 };
5947 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
5948 panic!("expected FillRecoveryGaveUp, got {event:?}");
5949 };
5950 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
5951 assert_eq!(
5952 gave_up.scope,
5953 crate::FillRecoveryScope::Instruments(vec![btc])
5954 );
5955 assert_eq!(
5956 gave_up.start.timestamp_millis(),
5957 disconnect.timestamp_millis()
5958 );
5959 assert_eq!(
5960 gave_up.attempts,
5961 crate::client::order_recovery::MAX_GAP_RETRIES + 1
5962 );
5963 assert!(
5964 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
5965 "the last read's error: {:?}",
5966 gave_up.reason
5967 );
5968 }
5969
5970 #[tokio::test]
5973 async fn a_failed_recovery_keeps_its_closed_gap_for_the_retry() {
5974 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5975 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5976 let trade = |id: i64, time: DateTime<Utc>| {
5977 serde_json::json!({
5978 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5979 "commission": "0", "commissionAsset": "USDT", "time": time.timestamp_millis(),
5980 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5981 })
5982 };
5983 let server = wiremock::MockServer::start().await;
5984 wiremock::Mock::given(wiremock::matchers::method("GET"))
5985 .and(wiremock::matchers::path("/api/v3/myTrades"))
5986 .respond_with(
5987 wiremock::ResponseTemplate::new(400)
5988 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5989 )
5990 .up_to_n_times(1)
5991 .with_priority(1)
5992 .mount(&server)
5993 .await;
5994 wiremock::Mock::given(wiremock::matchers::method("GET"))
5995 .and(wiremock::matchers::path("/api/v3/myTrades"))
5996 .respond_with(
5997 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
5998 trade(1, disconnect + chrono::Duration::minutes(1)),
5999 trade(2, Utc::now() - chrono::Duration::minutes(1)),
6000 ])),
6001 )
6002 .mount(&server)
6003 .await;
6004 let rest = Arc::new(SpotRestApi::from_config(
6005 ConfigurationRestApi::builder()
6006 .api_key("key")
6007 .api_secret("secret")
6008 .base_path(server.uri())
6009 .build()
6010 .unwrap(),
6011 ));
6012 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6013 let (tx, mut rx) = mpsc::unbounded_channel();
6014 let dedup = new_dedup_cache();
6015 let mut unrecovered = UnrecoveredFills::default();
6016 unrecovered.open(
6017 &[InstrumentNameExchange::new("BTCUSDT")],
6018 disconnect,
6019 reconnect,
6020 );
6021
6022 recover_fills(
6023 &rest,
6024 &tracker,
6025 &mut unrecovered,
6026 &tx,
6027 &dedup,
6028 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6029 )
6030 .await;
6031 assert!(!unrecovered.is_empty(), "the failed gap is kept");
6032 assert!(rx.try_recv().is_err(), "nothing forwarded");
6033 assert!(
6034 unrecovered.due(tokio::time::Instant::now()).is_empty(),
6035 "and waits for its retry"
6036 );
6037
6038 unrecovered.make_due();
6039 recover_fills(
6040 &rest,
6041 &tracker,
6042 &mut unrecovered,
6043 &tx,
6044 &dedup,
6045 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6046 )
6047 .await;
6048 assert!(unrecovered.is_empty(), "the retry recovered it");
6049 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6050 assert_eq!(
6051 forwarded.len(),
6052 1,
6053 "only the fill inside the gap: {forwarded:?}"
6054 );
6055
6056 let start_times: Vec<String> = server
6057 .received_requests()
6058 .await
6059 .unwrap()
6060 .iter()
6061 .filter_map(|request| {
6062 request
6063 .url
6064 .query_pairs()
6065 .find(|(key, _)| key == "startTime")
6066 .map(|(_, value)| value.into_owned())
6067 })
6068 .collect();
6069 let start = disconnect.timestamp_millis().to_string();
6070 assert_eq!(start_times, [start.clone(), start]);
6071 }
6072
6073 #[tokio::test]
6076 async fn recovered_fills_carry_their_orders_client_order_ids() {
6077 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6078 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6079 let trade = |id: i64| {
6080 serde_json::json!({
6081 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
6082 "commission": "0", "commissionAsset": "USDT",
6083 "time": (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
6084 "isBuyer": true, "isMaker": false, "isBestMatch": true,
6085 })
6086 };
6087 let server = wiremock::MockServer::start().await;
6088 for id in [1, 2] {
6090 wiremock::Mock::given(wiremock::matchers::method("GET"))
6091 .and(wiremock::matchers::path("/api/v3/myTrades"))
6092 .and(wiremock::matchers::query_param("orderId", id.to_string()))
6093 .respond_with(
6094 wiremock::ResponseTemplate::new(200)
6095 .set_body_json(serde_json::json!([trade(id)])),
6096 )
6097 .with_priority(1)
6098 .mount(&server)
6099 .await;
6100 }
6101 wiremock::Mock::given(wiremock::matchers::method("GET"))
6102 .and(wiremock::matchers::path("/api/v3/myTrades"))
6103 .respond_with(
6104 wiremock::ResponseTemplate::new(200)
6105 .set_body_json(serde_json::json!([trade(1), trade(2)])),
6106 )
6107 .mount(&server)
6108 .await;
6109 wiremock::Mock::given(wiremock::matchers::method("GET"))
6110 .and(wiremock::matchers::path("/api/v3/order"))
6111 .and(wiremock::matchers::query_param("orderId", "1"))
6112 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6113 serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "sweep-1"}),
6114 ))
6115 .mount(&server)
6116 .await;
6117 wiremock::Mock::given(wiremock::matchers::method("GET"))
6118 .and(wiremock::matchers::path("/api/v3/order"))
6119 .and(wiremock::matchers::query_param("orderId", "2"))
6120 .respond_with(
6121 wiremock::ResponseTemplate::new(400).set_body_json(
6122 serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
6123 ),
6124 )
6125 .mount(&server)
6126 .await;
6127 let rest = Arc::new(SpotRestApi::from_config(
6128 ConfigurationRestApi::builder()
6129 .api_key("key")
6130 .api_secret("secret")
6131 .base_path(server.uri())
6132 .build()
6133 .unwrap(),
6134 ));
6135 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6136 let (tx, mut rx) = mpsc::unbounded_channel();
6137 let mut unrecovered = UnrecoveredFills::default();
6138 unrecovered.open(
6139 &[InstrumentNameExchange::new("BTCUSDT")],
6140 disconnect,
6141 reconnect,
6142 );
6143
6144 recover_fills(
6145 &rest,
6146 &tracker,
6147 &mut unrecovered,
6148 &tx,
6149 &new_dedup_cache(),
6150 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6151 )
6152 .await;
6153
6154 let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
6155 .filter_map(|event| match event.kind {
6156 AccountEventKind::Trade(trade) => Some((
6157 trade.id.0.to_string(),
6158 trade.client_order_id,
6159 trade.order_filled_quantity,
6160 )),
6161 _ => None,
6162 })
6163 .collect();
6164 cids.sort();
6165 assert_eq!(
6167 cids,
6168 [
6169 (
6170 "1".to_string(),
6171 Some(ClientOrderId::new("sweep-1")),
6172 Some(Decimal::ONE)
6173 ),
6174 ("2".to_string(), None, Some(Decimal::ONE)),
6175 ]
6176 );
6177 let order_reads = server
6178 .received_requests()
6179 .await
6180 .unwrap()
6181 .iter()
6182 .filter(|request| request.url.path() == "/api/v3/order")
6183 .count();
6184 assert_eq!(order_reads, 2, "one read per order");
6185 }
6186
6187 #[tokio::test]
6190 async fn a_span_walk_pages_by_id_and_stops_past_the_end() {
6191 let start = Utc::now().timestamp_millis() - 600_000;
6192 let end = start + 10_000;
6193 let trade = |id: i64, time: i64| {
6194 serde_json::json!({
6195 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
6196 "commission": "0", "commissionAsset": "USDT", "time": time,
6197 "isBuyer": true, "isMaker": false, "isBestMatch": true,
6198 })
6199 };
6200 let server = wiremock::MockServer::start().await;
6201 wiremock::Mock::given(wiremock::matchers::method("GET"))
6202 .and(wiremock::matchers::path("/api/v3/myTrades"))
6203 .and(wiremock::matchers::query_param("fromId", "1001"))
6204 .respond_with(
6206 wiremock::ResponseTemplate::new(200).set_body_json(
6207 (1_001..=2_000)
6208 .map(|id| trade(id, if id == 1_001 { end } else { end + 1 }))
6209 .collect::<Vec<_>>(),
6210 ),
6211 )
6212 .with_priority(1)
6213 .mount(&server)
6214 .await;
6215 let full_page: Vec<_> = (1..=1_000).map(|id| trade(id, start + id)).collect();
6216 wiremock::Mock::given(wiremock::matchers::method("GET"))
6217 .and(wiremock::matchers::path("/api/v3/myTrades"))
6218 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(full_page))
6219 .mount(&server)
6220 .await;
6221 let rest = Arc::new(SpotRestApi::from_config(
6222 ConfigurationRestApi::builder()
6223 .api_key("key")
6224 .api_secret("secret")
6225 .base_path(server.uri())
6226 .build()
6227 .unwrap(),
6228 ));
6229
6230 let read = paginate_my_trades(
6231 &rest,
6232 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6233 &InstrumentNameExchange::new("BTCUSDT"),
6234 MyTradesFrom::Span { start, end },
6235 RequestKind::Query,
6236 )
6237 .await
6238 .unwrap();
6239
6240 let ids: Vec<_> = read.iter().filter_map(|t| t.id).collect();
6241 assert_eq!(ids, (1..=1_001).collect::<Vec<_>>());
6242 assert_eq!(server.received_requests().await.unwrap().len(), 2);
6243 }
6244
6245 #[tokio::test]
6248 async fn fetch_trades_returns_only_the_span() {
6249 let start = DateTime::from_timestamp_millis(
6251 (Utc::now() - chrono::Duration::minutes(10)).timestamp_millis(),
6252 )
6253 .unwrap()
6254 + chrono::Duration::microseconds(500);
6255 let end = start + chrono::Duration::seconds(10);
6256 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
6257 let trade = |id: i64, time: i64| {
6258 serde_json::json!({
6259 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
6260 "commission": "0", "commissionAsset": "USDT", "time": time,
6261 "isBuyer": true, "isMaker": false, "isBestMatch": true,
6262 })
6263 };
6264 let server = wiremock::MockServer::start().await;
6265 wiremock::Mock::given(wiremock::matchers::method("GET"))
6266 .and(wiremock::matchers::path("/api/v3/myTrades"))
6267 .and(wiremock::matchers::query_param(
6268 "startTime",
6269 start_ms.to_string(),
6270 ))
6271 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(vec![
6272 trade(1, start_ms),
6273 trade(2, start_ms + 1),
6274 trade(3, end_ms),
6275 trade(4, end_ms + 1),
6276 ]))
6277 .mount(&server)
6278 .await;
6279 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
6280 "key".into(),
6281 "secret".into(),
6282 ));
6283 client.rest = Arc::new(SpotRestApi::from_config(
6284 ConfigurationRestApi::builder()
6285 .api_key("key")
6286 .api_secret("secret")
6287 .base_path(server.uri())
6288 .build()
6289 .unwrap(),
6290 ));
6291 let btc = [InstrumentNameExchange::new("BTCUSDT")];
6292
6293 let read = client.fetch_trades(start, end, &btc).await.unwrap();
6294 let ids: Vec<_> = read.trades.iter().map(|t| t.id.0.as_str()).collect();
6295 assert_eq!(ids, ["2", "3"]);
6296 assert_eq!(read.resume, None, "spot reads a span whole");
6297
6298 let empty = client.fetch_trades(end, start, &btc).await.unwrap();
6299 assert!(empty.trades.is_empty() && empty.resume.is_none());
6300 assert_eq!(server.received_requests().await.unwrap().len(), 1);
6301 }
6302
6303 #[tokio::test]
6305 async fn a_gap_whose_every_read_times_out_is_reported_when_given_up() {
6306 let server = wiremock::MockServer::start().await;
6307 wiremock::Mock::given(wiremock::matchers::method("GET"))
6308 .and(wiremock::matchers::path("/api/v3/myTrades"))
6309 .respond_with(
6310 wiremock::ResponseTemplate::new(200)
6311 .set_body_json(serde_json::json!([]))
6312 .set_delay(Duration::from_secs(3_600)),
6313 )
6314 .mount(&server)
6315 .await;
6316 let rest = Arc::new(SpotRestApi::from_config(
6317 ConfigurationRestApi::builder()
6318 .api_key("key")
6319 .api_secret("secret")
6320 .base_path(server.uri())
6321 .timeout(3_600_000_u64)
6322 .retries(0_u32)
6323 .build()
6324 .expect("valid config"),
6325 ));
6326 let btc = InstrumentNameExchange::new("BTCUSDT");
6327 let mut unrecovered = UnrecoveredFills::default();
6328 unrecovered.open(
6329 std::slice::from_ref(&btc),
6330 Utc::now() - chrono::Duration::minutes(10),
6331 Utc::now(),
6332 );
6333 let (tx, mut rx) = mpsc::unbounded_channel();
6334 tokio::time::pause();
6336
6337 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
6338 recover_fills(
6339 &rest,
6340 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6341 &mut unrecovered,
6342 &tx,
6343 &new_dedup_cache(),
6344 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6345 )
6346 .await;
6347 unrecovered.make_due();
6348 }
6349
6350 assert!(unrecovered.is_empty(), "the gap is given up");
6351 let Ok(event) = rx.try_recv() else {
6352 panic!("the given-up gap is reported");
6353 };
6354 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
6355 panic!("expected FillRecoveryGaveUp, got {event:?}");
6356 };
6357 assert_eq!(
6358 gave_up.scope,
6359 crate::FillRecoveryScope::Instruments(vec![btc])
6360 );
6361 assert_eq!(
6362 gave_up.reason,
6363 FillRecoveryFailure::TimedOut {
6364 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS
6365 }
6366 );
6367 assert!(rx.try_recv().is_err(), "reported once");
6368 }
6369
6370 #[tokio::test]
6373 async fn a_timed_out_recovery_charges_only_the_gaps_it_started() {
6374 let server = wiremock::MockServer::start().await;
6375 wiremock::Mock::given(wiremock::matchers::method("GET"))
6376 .and(wiremock::matchers::path("/api/v3/myTrades"))
6377 .respond_with(
6378 wiremock::ResponseTemplate::new(200)
6379 .set_body_json(serde_json::json!([]))
6380 .set_delay(Duration::from_secs(3_600)),
6381 )
6382 .mount(&server)
6383 .await;
6384 let rest = Arc::new(SpotRestApi::from_config(
6385 ConfigurationRestApi::builder()
6386 .api_key("key")
6387 .api_secret("secret")
6388 .base_path(server.uri())
6389 .timeout(3_600_000_u64)
6390 .retries(0_u32)
6391 .build()
6392 .unwrap(),
6393 ));
6394 let instruments: Vec<_> = (0..9)
6395 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
6396 .collect();
6397 let mut unrecovered = UnrecoveredFills::default();
6398 unrecovered.open(
6399 &instruments,
6400 Utc::now() - chrono::Duration::minutes(10),
6401 Utc::now(),
6402 );
6403 let (tx, _rx) = mpsc::unbounded_channel();
6404 tokio::time::pause();
6406
6407 recover_fills(
6408 &rest,
6409 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6410 &mut unrecovered,
6411 &tx,
6412 &new_dedup_cache(),
6413 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6414 )
6415 .await;
6416
6417 let now = tokio::time::Instant::now();
6418 assert_eq!(
6419 unrecovered.due(now).len(),
6420 1,
6421 "the gap never started stays due"
6422 );
6423 assert_eq!(
6424 unrecovered
6425 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
6426 .len(),
6427 9,
6428 "the eight started wait for their first retry"
6429 );
6430 }
6431
6432 fn order_row(cid: &str, status: &str) -> serde_json::Value {
6438 serde_json::json!({
6439 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
6440 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
6441 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "time": 1_700_000_000_000_i64,
6442 "updateTime": 1_700_000_060_000_i64,
6443 })
6444 }
6445
6446 fn get_order_row(row: serde_json::Value) -> binance_sdk::spot::rest_api::GetOrderResponse {
6447 serde_json::from_value(row).unwrap()
6448 }
6449
6450 fn spot_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
6451 OrderKey::new(
6452 ExchangeId::BinanceSpot,
6453 InstrumentNameExchange::new(instrument),
6454 StrategyId::new("strategy"),
6455 ClientOrderId::new(cid),
6456 )
6457 }
6458
6459 fn ended_state(
6460 key: &UnindexedOrderKey,
6461 row: serde_json::Value,
6462 ) -> Option<crate::order::state::UnindexedInactiveOrderState> {
6463 let order = convert_ended_order(&get_order_row(row), ExchangeId::BinanceSpot, key, None)?;
6464 assert_eq!(&order.key, key, "reported under the key asked");
6465 assert_eq!(order.quantity, Decimal::TWO);
6466 Some(order.state)
6467 }
6468
6469 #[test]
6472 fn an_ended_order_row_without_a_fill_reports_it_unknown() {
6473 use crate::order::state::{Expired, Filled, InactiveOrderState};
6474 let key = spot_key("BTCUSDT", "a");
6475 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
6476 let id = OrderId::new("7");
6477 let row = |status: &str, executed_qty: Option<&str>| {
6478 let mut row = order_row("a", status);
6479 row["executedQty"] = executed_qty.map_or(serde_json::Value::Null, Into::into);
6480 row
6481 };
6482
6483 for executed_qty in [None, Some("not a number")] {
6484 assert_eq!(
6485 ended_state(&key, row("CANCELED", executed_qty)),
6486 Some(InactiveOrderState::Cancelled(Cancelled::new(
6487 id.clone(),
6488 ended,
6489 None
6490 ))),
6491 "{executed_qty:?}"
6492 );
6493 assert_eq!(
6494 ended_state(&key, row("EXPIRED", executed_qty)),
6495 Some(InactiveOrderState::Expired(Expired::new(
6496 id.clone(),
6497 ended,
6498 None
6499 ))),
6500 "{executed_qty:?}"
6501 );
6502 assert_eq!(
6503 ended_state(&key, row("FILLED", executed_qty)),
6504 Some(InactiveOrderState::FullyFilled(Filled::new(
6505 id.clone(),
6506 ended,
6507 Decimal::TWO,
6508 Some(Decimal::new(525, 1)),
6509 ))),
6510 "the whole quantity, at the quote over it: {executed_qty:?}"
6511 );
6512 }
6513 }
6514
6515 #[test]
6516 fn an_ended_order_row_says_how_the_order_ended() {
6517 use crate::order::state::{Expired, Filled, InactiveOrderState};
6518 let key = spot_key("BTCUSDT", "a");
6519 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
6520 let id = OrderId::new("7");
6521
6522 let mut filled = order_row("a", "FILLED");
6523 filled["executedQty"] = "2".into();
6524 filled["cummulativeQuoteQty"] = "210".into();
6525 assert_eq!(
6526 ended_state(&key, filled),
6527 Some(InactiveOrderState::FullyFilled(Filled::new(
6528 id.clone(),
6529 ended,
6530 Decimal::TWO,
6531 Some(Decimal::from(105)),
6532 )))
6533 );
6534 assert_eq!(
6535 ended_state(&key, order_row("a", "CANCELED")),
6536 Some(InactiveOrderState::Cancelled(Cancelled::new(
6537 id.clone(),
6538 ended,
6539 Some(Decimal::ONE)
6540 )))
6541 );
6542 for status in ["EXPIRED", "EXPIRED_IN_MATCH"] {
6543 assert_eq!(
6544 ended_state(&key, order_row("a", status)),
6545 Some(InactiveOrderState::Expired(Expired::new(
6546 id.clone(),
6547 ended,
6548 Some(Decimal::ONE)
6549 ))),
6550 "{status}"
6551 );
6552 }
6553 assert!(matches!(
6554 ended_state(&key, order_row("a", "REJECTED")),
6555 Some(InactiveOrderState::OpenFailed(OrderError::Rejected(
6556 ApiError::OrderRejected(_)
6557 )))
6558 ));
6559 }
6560
6561 #[test]
6562 fn a_live_or_unreadable_order_row_is_not_ended() {
6563 let key = spot_key("BTCUSDT", "a");
6564 for status in [
6565 "NEW",
6566 "PARTIALLY_FILLED",
6567 "PENDING_NEW",
6568 "PENDING_CANCEL",
6569 "SOMETHING_NEW",
6570 ] {
6571 assert_eq!(ended_state(&key, order_row("a", status)), None, "{status}");
6572 }
6573 let mut no_status = order_row("a", "FILLED");
6574 no_status["status"] = serde_json::Value::Null;
6575 assert_eq!(ended_state(&key, no_status), None);
6576 let mut no_side = order_row("a", "FILLED");
6577 no_side["side"] = serde_json::Value::Null;
6578 assert_eq!(ended_state(&key, no_side), None);
6579 }
6580
6581 #[test]
6582 fn an_unknown_order_or_symbol_is_recognised_by_its_code() {
6583 use binance_sdk::common::errors::ConnectorError;
6584 let bad = |code| {
6585 anyhow::Error::new(ConnectorError::BadRequestError {
6586 msg: "no".into(),
6587 code: Some(code),
6588 })
6589 };
6590 assert!(is_unknown_order(&bad(-2013)));
6591 assert!(is_unknown_order(&bad(-1121)));
6592 assert!(!is_unknown_order(&bad(-1100)));
6593 assert!(is_unknown_order(&anyhow::Error::new(
6594 ConnectorError::NotFoundError {
6595 msg: "no".into(),
6596 code: Some(-2013),
6597 }
6598 )));
6599 assert!(!is_unknown_order(&anyhow::anyhow!("-2013 in text only")));
6600 }
6601
6602 fn rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
6603 Arc::new(SpotRestApi::from_config(
6604 ConfigurationRestApi::builder()
6605 .api_key("key")
6606 .api_secret("secret")
6607 .base_path(server.uri())
6608 .build()
6609 .unwrap(),
6610 ))
6611 }
6612
6613 async fn mount_order(
6615 server: &wiremock::MockServer,
6616 cid: &str,
6617 response: wiremock::ResponseTemplate,
6618 ) {
6619 wiremock::Mock::given(wiremock::matchers::method("GET"))
6620 .and(wiremock::matchers::path("/api/v3/order"))
6621 .and(wiremock::matchers::query_param("origClientOrderId", cid))
6622 .respond_with(response)
6623 .mount(server)
6624 .await;
6625 }
6626
6627 fn venue_error(code: i64) -> wiremock::ResponseTemplate {
6628 wiremock::ResponseTemplate::new(400)
6629 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
6630 }
6631
6632 fn row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
6633 wiremock::ResponseTemplate::new(200).set_body_json(order_row(cid, status))
6634 }
6635
6636 async fn looked_up(server: &wiremock::MockServer) -> Vec<String> {
6638 server
6639 .received_requests()
6640 .await
6641 .unwrap()
6642 .iter()
6643 .filter(|request| request.url.path() == "/api/v3/order")
6644 .filter_map(|request| {
6645 request
6646 .url
6647 .query_pairs()
6648 .find(|(key, _)| key == "origClientOrderId")
6649 .map(|(_, value)| value.into_owned())
6650 })
6651 .collect()
6652 }
6653
6654 #[tokio::test]
6655 async fn fetch_ended_orders_reports_each_ended_order_under_its_key_and_omits_the_rest() {
6656 let server = wiremock::MockServer::start().await;
6657 mount_order(&server, "filled", row_response("filled", "FILLED")).await;
6658 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
6659 mount_order(&server, "live", row_response("live", "PARTIALLY_FILLED")).await;
6660 mount_order(&server, "unknown", venue_error(-2013)).await;
6661 mount_order(&server, "no-symbol", venue_error(-1121)).await;
6662 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
6663 "key".into(),
6664 "secret".into(),
6665 ));
6666 client.rest = rest_at(&server);
6667 let keys = ["filled", "cancelled", "live", "unknown"].map(|cid| spot_key("BTCUSDT", cid));
6668
6669 let mut ended = client
6670 .fetch_ended_orders(&[keys.to_vec(), vec![spot_key("NOSUCH", "no-symbol")]].concat())
6671 .await
6672 .unwrap();
6673
6674 ended.sort_by(|a, b| a.key.cid.cmp(&b.key.cid));
6675 let reported: Vec<_> = ended.iter().map(|order| &order.key).collect();
6676 assert_eq!(
6677 reported,
6678 [&keys[1], &keys[0]],
6679 "the strategy asked survives"
6680 );
6681 assert!(matches!(
6682 ended[0].state,
6683 crate::order::state::InactiveOrderState::Cancelled(_)
6684 ));
6685 assert!(matches!(
6686 ended[1].state,
6687 crate::order::state::InactiveOrderState::FullyFilled(_)
6688 ));
6689 assert!(client.fetch_ended_orders(&[]).await.unwrap().is_empty());
6690 }
6691
6692 #[tokio::test]
6693 async fn fetch_ended_orders_fails_when_any_lookup_fails() {
6694 let server = wiremock::MockServer::start().await;
6695 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
6696 mount_order(&server, "refused", venue_error(-1100)).await;
6697 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
6698 "key".into(),
6699 "secret".into(),
6700 ));
6701 client.rest = rest_at(&server);
6702
6703 let result = client
6704 .fetch_ended_orders(&[
6705 spot_key("BTCUSDT", "cancelled"),
6706 spot_key("BTCUSDT", "refused"),
6707 ])
6708 .await;
6709
6710 assert!(
6711 matches!(
6712 result,
6713 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
6714 ),
6715 "{result:?}"
6716 );
6717 }
6718
6719 async fn venue_after_a_disconnect(delay: Duration) -> wiremock::MockServer {
6722 let server = wiremock::MockServer::start().await;
6723 wiremock::Mock::given(wiremock::matchers::method("GET"))
6724 .and(wiremock::matchers::path("/api/v3/openOrders"))
6725 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
6726 .respond_with(
6727 wiremock::ResponseTemplate::new(200)
6728 .set_body_json(serde_json::json!([order_row("live", "NEW")])),
6729 )
6730 .mount(&server)
6731 .await;
6732 mount_order(
6733 &server,
6734 "gone",
6735 row_response("gone", "CANCELED").set_delay(delay),
6736 )
6737 .await;
6738 mount_order(&server, "unknown", venue_error(-2013)).await;
6739 server
6740 }
6741
6742 fn held_orders() -> SharedKnownLiveOrders {
6744 let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
6745 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
6746 known.lock().live(
6747 &spot_key("BTCUSDT", cid),
6748 Decimal::TWO,
6749 OrderKind::Limit,
6750 &Open::new(
6751 VenueOrderId::Assigned(OrderId::new(n.to_string())),
6752 Utc::now(),
6753 Decimal::ZERO,
6754 ),
6755 );
6756 }
6757 known
6758 }
6759
6760 fn btcusdt_unchecked() -> UncheckedOrders {
6761 let mut unchecked = UncheckedOrders::default();
6762 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
6763 unchecked
6764 }
6765
6766 #[tokio::test]
6767 async fn a_reconnect_reports_how_an_unlisted_order_ended_and_forgets_an_unknown_one() {
6768 let server = venue_after_a_disconnect(Duration::ZERO).await;
6769 let known = held_orders();
6770 let mut unchecked = btcusdt_unchecked();
6771 let (tx, mut rx) = mpsc::unbounded_channel();
6772
6773 recover_spot_ended_orders(
6774 &rest_at(&server),
6775 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6776 &known,
6777 &mut unchecked,
6778 &UnrecoveredFills::default(),
6779 &tx,
6780 )
6781 .await;
6782
6783 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6784 let [event] = sent.as_slice() else {
6785 panic!("one order ended: {sent:?}");
6786 };
6787 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6788 panic!("an order snapshot: {event:?}");
6789 };
6790 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
6791 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
6792 assert!(matches!(
6793 snapshot.0.state,
6794 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6795 ));
6796 let mut asked = looked_up(&server).await;
6797 asked.sort();
6798 assert_eq!(
6799 asked,
6800 ["gone", "unknown"],
6801 "a listed order is not looked up"
6802 );
6803 let known = known.lock();
6804 assert!(known.contains(&ClientOrderId::new("live")));
6805 assert!(!known.contains(&ClientOrderId::new("gone")));
6806 assert!(
6807 !known.contains(&ClientOrderId::new("unknown")),
6808 "asking again cannot help"
6809 );
6810 assert!(unchecked.is_empty());
6811 }
6812
6813 #[tokio::test]
6814 async fn an_order_the_stream_reports_ending_during_the_check_is_not_reported_again() {
6815 let server = venue_after_a_disconnect(Duration::from_millis(300)).await;
6816 let known = held_orders();
6817 let mut unchecked = btcusdt_unchecked();
6818 let (tx, mut rx) = mpsc::unbounded_channel();
6819 let (rest, tracker) = (
6820 rest_at(&server),
6821 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6822 );
6823 let unrecovered = UnrecoveredFills::default();
6824
6825 tokio::join!(
6826 recover_spot_ended_orders(&rest, &tracker, &known, &mut unchecked, &unrecovered, &tx,),
6827 async {
6828 tokio::time::sleep(Duration::from_millis(100)).await;
6829 known.lock().ended(&ClientOrderId::new("gone"));
6830 }
6831 );
6832
6833 assert!(rx.try_recv().is_err(), "the stream already reported it");
6834 assert!(unchecked.is_empty());
6835 }
6836
6837 #[tokio::test]
6838 async fn an_instrument_with_unrecovered_fills_is_not_checked_yet() {
6839 let server = venue_after_a_disconnect(Duration::ZERO).await;
6840 let known = held_orders();
6841 let mut unchecked = btcusdt_unchecked();
6842 let mut unrecovered = UnrecoveredFills::default();
6843 unrecovered.open(
6844 &[InstrumentNameExchange::new("BTCUSDT")],
6845 Utc::now() - chrono::Duration::minutes(1),
6846 Utc::now(),
6847 );
6848 let (tx, mut rx) = mpsc::unbounded_channel();
6849
6850 recover_spot_ended_orders(
6851 &rest_at(&server),
6852 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6853 &known,
6854 &mut unchecked,
6855 &unrecovered,
6856 &tx,
6857 )
6858 .await;
6859
6860 assert!(rx.try_recv().is_err());
6861 assert!(server.received_requests().await.unwrap().is_empty());
6862 assert!(!unchecked.is_empty(), "it waits for its fills");
6863 assert_eq!(unchecked.next_due(&unrecovered), None, "woken by the gap");
6864 }
6865
6866 #[tokio::test]
6867 async fn a_failed_check_sends_nothing_and_waits_for_its_retry() {
6868 let server = wiremock::MockServer::start().await;
6869 wiremock::Mock::given(wiremock::matchers::method("GET"))
6870 .and(wiremock::matchers::path("/api/v3/openOrders"))
6871 .respond_with(venue_error(-1100))
6872 .mount(&server)
6873 .await;
6874 let server_rest = rest_at(&server);
6875 let known = held_orders();
6876 let mut unchecked = btcusdt_unchecked();
6877 let (tx, mut rx) = mpsc::unbounded_channel();
6878
6879 recover_spot_ended_orders(
6880 &server_rest,
6881 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6882 &known,
6883 &mut unchecked,
6884 &UnrecoveredFills::default(),
6885 &tx,
6886 )
6887 .await;
6888
6889 assert!(rx.try_recv().is_err());
6890 assert!(!unchecked.is_empty());
6891 assert!(
6892 unchecked
6893 .next_due(&UnrecoveredFills::default())
6894 .is_some_and(|due| due > tokio::time::Instant::now()),
6895 "retried after a backoff"
6896 );
6897 assert!(
6898 known.lock().contains(&ClientOrderId::new("gone")),
6899 "still held"
6900 );
6901 }
6902
6903 #[tokio::test]
6904 async fn an_unlisted_order_the_lookup_finds_live_stays_held() {
6905 let server = wiremock::MockServer::start().await;
6906 wiremock::Mock::given(wiremock::matchers::method("GET"))
6907 .and(wiremock::matchers::path("/api/v3/openOrders"))
6908 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6909 .mount(&server)
6910 .await;
6911 for (cid, status) in [
6912 ("live", "NEW"),
6913 ("gone", "PENDING_CANCEL"),
6914 ("unknown", "WHAT"),
6915 ] {
6916 mount_order(&server, cid, row_response(cid, status)).await;
6917 }
6918 let known = held_orders();
6919 let mut unchecked = btcusdt_unchecked();
6920 let (tx, mut rx) = mpsc::unbounded_channel();
6921
6922 recover_spot_ended_orders(
6923 &rest_at(&server),
6924 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6925 &known,
6926 &mut unchecked,
6927 &UnrecoveredFills::default(),
6928 &tx,
6929 )
6930 .await;
6931
6932 assert!(rx.try_recv().is_err(), "none has ended");
6933 let known = known.lock();
6934 for cid in ["live", "gone", "unknown"] {
6935 assert!(
6936 known.contains(&ClientOrderId::new(cid)),
6937 "{cid}: only an order Binance does not know is dropped"
6938 );
6939 }
6940 assert!(unchecked.is_empty());
6941 }
6942
6943 #[tokio::test]
6944 async fn one_instrument_failing_does_not_hold_up_another() {
6945 let server = venue_after_a_disconnect(Duration::ZERO).await;
6946 wiremock::Mock::given(wiremock::matchers::method("GET"))
6947 .and(wiremock::matchers::path("/api/v3/openOrders"))
6948 .and(wiremock::matchers::query_param("symbol", "ETHUSDT"))
6949 .respond_with(venue_error(-1100))
6950 .mount(&server)
6951 .await;
6952 let known = held_orders();
6953 let eth = spot_key("ETHUSDT", "eth");
6954 known.lock().live(
6955 ð,
6956 Decimal::TWO,
6957 OrderKind::Limit,
6958 &Open::new(
6959 VenueOrderId::Assigned(OrderId::new("9")),
6960 Utc::now(),
6961 Decimal::ZERO,
6962 ),
6963 );
6964 let mut unchecked = btcusdt_unchecked();
6965 unchecked.open([eth.instrument.clone()]);
6966 let (tx, mut rx) = mpsc::unbounded_channel();
6967
6968 recover_spot_ended_orders(
6969 &rest_at(&server),
6970 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6971 &known,
6972 &mut unchecked,
6973 &UnrecoveredFills::default(),
6974 &tx,
6975 )
6976 .await;
6977
6978 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6979 assert_eq!(sent.len(), 1, "BTCUSDT's ended order: {sent:?}");
6980 assert!(!unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
6981 assert!(
6982 unchecked.contains(ð.instrument),
6983 "ETHUSDT waits for its retry"
6984 );
6985 assert!(known.lock().contains(ð.cid));
6986 }
6987
6988 #[tokio::test]
6989 async fn a_check_dropped_part_way_changes_nothing() {
6990 let server = venue_after_a_disconnect(Duration::from_millis(500)).await;
6991 let known = held_orders();
6992 let mut unchecked = btcusdt_unchecked();
6993 let (tx, mut rx) = mpsc::unbounded_channel();
6994
6995 let dropped = tokio::time::timeout(
6996 Duration::from_millis(100),
6997 recover_spot_ended_orders(
6998 &rest_at(&server),
6999 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7000 &known,
7001 &mut unchecked,
7002 &UnrecoveredFills::default(),
7003 &tx,
7004 ),
7005 )
7006 .await;
7007
7008 assert!(dropped.is_err(), "the lookup of `gone` was still waiting");
7009 assert!(rx.try_recv().is_err());
7010 assert!(known.lock().contains(&ClientOrderId::new("gone")));
7011 assert!(unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
7012 assert!(
7013 unchecked
7014 .next_due(&UnrecoveredFills::default())
7015 .is_some_and(|due| due <= tokio::time::Instant::now()),
7016 "still due, not charged a failure"
7017 );
7018 }
7019}