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