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