1use super::commission::{CommissionRates, fetch_commission_rates};
40use super::restatement::{
41 BalanceRestatement, SharedStreamClock, StreamClock, restate, restatement_time,
42};
43use super::rules::RulesLoader;
44use super::server_time::{ServerTime, SigningClock, default_server_time_sync, read_server_time};
45use super::shared::{
46 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
47 CONNECT_TIMEOUT_SECS, Disconnect, DisconnectOnDrop, ExecutionReport,
48 FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS, HandshakeRefusal, MyTradesFrom,
49 ORDER_EXECUTIONS_BUDGET, OpenOrderListing, PlacementResponse, RateLimitTracker, RequestKind,
50 SharedDedupCache, UnhandledEvents, UnrecoveredFills, UnsubscribeOnDrop, UserDataFrame,
51 WeightPool, account_position_time, answer_time, binance_filled_qty, classify_order_kind_tif,
52 classify_rest_query_error, classify_ws_order_error, convert_ended_order,
53 convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
54 dedup_key_from_event, disconnect_error, disconnect_lookback, drop_after, frame_excerpt,
55 gap_failed, gap_time, is_duplicate, is_unknown_order, log_unhandled_event,
56 log_unrecognised_frame, new_dedup_cache, parse_user_data_frame, placed_order_state,
57 recovered_order_cids, recovered_order_totals, response_decode_error, rest_call_with_retry,
58 rest_read_with_retry, retry_or_end, trailing_params, unix_ms, unreadable_answer,
59};
60use crate::client::venue_clock::HostMoment;
61use crate::rules::{OrderRules, PreSendRules};
62use crate::{
63 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
64 UnindexedAccountEvent, UnindexedAccountSnapshot,
65 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
66 client::{
67 ExecutionClient, OrderStatusClient,
68 order_recovery::{
69 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
70 fetch_ended_by_key, recover_ended_orders,
71 },
72 reconnect_budget::{BackoffLimits, ReconnectBudget},
73 },
74 emit_stream_terminated,
75 error::{
76 ApiError, ConnectivityError, NotPlacedReason, OrderError, UnindexedClientError,
77 UnindexedOrderError,
78 },
79 margin::MarginEffect,
80 order::{
81 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
82 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
83 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
84 state::{Cancelled, Open, OrderState, UnindexedOrderState},
85 },
86 parse_env_bool,
87 position::PositionReport,
88 trade::{AssetFees, TimeSource, Trade, TradeId, TradesRead},
89};
90use binance_sdk::{
91 common::{
92 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
93 models::WebsocketEvent,
94 },
95 spot::{
96 SpotRestApi, SpotWsApi,
97 rest_api::{
98 GetAccountParams, GetOpenOrdersParams, GetOrderParams, MyTradesParams, RestApi,
99 },
100 websocket_api::{
101 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
102 OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
103 WebsocketApiHandle,
104 },
105 },
106};
107use chrono::{DateTime, TimeDelta, TimeZone, Utc};
108use fnv::FnvHashSet;
109use futures::stream::BoxStream;
110use rust_decimal::Decimal;
111use rustrade_instrument::{
112 Side,
113 asset::name::AssetNameExchange,
114 exchange::ExchangeId,
115 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
116};
117use serde::Deserialize;
118use smol_str::format_smolstr;
119use std::{
120 collections::HashMap,
121 str::FromStr,
122 sync::{
123 Arc,
124 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
125 },
126 time::Duration,
127};
128use tokio::sync::{RwLock, mpsc, oneshot};
129use tracing::{debug, error, info, trace, warn};
130
131#[derive(Clone, Deserialize)]
138pub struct BinanceSpotConfig {
139 api_key: String,
142 secret_key: String,
143
144 #[serde(default = "default_testnet")]
146 pub testnet: bool,
147
148 #[serde(default)]
152 pub pre_send_rules: PreSendRules,
153
154 #[serde(default = "crate::client::binance::server_time::default_server_time_sync")]
158 pub server_time_sync: bool,
159}
160
161fn default_testnet() -> bool {
167 true
168}
169
170impl std::fmt::Debug for BinanceSpotConfig {
172 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
173 f.debug_struct("BinanceSpotConfig")
174 .field("api_key", &"***")
175 .field("secret_key", &"***")
176 .field("testnet", &self.testnet)
177 .field("pre_send_rules", &self.pre_send_rules)
178 .field("server_time_sync", &self.server_time_sync)
179 .finish()
180 }
181}
182
183impl BinanceSpotConfig {
184 pub fn new(api_key: String, secret_key: String) -> Self {
187 Self::testnet(api_key, secret_key)
188 }
189
190 pub fn testnet(api_key: String, secret_key: String) -> Self {
192 Self {
193 api_key,
194 secret_key,
195 testnet: true,
196 pre_send_rules: PreSendRules::default(),
197 server_time_sync: default_server_time_sync(),
198 }
199 }
200
201 #[must_use]
203 pub fn with_pre_send_rules(mut self, pre_send_rules: PreSendRules) -> Self {
204 self.pre_send_rules = pre_send_rules;
205 self
206 }
207
208 #[must_use]
211 pub fn with_server_time_sync(mut self, server_time_sync: bool) -> Self {
212 self.server_time_sync = server_time_sync;
213 self
214 }
215
216 pub fn production(api_key: String, secret_key: String) -> Self {
221 Self {
222 api_key,
223 secret_key,
224 testnet: false,
225 pre_send_rules: PreSendRules::default(),
226 server_time_sync: default_server_time_sync(),
227 }
228 }
229
230 pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
249 let api_key = match std::env::var("BINANCE_API_KEY") {
250 Ok(value) => value,
251 Err(std::env::VarError::NotPresent) => {
252 return Err(BinanceSpotConfigError::MissingApiKey);
253 }
254 Err(std::env::VarError::NotUnicode(_)) => {
255 return Err(BinanceSpotConfigError::InvalidApiKey);
256 }
257 };
258 let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
259 Ok(value) => value,
260 Err(std::env::VarError::NotPresent) => {
261 return Err(BinanceSpotConfigError::MissingSecretKey);
262 }
263 Err(std::env::VarError::NotUnicode(_)) => {
264 return Err(BinanceSpotConfigError::InvalidSecretKey);
265 }
266 };
267 let testnet = match std::env::var("BINANCE_TESTNET") {
268 Ok(value) => {
269 parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
270 }
271 Err(std::env::VarError::NotPresent) => true,
272 Err(std::env::VarError::NotUnicode(value)) => {
275 return Err(BinanceSpotConfigError::InvalidTestnet(
276 value.to_string_lossy().into_owned(),
277 ));
278 }
279 };
280
281 if testnet {
282 Ok(Self::testnet(api_key, secret_key))
283 } else {
284 Ok(Self::production(api_key, secret_key))
285 }
286 }
287
288 pub fn api_key(&self) -> &str {
290 &self.api_key
291 }
292}
293
294#[derive(Debug, PartialEq, thiserror::Error)]
295pub enum BinanceSpotConfigError {
296 #[error("BINANCE_API_KEY environment variable not set")]
297 MissingApiKey,
298
299 #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
302 InvalidApiKey,
303
304 #[error("BINANCE_SECRET_KEY environment variable not set")]
305 MissingSecretKey,
306
307 #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
308 InvalidSecretKey,
309
310 #[error("BINANCE_TESTNET must be true or false, got {0}")]
311 InvalidTestnet(String),
312}
313
314#[derive(Debug)]
321enum WsApiUnavailable {
322 RateLimited {
325 msg: String,
326 retry_after: Option<Duration>,
327 },
328 Connect(String),
330}
331
332impl WsApiUnavailable {
333 fn into_order_error(self) -> UnindexedOrderError {
336 match self {
337 Self::RateLimited { msg, retry_after } => {
338 debug!(%msg, "BinanceSpot WS-API rate-limited, request not sent");
341 UnindexedOrderError::Rejected(ApiError::rate_limited(retry_after))
342 }
343 Self::Connect(msg) => UnindexedOrderError::Connectivity(ConnectivityError::Socket(msg)),
344 }
345 }
346
347 fn into_open_error(self) -> UnindexedOrderError {
351 match self {
352 Self::Connect(msg) => UnindexedOrderError::NotPlaced(NotPlacedReason::NotSent(
353 ConnectivityError::Socket(msg),
354 )),
355 rate_limited @ Self::RateLimited { .. } => rate_limited.into_order_error(),
356 }
357 }
358}
359
360#[derive(Clone)]
403pub struct BinanceSpot {
404 config: Arc<BinanceSpotConfig>,
405 rest: Arc<RestApi>,
406 ws_handle: WebsocketApiHandle,
408 ws_api: Arc<RwLock<Option<DisconnectOnDrop<WebsocketApi>>>>,
414 rate_limiter: Arc<RateLimitTracker>,
416 known_live: SharedKnownLiveOrders,
419 rules: Arc<RulesLoader>,
422}
423
424impl std::fmt::Debug for BinanceSpot {
425 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
426 f.debug_struct("BinanceSpot")
427 .field("testnet", &self.config.testnet)
428 .finish_non_exhaustive()
429 }
430}
431
432impl BinanceSpot {
433 pub async fn load_rules(
442 &self,
443 instruments: &[InstrumentNameExchange],
444 ) -> Result<HashMap<InstrumentNameExchange, OrderRules>, UnindexedClientError> {
445 self.rules.load(instruments).await
446 }
447
448 pub async fn fetch_commission_rates(
460 &self,
461 instrument: &InstrumentNameExchange,
462 ) -> Result<CommissionRates, UnindexedClientError> {
463 fetch_commission_rates(&self.rest, &self.rate_limiter, instrument).await
464 }
465
466 pub fn host_clock_lead(&self) -> Option<TimeDelta> {
474 self.rate_limiter.host_clock_lead()
475 }
476
477 fn remember_live<'a>(
479 &self,
480 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
481 ) {
482 let mut known = self.known_live.lock();
483 for order in orders {
484 known.live(&order.key, order.quantity, order.kind, &order.state);
485 }
486 }
487
488 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig, signing: Option<&SigningClock>) -> Arc<RestApi> {
492 let mut builder = ConfigurationRestApi::builder()
493 .api_key(config.api_key.clone())
494 .api_secret(config.secret_key.clone());
495 if let Some(signing) = signing {
496 builder = builder.timestamp_provider(signing.provider());
497 }
498 let rest_config = builder
499 .build()
500 .expect("failed to build Binance REST configuration");
501
502 Arc::new(if config.testnet {
503 SpotRestApi::testnet(rest_config)
504 } else {
505 SpotRestApi::production(rest_config)
506 })
507 }
508
509 #[allow(clippy::expect_used)] fn build_ws_handle(
513 config: &BinanceSpotConfig,
514 signing: Option<&SigningClock>,
515 ) -> WebsocketApiHandle {
516 let mut builder = ConfigurationWebsocketApi::builder()
517 .api_key(config.api_key.clone())
518 .api_secret(config.secret_key.clone());
519 if let Some(signing) = signing {
520 builder = builder.timestamp_provider(signing.provider());
521 }
522 let ws_config = builder
523 .build()
524 .expect("failed to build Binance WebSocket configuration");
525
526 if config.testnet {
527 SpotWsApi::testnet(ws_config)
528 } else {
529 SpotWsApi::production(ws_config)
530 }
531 }
532
533 async fn get_ws_api(&self) -> Result<WebsocketApi, WsApiUnavailable> {
544 if let Err(cooldown) = self.rate_limiter.wait_if_blocked(RequestKind::Order).await {
545 return Err(WsApiUnavailable::RateLimited {
546 msg: cooldown.to_string(),
547 retry_after: Some(cooldown.remaining),
548 });
549 }
550 {
552 let guard = self.ws_api.read().await;
553 if let Some(ref ws) = *guard {
554 return Ok(WebsocketApi::clone(ws));
555 }
556 }
557 let mut guard = self.ws_api.write().await;
564 if let Some(ref ws) = *guard {
566 return Ok(WebsocketApi::clone(ws));
567 }
568 if let Some(remaining) = self.rate_limiter.cooldown_remaining() {
569 return Err(WsApiUnavailable::RateLimited {
570 msg: "rate-limit cooldown active, not connecting the WS-API session".into(),
571 retry_after: Some(remaining),
572 });
573 }
574 let ws = match tokio::time::timeout(
575 Duration::from_secs(CONNECT_TIMEOUT_SECS),
576 self.ws_handle.connect(),
577 )
578 .await
579 {
580 Ok(Ok(ws)) => ws,
581 Ok(Err(e)) => {
582 let Some(refusal) = HandshakeRefusal::of(&e) else {
583 return Err(WsApiUnavailable::Connect(format!("{e:#}")));
584 };
585 warn!(error = %format!("{e:#}"), "Binance refused the WS-API handshake");
587 let retry_after = refusal.retry_after();
588 self.rate_limiter.on_rate_limited(retry_after);
589 return Err(WsApiUnavailable::RateLimited {
590 msg: format!("{e:#}"),
591 retry_after,
592 });
593 }
594 Err(_) => {
595 return Err(WsApiUnavailable::Connect(format!(
596 "BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s"
597 )));
598 }
599 };
600 *guard = Some(DisconnectOnDrop::new(
601 ws.clone(),
602 "BinanceSpot order session",
603 ));
604 Ok(ws)
605 }
606
607 async fn clear_ws_api(&self) {
610 let ws = {
622 let mut guard = self.ws_api.write().await;
623 guard.take()
624 };
625 if let Some(ws) = ws {
626 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
628 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
629 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
630 Ok(Ok(())) => {}
631 }
632 }
633 }
634}
635
636async fn fetch_open_orders_for_instrument(
643 rest: Arc<RestApi>,
644 rate_limiter: Arc<RateLimitTracker>,
645 instrument: InstrumentNameExchange,
646 kind: RequestKind,
647) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
648 let symbol_str = instrument.name().to_string();
650 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
651 let sym = symbol_str.clone();
652 Box::pin(async move {
653 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
654 rest.get_open_orders(params).await
655 })
656 })
657 .await
658 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
659
660 let orders_data = response.data().await.map_err(response_decode_error)?;
661
662 let listing =
664 convert_open_order_listing(&orders_data, ExchangeId::BinanceSpot, &instrument, &|_| {
665 None
666 });
667
668 Ok((instrument, listing))
669}
670
671async fn fetch_all_open_orders(
676 rest: Arc<RestApi>,
677 rate_limiter: Arc<RateLimitTracker>,
678) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
679 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
680 Box::pin(async move {
681 let params = GetOpenOrdersParams::builder().build()?;
682 rest.get_open_orders(params).await
683 })
684 })
685 .await
686 .map_err(|e| classify_rest_query_error(&e, None))?;
687
688 let orders_data = response.data().await.map_err(response_decode_error)?;
689
690 let orders = orders_data
691 .into_iter()
692 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot, &|_| None))
694 .collect();
695
696 Ok(orders)
697}
698
699async fn fetch_order_lookup(
701 rest: Arc<RestApi>,
702 rate_limiter: Arc<RateLimitTracker>,
703 key: UnindexedOrderKey,
704 kind: RequestKind,
705) -> Result<OrderLookup, UnindexedClientError> {
706 let symbol = key.instrument.name().to_string();
708 let cid = key.cid.0.to_string();
709 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
710 let (symbol, cid) = (symbol.clone(), cid.clone());
711 Box::pin(async move {
712 let params = GetOrderParams::builder(symbol)
713 .orig_client_order_id(cid)
714 .build()?;
715 rest.get_order(params).await
716 })
717 })
718 .await
719 {
720 Ok(response) => response,
721 Err(e) if is_unknown_order(&e) => {
722 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceSpot does not know this order");
723 return Ok(OrderLookup::Unknown);
724 }
725 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
726 };
727
728 let row = response.data().await.map_err(response_decode_error)?;
729
730 Ok(
732 convert_ended_order(&row, ExchangeId::BinanceSpot, &key, None)
733 .map_or(OrderLookup::NotEnded, |order| {
734 OrderLookup::Ended(Box::new(order))
735 }),
736 )
737}
738
739async fn fetch_order_cid(
743 rest: &Arc<RestApi>,
744 rate_limiter: &RateLimitTracker,
745 instrument: &InstrumentNameExchange,
746 order_id: i64,
747) -> Result<Option<ClientOrderId>, UnindexedClientError> {
748 let symbol = instrument.name().to_string();
749 let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
750 let symbol = symbol.clone();
751 Box::pin(async move {
752 let params = GetOrderParams::builder(symbol).order_id(order_id).build()?;
753 rest.get_order(params).await
754 })
755 })
756 .await
757 {
758 Ok(response) => response,
759 Err(e) if is_unknown_order(&e) => {
760 debug!(%instrument, order_id, error = %e, "BinanceSpot does not know this order");
761 return Ok(None);
762 }
763 Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
764 };
765 let row = response.data().await.map_err(response_decode_error)?;
766 Ok(row
767 .client_order_id
768 .filter(|cid| !cid.is_empty())
769 .map(ClientOrderId::new))
770}
771
772async fn listed_open_cids(
775 rest: Arc<RestApi>,
776 rate_limiter: Arc<RateLimitTracker>,
777 instruments: Vec<InstrumentNameExchange>,
778) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
779 let mut listed = FnvHashSet::default();
780 for instrument in instruments {
781 let (_, listing) = fetch_open_orders_for_instrument(
782 rest.clone(),
783 rate_limiter.clone(),
784 instrument,
785 RequestKind::Essential,
786 )
787 .await?;
788 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
789 }
790 Ok(listed)
791}
792
793async fn recover_spot_ended_orders(
796 rest: &Arc<RestApi>,
797 rate_limiter: &Arc<RateLimitTracker>,
798 known: &SharedKnownLiveOrders,
799 unchecked: &mut UncheckedOrders,
800 unrecovered: &UnrecoveredFills,
801 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
802) {
803 recover_ended_orders(
804 ExchangeId::BinanceSpot,
805 known,
806 unchecked,
807 unrecovered,
808 tx,
809 OpenListing::PerInstrument,
810 |instruments| listed_open_cids(rest.clone(), rate_limiter.clone(), instruments),
811 |key| {
812 fetch_order_lookup(
813 rest.clone(),
814 rate_limiter.clone(),
815 key,
816 RequestKind::Essential,
817 )
818 },
819 )
820 .await;
821}
822
823async fn paginate_my_trades(
834 rest: &Arc<RestApi>,
835 rate_limiter: &Arc<RateLimitTracker>,
836 instrument: &InstrumentNameExchange,
837 from: MyTradesFrom,
838 kind: RequestKind,
839) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
840 let symbol_str = instrument.name().to_string();
842 let mut all_pages = Vec::new();
847 let mut cursor: Option<i64> = None;
848 loop {
849 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
851 let sym = symbol_str.clone();
852 Box::pin(async move {
853 #[allow(clippy::cast_possible_truncation)]
855 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
856 let params = match (from, fid) {
857 (MyTradesFrom::Span { start, .. }, None) => builder.start_time(start),
858 (MyTradesFrom::Span { .. }, Some(id)) => builder.from_id(id),
859 (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
860 (MyTradesFrom::Order(order_id), Some(id)) => {
861 builder.order_id(order_id).from_id(id)
862 }
863 }
864 .build()?;
865 rest.my_trades(params).await
866 })
867 })
868 .await
869 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
870
871 let mut page = response.data().await.map_err(response_decode_error)?;
872
873 let page_len = page.len();
874 let last_id = page.last().and_then(|t| t.id);
875 let past_end = match from {
877 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
878 MyTradesFrom::Order(_) => false,
879 };
880 all_pages.extend(page);
881
882 if past_end || page_len < BINANCE_MAX_TRADES {
883 break;
884 }
885 match last_id {
886 Some(id) => {
887 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
888 match id.checked_add(1) {
889 Some(next) => cursor = Some(next),
890 None => break, }
892 }
893 None => {
894 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
895 break;
896 }
897 }
898 }
899 Ok(all_pages)
900}
901
902impl ExecutionClient for BinanceSpot {
907 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
908
909 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
912 &[InstrumentKindDiscriminant::Spot];
913 type Config = BinanceSpotConfig;
914 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
915
916 fn new(config: Self::Config) -> Self {
921 let signing = config.server_time_sync.then(SigningClock::default);
922 let rest = Self::build_rest(&config, signing.as_ref());
923 let ws_handle = Self::build_ws_handle(&config, signing.as_ref());
924 let server_time =
926 signing.map(|signing| ServerTime::new(signing, read_server_time(Arc::clone(&rest))));
927 let rate_limiter =
928 Arc::new(RateLimitTracker::new(WeightPool::Spot).with_server_time(server_time));
929 let rules = Arc::new(RulesLoader::new(
930 config.pre_send_rules,
931 Arc::clone(&rest),
932 Arc::clone(&rate_limiter),
933 ));
934 Self {
935 config: Arc::new(config),
936 rest,
937 ws_handle,
938 ws_api: Arc::new(RwLock::new(None)),
939 rate_limiter,
940 known_live: KnownLiveOrders::shared(ExchangeId::BinanceSpot),
941 rules,
942 }
943 }
944
945 async fn account_snapshot(
946 &self,
947 assets: &[AssetNameExchange],
948 instruments: &[InstrumentNameExchange],
949 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
950 let (response, sent) =
952 rest_read_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
953 Box::pin(async move {
954 let params = GetAccountParams::builder().build()?;
955 rest.get_account(params).await
956 })
957 })
958 .await
959 .map_err(|e| classify_rest_query_error(&e, None))?;
960
961 let account = response.data().await.map_err(response_decode_error)?;
962
963 let balances = filter_and_convert_balances(
965 account.balances.unwrap_or_default(),
966 assets,
967 self.rate_limiter.balance_read_time(sent),
968 );
969
970 use futures::{StreamExt as _, TryStreamExt};
976 let instrument_snapshots: Vec<_> =
977 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
978 fetch_open_orders_for_instrument(
979 self.rest.clone(),
980 self.rate_limiter.clone(),
981 instrument,
982 RequestKind::Query,
983 )
984 }))
985 .buffer_unordered(8)
986 .map(|result| {
987 let (inst, listing) = result?;
988 self.remember_live(&listing.orders);
989 let wrapped = listing
990 .orders
991 .into_iter()
992 .map(|o| Order {
993 key: o.key,
994 side: o.side,
995 price: o.price,
996 quantity: o.quantity,
997 kind: o.kind,
998 time_in_force: o.time_in_force,
999 state: OrderState::active(o.state),
1000 })
1001 .collect();
1002 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1003 inst,
1004 wrapped,
1005 listing.complete,
1006 PositionReport::Unreported,
1007 None,
1008 ))
1009 })
1010 .try_collect()
1011 .await?;
1012
1013 Ok(AccountSnapshot::new(
1014 ExchangeId::BinanceSpot,
1015 balances,
1016 instrument_snapshots,
1017 ))
1018 }
1019
1020 async fn account_stream(
1101 &self,
1102 assets: &[AssetNameExchange],
1106 instruments: &[InstrumentNameExchange],
1107 ) -> Result<Self::AccountStream, UnindexedClientError> {
1108 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1123 let dedup = new_dedup_cache();
1124 let ws_handle = self.ws_handle.clone();
1125 let rest = self.rest.clone();
1126 let rate_limiter = self.rate_limiter.clone();
1127 let known_live = self.known_live.clone();
1128 let instruments = instruments.to_vec();
1129 debug_assert!(
1133 instruments.iter().all(|i| i.name().len() <= 23),
1134 "instrument name exceeds SmolStr inline capacity: {:?}",
1135 instruments.iter().find(|i| i.name().len() > 23)
1136 );
1137
1138 self.rate_limiter.before_stream_open().await?;
1140
1141 let initial_ws = ws_handle.connect().await.map_err(|e| {
1145 self.rate_limiter.stream_open_error(&e, |e| {
1146 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1147 })
1148 })?;
1149 let initial_ws = DisconnectOnDrop::new(initial_ws, ACCOUNT_STREAM_WS);
1151
1152 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1154 .build()
1155 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1156
1157 match initial_ws
1158 .user_data_stream_subscribe_signature(params)
1159 .await
1160 {
1161 Ok(_) => {}
1162 Err(e) => {
1163 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
1167 Ok(Err(de)) => {
1168 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
1169 }
1170 Err(_) => {
1171 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
1172 }
1173 Ok(Ok(())) => {}
1174 }
1175 self.rate_limiter.after_failure(&e).await;
1176 return Err(self
1177 .rate_limiter
1178 .stream_open_error(&e, |e| UnindexedClientError::Internal(e.to_string())));
1179 }
1180 }
1181
1182 let cm_handle = tokio::spawn(connection_manager(
1194 tx,
1195 dedup,
1196 ws_handle,
1197 rest,
1198 rate_limiter,
1199 known_live,
1200 instruments,
1201 assets.to_vec(),
1202 Some(initial_ws),
1203 ));
1204
1205 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1209 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1210 Ok(futures::StreamExt::boxed(guarded_stream))
1211 }
1212
1213 async fn cancel_order(
1214 &self,
1215 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1216 ) -> UnindexedOrderResponseCancel {
1217 let instrument = request.key.instrument.clone();
1218 let key = OrderKey {
1219 exchange: request.key.exchange,
1220 instrument: instrument.clone(),
1221 strategy: request.key.strategy.clone(),
1222 cid: request.key.cid.clone(),
1223 };
1224
1225 let ws = match self.get_ws_api().await {
1226 Ok(ws) => ws,
1227 Err(unavailable) => {
1228 return UnindexedOrderResponseCancel {
1229 key,
1230 state: Err(unavailable.into_order_error()),
1231 };
1232 }
1233 };
1234
1235 let mut params_builder =
1238 OrderCancelParams::builder(request.key.instrument.name().to_string());
1239
1240 if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1242 if let Ok(id) = order_id.0.parse::<i64>() {
1243 params_builder = params_builder.order_id(id);
1244 } else {
1245 error!(
1250 order_id = %order_id.0,
1251 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
1252 );
1253 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1254 }
1255 } else {
1256 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1257 }
1258
1259 let params = match params_builder.build() {
1260 Ok(p) => p,
1261 Err(e) => {
1262 error!(%e, "BinanceSpot failed to build cancel order params");
1263 return UnindexedOrderResponseCancel {
1264 key,
1265 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1266 e.to_string(),
1267 ))),
1268 };
1269 }
1270 };
1271
1272 let sent_ms = unix_ms();
1273 let result = ws.order_cancel(params).await;
1274 if let Ok(response) = &result {
1276 self.rate_limiter
1277 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1278 }
1279 match result {
1280 Ok(response) => match response.data() {
1281 Ok(data) => {
1282 let time_exchange = answer_time(
1283 ExchangeId::BinanceSpot,
1284 &instrument,
1285 "cancel_order",
1286 data.transact_time,
1287 );
1288
1289 let exchange_order_id = match data.order_id {
1290 Some(id) => OrderId(format_smolstr!("{id}")),
1291 None => {
1292 return UnindexedOrderResponseCancel {
1293 key,
1294 state: Err(unreadable_answer(
1295 ExchangeId::BinanceSpot,
1296 "cancel answer has no orderId",
1297 )),
1298 };
1299 }
1300 };
1301
1302 let filled_qty = binance_filled_qty(
1303 ExchangeId::BinanceSpot,
1304 &exchange_order_id,
1305 data.executed_qty.as_deref(),
1306 );
1307
1308 self.known_live.lock().ended(&key.cid);
1309 UnindexedOrderResponseCancel {
1310 key,
1311 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1312 }
1313 }
1314 Err(e) => UnindexedOrderResponseCancel {
1315 key,
1316 state: Err(unreadable_answer(ExchangeId::BinanceSpot, &e.to_string())),
1317 },
1318 },
1319 Err(e) => {
1320 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1328 self.rate_limiter.after_failure(&e).await;
1330 if let OrderError::Rejected(api @ ApiError::RateLimit { .. }) = &order_err {
1334 self.rate_limiter.on_rate_limited(api.retry_after());
1335 }
1336 UnindexedOrderResponseCancel {
1337 key,
1338 state: Err(order_err),
1339 }
1340 } else {
1341 self.clear_ws_api().await;
1344 UnindexedOrderResponseCancel {
1345 key,
1346 state: Err(UnindexedOrderError::Connectivity(
1347 ConnectivityError::Socket(format!("{e:#}")),
1348 )),
1349 }
1350 }
1351 }
1352 }
1353 }
1354
1355 async fn open_order(
1356 &self,
1357 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1358 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1359 let instrument = request.key.instrument.clone();
1360 let side = request.state.side;
1361 let price = request.state.price;
1362 let quantity = request.state.quantity;
1363 let kind = request.state.kind;
1364 let time_in_force = request.state.time_in_force;
1365 let cid = request.key.cid.clone();
1366
1367 let order_key = OrderKey::new(
1368 ExchangeId::BinanceSpot,
1369 instrument.clone(),
1370 request.key.strategy.clone(),
1371 cid.clone(),
1372 );
1373
1374 if let Some(effect) = request
1376 .state
1377 .margin_effect
1378 .filter(|effect| *effect != MarginEffect::NoSideEffect)
1379 {
1380 return Order {
1381 key: order_key,
1382 side,
1383 price,
1384 quantity,
1385 kind,
1386 time_in_force,
1387 state: OrderState::inactive(OrderError::UnsupportedMarginEffect(effect)),
1388 };
1389 }
1390
1391 if let Err(refused) = self
1392 .rules
1393 .check(instrument.name(), side, kind, price, quantity)
1394 .await
1395 {
1396 return Order {
1397 key: order_key,
1398 side,
1399 price,
1400 quantity,
1401 kind,
1402 time_in_force,
1403 state: OrderState::inactive(refused),
1404 };
1405 }
1406
1407 let ws = match self.get_ws_api().await {
1408 Ok(ws) => ws,
1409 Err(unavailable) => {
1410 return Order {
1411 key: order_key,
1412 side,
1413 price,
1414 quantity,
1415 kind,
1416 time_in_force,
1417 state: OrderState::inactive(unavailable.into_open_error()),
1418 };
1419 }
1420 };
1421
1422 let binance_side = match side {
1423 Side::Buy => OrderPlaceSideEnum::Buy,
1424 Side::Sell => OrderPlaceSideEnum::Sell,
1425 };
1426
1427 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
1428 Some(converted) => converted,
1429 None => {
1430 return Order {
1431 key: order_key,
1432 side,
1433 price,
1434 quantity,
1435 kind,
1436 time_in_force,
1437 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1438 "Binance Spot does not yet support OrderKind::{kind:?}"
1439 ))),
1440 };
1441 }
1442 };
1443
1444 let mut params_builder =
1451 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
1452 .quantity(quantity)
1453 .new_client_order_id(cid.0.to_string());
1454
1455 match trailing_params(&kind, price) {
1457 Ok(Some(trailing)) => {
1458 params_builder = params_builder.trailing_delta(trailing.trailing_delta);
1459 if let Some(limit) = trailing.price {
1460 params_builder = params_builder.price(limit);
1461 }
1462 if let Some(activation_price) = trailing.stop_price {
1463 params_builder = params_builder.stop_price(activation_price);
1465 }
1466 }
1467 Err(refused) => {
1468 return Order {
1469 key: order_key,
1470 side,
1471 price,
1472 quantity,
1473 kind,
1474 time_in_force,
1475 state: OrderState::inactive(refused.into_order_error()),
1476 };
1477 }
1478 Ok(None) => {}
1479 }
1480
1481 match kind {
1484 OrderKind::Limit => {
1485 params_builder = params_builder.price(price);
1486 }
1487 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
1488 params_builder = params_builder.stop_price(trigger_price);
1489 }
1490 OrderKind::StopLimit { trigger_price }
1491 | OrderKind::TakeProfitLimit { trigger_price } => {
1492 params_builder = params_builder.price(price).stop_price(trigger_price);
1493 }
1494 OrderKind::TrailingStop { .. }
1497 | OrderKind::TrailingStopFixedLimit { .. }
1498 | OrderKind::Market
1499 | OrderKind::TrailingStopLimit { .. }
1500 | OrderKind::ConditionalUnresolved { .. } => {}
1501 }
1502
1503 if let Some(tif) = binance_tif {
1504 params_builder = params_builder.time_in_force(tif);
1505 }
1506
1507 let params = match params_builder.build() {
1508 Ok(p) => p,
1509 Err(e) => {
1510 error!(%e, "BinanceSpot failed to build new order params");
1511 return Order {
1512 key: order_key,
1513 side,
1514 price,
1515 quantity,
1516 kind,
1517 time_in_force,
1518 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1519 e.to_string(),
1520 ))),
1521 };
1522 }
1523 };
1524
1525 let sent_ms = unix_ms();
1526 let result = ws.order_place(params).await;
1527 if let Ok(response) = &result {
1529 self.rate_limiter
1530 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1531 }
1532 match result {
1533 Ok(response) => match response.data() {
1534 Ok(data) => {
1535 let time_exchange = answer_time(
1536 ExchangeId::BinanceSpot,
1537 &instrument,
1538 "open_order",
1539 data.transact_time,
1540 );
1541
1542 let exchange_order_id = match data.order_id {
1543 Some(id) => OrderId(format_smolstr!("{id}")),
1544 None => {
1545 return Order {
1546 key: order_key,
1547 side,
1548 price,
1549 quantity,
1550 kind,
1551 time_in_force,
1552 state: OrderState::inactive(unreadable_answer(
1553 ExchangeId::BinanceSpot,
1554 "open_order answer has no orderId",
1555 )),
1556 };
1557 }
1558 };
1559
1560 let state = placed_order_state(
1565 ExchangeId::BinanceSpot,
1566 &instrument,
1567 quantity,
1568 PlacementResponse {
1569 status: data.status.as_deref(),
1570 order_id: exchange_order_id,
1571 time_exchange,
1572 executed_qty: data.executed_qty.as_deref(),
1573 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
1574 },
1575 );
1576 self.known_live
1577 .lock()
1578 .placed(&order_key, quantity, kind, &state);
1579
1580 Order {
1581 key: order_key,
1582 side,
1583 price,
1584 quantity,
1585 kind,
1586 time_in_force,
1587 state,
1588 }
1589 }
1590 Err(e) => Order {
1591 key: order_key,
1592 side,
1593 price,
1594 quantity,
1595 kind,
1596 time_in_force,
1597 state: OrderState::inactive(unreadable_answer(
1598 ExchangeId::BinanceSpot,
1599 &e.to_string(),
1600 )),
1601 },
1602 },
1603 Err(e) => {
1604 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1612 self.rate_limiter.after_failure(&e).await;
1614 if let OrderError::Rejected(api @ ApiError::RateLimit { .. }) = &order_err {
1618 self.rate_limiter.on_rate_limited(api.retry_after());
1619 }
1620 Order {
1621 key: order_key,
1622 side,
1623 price,
1624 quantity,
1625 kind,
1626 time_in_force,
1627 state: OrderState::inactive(order_err),
1628 }
1629 } else {
1630 self.clear_ws_api().await;
1633 Order {
1634 key: order_key,
1635 side,
1636 price,
1637 quantity,
1638 kind,
1639 time_in_force,
1640 state: OrderState::inactive(OrderError::Connectivity(
1641 ConnectivityError::Socket(format!("{e:#}")),
1642 )),
1643 }
1644 }
1645 }
1646 }
1647 }
1648
1649 async fn fetch_balances(
1655 &self,
1656 assets: &[AssetNameExchange],
1657 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1658 let (response, sent) =
1659 rest_read_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1660 Box::pin(async move {
1661 let params = GetAccountParams::builder().build()?;
1662 rest.get_account(params).await
1663 })
1664 })
1665 .await
1666 .map_err(|e| classify_rest_query_error(&e, None))?;
1667
1668 let account = response.data().await.map_err(response_decode_error)?;
1669
1670 Ok(filter_and_convert_balances(
1671 account.balances.unwrap_or_default(),
1672 assets,
1673 self.rate_limiter.balance_read_time(sent),
1674 ))
1675 }
1676
1677 async fn fetch_open_orders(
1682 &self,
1683 instruments: &[InstrumentNameExchange],
1684 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1685 if instruments.is_empty() {
1688 let orders =
1689 fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await?;
1690 self.remember_live(&orders);
1691 return Ok(orders);
1692 }
1693 use futures::{StreamExt as _, TryStreamExt as _};
1698 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1699 fetch_open_orders_for_instrument(
1700 self.rest.clone(),
1701 self.rate_limiter.clone(),
1702 instrument,
1703 RequestKind::Query,
1704 )
1705 }))
1706 .buffer_unordered(8)
1707 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1708 acc.extend(listing.orders);
1709 Ok(acc)
1710 })
1711 .await
1712 .inspect(|orders| self.remember_live(orders))
1713 }
1714
1715 #[allow(clippy::redundant_iter_cloned)]
1726 async fn fetch_trades(
1727 &self,
1728 start: DateTime<Utc>,
1729 end: DateTime<Utc>,
1730 instruments: &[InstrumentNameExchange],
1731 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1732 use futures::StreamExt;
1733
1734 if instruments.is_empty() {
1735 debug!(
1736 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1737 );
1738 return Ok(TradesRead::complete(Vec::new()));
1739 }
1740 if start > end {
1741 return Ok(TradesRead::complete(Vec::new()));
1742 }
1743 let span = MyTradesFrom::Span {
1745 start: start.timestamp_millis(),
1746 end: end.timestamp_millis(),
1747 };
1748 let mut all_trades = Vec::new();
1751
1752 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1756 let rest = self.rest.clone();
1757 let rate_limiter = self.rate_limiter.clone();
1758 async move {
1759 let pages =
1760 paginate_my_trades(&rest, &rate_limiter, &inst, span, RequestKind::Query)
1761 .await?;
1762 Ok::<_, UnindexedClientError>((inst, pages))
1763 }
1764 }))
1765 .buffer_unordered(8);
1766 while let Some(result) = stream.next().await {
1767 let (instrument, trades_data) = result?;
1768 for t in trades_data {
1769 if let Some(trade) = convert_my_trade(&t, &instrument)
1770 && (start..=end).contains(&trade.time_exchange)
1771 {
1772 all_trades.push(trade);
1773 }
1774 }
1775 }
1776
1777 Ok(TradesRead::complete(all_trades))
1778 }
1779}
1780
1781impl OrderStatusClient for BinanceSpot {
1796 async fn fetch_ended_orders(
1797 &self,
1798 orders: &[UnindexedOrderKey],
1799 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1800 fetch_ended_by_key(orders, |key| {
1801 fetch_order_lookup(
1802 self.rest.clone(),
1803 self.rate_limiter.clone(),
1804 key,
1805 RequestKind::Query,
1806 )
1807 })
1808 .await
1809 }
1810}
1811
1812const ACCOUNT_STREAM_WS: &str = "BinanceSpot account stream";
1814
1815async fn connect_and_subscribe(
1819 ws_handle: &WebsocketApiHandle,
1820) -> anyhow::Result<DisconnectOnDrop<WebsocketApi>> {
1821 let ws = DisconnectOnDrop::new(ws_handle.connect().await?, ACCOUNT_STREAM_WS);
1822 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1824 .build()
1825 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1826 match ws.user_data_stream_subscribe_signature(params).await {
1827 Ok(_) => Ok(ws),
1828 Err(e) => {
1829 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1832 Err(e)
1833 }
1834 }
1835}
1836
1837#[allow(clippy::cognitive_complexity)]
1861async fn connection_manager(
1862 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1863 dedup: SharedDedupCache,
1864 ws_handle: WebsocketApiHandle,
1865 rest: Arc<RestApi>,
1866 rate_limiter: Arc<RateLimitTracker>,
1867 known: SharedKnownLiveOrders,
1868 instruments: Vec<InstrumentNameExchange>,
1869 assets: Vec<AssetNameExchange>,
1871 initial_ws: Option<DisconnectOnDrop<WebsocketApi>>,
1872) {
1873 let mut budget = ReconnectBudget::new("Binance", BackoffLimits::ACCOUNT_STREAM);
1874 let mut disconnect: Option<Disconnect> = None;
1875 let mut unrecovered = UnrecoveredFills::default();
1877 let mut unchecked = UncheckedOrders::default();
1879 let mut restatement = BalanceRestatement::default();
1881 let clock = StreamClock::shared(rate_limiter.signing_clock());
1883 let mut current_ws = initial_ws;
1884
1885 loop {
1886 let ws = match current_ws.take() {
1888 Some(ws) => ws,
1889 None => {
1890 if let Err(reason) = rate_limiter.before_stream_connect().await {
1893 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
1894 break;
1895 }
1896 match connect_and_subscribe(&ws_handle).await {
1897 Ok(ws) => ws,
1903 Err(e) => {
1904 error!(%e, "BinanceSpot WS connect/subscribe failed");
1905 rate_limiter.on_stream_refused(&e);
1907 rate_limiter.after_failure(&e).await;
1909 if let Err(reason) =
1910 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
1911 {
1912 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
1914 break;
1915 }
1916 continue;
1917 }
1918 }
1919 }
1920 };
1921 info!("BinanceSpot account_stream connected and subscribed");
1922
1923 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1929 let mut signal_tx_opt = Some(signal_tx);
1930 let heartbeat_flag = Arc::new(AtomicBool::new(false));
1934 let hb_callback = heartbeat_flag.clone();
1935 let dedup_callback = dedup.clone();
1936 let known_callback = known.clone();
1937 let clock_callback = clock.clone();
1938 let mut event_tx = Some(tx.clone());
1942 let mut event_buf = Vec::with_capacity(32);
1946
1947 let subscription = ws.subscribe_on_ws_events(move |event| {
1954 let Some(ref sender) = event_tx else { return };
1955 match event {
1956 WebsocketEvent::Message(json_str) => {
1957 hb_callback.store(true, Ordering::Release);
1960 let outcome = convert_user_data_events(&json_str, &mut event_buf);
1964 StreamClock::observe_frame(
1965 &clock_callback,
1966 outcome.event_time,
1967 &event_buf,
1968 tokio::time::Instant::now(),
1969 );
1970 let stream_terminated = outcome.terminated;
1971 for ev in event_buf.drain(..) {
1972 if let Some(key) = dedup_key_from_event(&ev)
1974 && is_duplicate(&dedup_callback, key)
1975 {
1976 trace!("BinanceSpot dedup: skipping duplicate event");
1977 continue;
1978 }
1979 let known = KnownLiveOrders::observes(&ev.kind).then(|| {
1983 let mut known = known_callback.lock();
1984 known.observe(&ev);
1985 known
1986 });
1987 if sender.send(ev).is_err() {
1988 drop(known);
1989 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1990 event_tx.take();
1991 if let Some(s) = signal_tx_opt.take() {
1992 let _ = s.send(());
1993 }
1994 return;
1995 }
1996 }
1997 if stream_terminated {
2000 event_tx.take();
2001 if let Some(s) = signal_tx_opt.take() {
2002 let _ = s.send(());
2003 }
2004 }
2005 }
2006 WebsocketEvent::Ping | WebsocketEvent::Pong => {
2007 hb_callback.store(true, Ordering::Release);
2010 }
2011 WebsocketEvent::Error(e) => {
2012 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
2016 event_tx.take();
2017 if let Some(s) = signal_tx_opt.take() {
2018 let _ = s.send(());
2019 }
2020 }
2021 WebsocketEvent::Close(code, reason) => {
2022 warn!(code, %reason, "BinanceSpot WebSocket closed");
2023 event_tx.take();
2024 if let Some(s) = signal_tx_opt.take() {
2025 let _ = s.send(());
2026 }
2027 }
2028 _ => {}
2029 }
2030 });
2031 let subscription = UnsubscribeOnDrop::new(subscription);
2033
2034 if let Some(disconnect) = disconnect.take() {
2041 let end = disconnect.end(&clock.lock().venue(), HostMoment::now());
2042 unrecovered.open(&instruments, disconnect.start, end);
2043 let held = known.lock().instruments_among(&instruments);
2045 unchecked.open(held);
2046 restatement.open();
2047 }
2048 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known).await;
2049 recover_spot_ended_orders(
2050 &rest,
2051 &rate_limiter,
2052 &known,
2053 &mut unchecked,
2054 &unrecovered,
2055 &tx,
2056 )
2057 .await;
2058 enum DisconnectReason {
2064 Signal,
2065 HeartbeatTimeout,
2066 ConsumerDropped,
2067 }
2068 let reason = {
2069 let mut signal_rx = signal_rx;
2070 let retry_gaps = async {
2076 loop {
2077 restate("BinanceSpot", &clock, &mut restatement, &tx, |floor| {
2079 read_spot_restatement(&rest, &rate_limiter, &assets, &clock, floor)
2080 })
2081 .await;
2082 let next_check = unchecked.next_due(&unrecovered);
2083 match unrecovered
2084 .next_due()
2085 .into_iter()
2086 .chain(next_check)
2087 .chain(restatement.next_due())
2088 .min()
2089 {
2090 Some(due) => tokio::time::sleep_until(due).await,
2091 None => std::future::pending::<()>().await,
2092 }
2093 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known)
2094 .await;
2095 recover_spot_ended_orders(
2096 &rest,
2097 &rate_limiter,
2098 &known,
2099 &mut unchecked,
2100 &unrecovered,
2101 &tx,
2102 )
2103 .await;
2104 }
2105 };
2106 tokio::pin!(retry_gaps);
2107 let mut grace = true;
2109 loop {
2110 tokio::select! {
2111 biased;
2113 _ = tx.closed() => {
2114 debug!("BinanceSpot account_stream consumer dropped, terminating");
2115 break DisconnectReason::ConsumerDropped;
2116 }
2117 _ = &mut signal_rx => {
2118 warn!("BinanceSpot WS disconnected, will attempt reconnect");
2119 break DisconnectReason::Signal;
2120 }
2121 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2122 let in_grace = std::mem::take(&mut grace);
2129 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2130 budget.reset();
2134 continue;
2135 }
2136 if in_grace {
2137 continue;
2140 }
2141 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2142 break DisconnectReason::HeartbeatTimeout;
2143 }
2144 () = &mut retry_gaps => {}
2146 }
2147 }
2148 };
2149 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2150
2151 if should_reconnect {
2158 let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
2159 disconnect = Some(Disconnect::seen(
2160 &clock.lock().venue(),
2161 HostMoment::now(),
2162 disconnect_lookback(heartbeat_timed_out),
2163 ));
2164 }
2165
2166 subscription.unsubscribe();
2176 if let Err(e) = ws.disconnect().await {
2179 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
2180 }
2181
2182 if !should_reconnect || tx.is_closed() {
2183 debug!("BinanceSpot connection manager exiting");
2186 break;
2187 }
2188 let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
2191 let last_error = || disconnect_error(heartbeat_timed_out);
2192 if let Err(reason) = retry_or_end(&mut budget, &rate_limiter, last_error).await {
2193 emit_stream_terminated(&tx, ExchangeId::BinanceSpot, reason);
2194 break;
2195 }
2196 }
2197}
2198
2199async fn read_spot_restatement(
2203 rest: &Arc<RestApi>,
2204 rate_limiter: &Arc<RateLimitTracker>,
2205 assets: &[AssetNameExchange],
2206 clock: &SharedStreamClock,
2207 floor: Option<DateTime<Utc>>,
2208) -> Result<Vec<UnindexedAccountEvent>, UnindexedClientError> {
2209 let (response, sent) = rest_read_with_retry(rest, rate_limiter, RequestKind::Query, |rest| {
2210 Box::pin(async move {
2211 let params = GetAccountParams::builder().build()?;
2212 rest.get_account(params).await
2213 })
2214 })
2215 .await
2216 .map_err(|e| classify_rest_query_error(&e, None))?;
2217 let account = response.data().await.map_err(response_decode_error)?;
2218 let time = restatement_time(&clock.lock(), sent, floor);
2219 Ok(
2220 filter_and_convert_balances(account.balances.unwrap_or_default(), assets, time)
2221 .into_iter()
2222 .map(|balance| {
2223 UnindexedAccountEvent::new(
2224 ExchangeId::BinanceSpot,
2225 AccountEventKind::BalanceSnapshot(
2226 rustrade_integration::collection::snapshot::Snapshot(balance),
2227 ),
2228 )
2229 })
2230 .collect(),
2231 )
2232}
2233
2234async fn recover_fills(
2253 rest: &Arc<RestApi>,
2254 rate_limiter: &Arc<RateLimitTracker>,
2255 unrecovered: &mut UnrecoveredFills,
2256 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2257 dedup: &SharedDedupCache,
2258 known: &SharedKnownLiveOrders,
2259) {
2260 use futures::StreamExt;
2261
2262 let due = unrecovered.due(tokio::time::Instant::now());
2263 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2264 return;
2265 };
2266 info!(
2267 gaps = due.len(),
2268 oldest = %gap_time(oldest),
2269 "BinanceSpot recovering fills missed while disconnected"
2270 );
2271
2272 let mut settled = vec![false; due.len()];
2275 let started = AtomicUsize::new(0);
2276 let recovery = async {
2277 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2278 let mut recovered = 0u32;
2279 let mut duplicates = 0u32;
2280
2281 let mut stream =
2287 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2288 started.store(index + 1, Ordering::Relaxed);
2289 let rest = rest.clone();
2290 let rl = rate_limiter.clone();
2291 async move {
2292 let span = MyTradesFrom::Span {
2293 start: gap.start_ms,
2294 end: gap.end_ms,
2295 };
2296 let raw =
2297 match paginate_my_trades(&rest, &rl, &inst, span, RequestKind::Essential)
2298 .await
2299 {
2300 Ok(pages) => pages,
2301 Err(e) => return (index, Err(e)),
2302 };
2303 let totals = recovered_order_totals(
2307 ExchangeId::BinanceSpot,
2308 &inst,
2309 &raw,
2310 order_executions_deadline,
2311 |order_id| {
2312 paginate_my_trades(
2313 &rest,
2314 &rl,
2315 &inst,
2316 MyTradesFrom::Order(order_id),
2317 RequestKind::Essential,
2318 )
2319 },
2320 )
2321 .await;
2322 let cids = recovered_order_cids(
2325 ExchangeId::BinanceSpot,
2326 &inst,
2327 &raw,
2328 order_executions_deadline,
2329 |order_id| fetch_order_cid(&rest, &rl, &inst, order_id),
2330 )
2331 .await;
2332 let trades: Vec<_> = raw
2333 .iter()
2334 .filter_map(|t| {
2335 let mut trade = convert_my_trade(t, &inst)?;
2336 trade.order_filled_quantity =
2337 t.id.and_then(|id| totals.get(&id).copied());
2338 trade.client_order_id =
2339 t.order_id.and_then(|id| cids.get(&id).cloned());
2340 Some(trade)
2341 })
2342 .collect();
2343 (index, Ok(trades))
2344 }
2345 }))
2346 .buffer_unordered(8);
2347 while let Some((index, result)) = stream.next().await {
2348 let (inst, gap) = &due[index];
2349 settled[index] = true;
2350 let trades = match result {
2351 Ok(trades) => trades,
2352 Err(e) => {
2353 gap_failed(
2354 unrecovered,
2355 tx,
2356 ExchangeId::BinanceSpot,
2357 inst,
2358 gap,
2359 FillRecoveryFailure::Request(e.to_string()),
2360 );
2361 continue;
2362 }
2363 };
2364 for trade in trades {
2365 let event = UnindexedAccountEvent::new(
2368 ExchangeId::BinanceSpot,
2369 AccountEventKind::Trade(trade),
2370 );
2371 if let Some(key) = dedup_key_from_event(&event)
2374 && is_duplicate(dedup, key)
2375 {
2376 duplicates += 1;
2377 continue;
2378 }
2379 let sent = {
2382 let mut known = known.lock();
2383 known.observe(&event);
2384 tx.send(event)
2385 };
2386 if sent.is_err() {
2387 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
2390 return;
2391 }
2392 recovered += 1;
2393 }
2394 unrecovered.recovered(inst, gap);
2395 }
2396 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
2397 };
2398 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2402 .await
2403 .is_err()
2404 {
2405 let started = started.load(Ordering::Relaxed);
2406 for ((inst, gap), _) in due[..started]
2407 .iter()
2408 .zip(&settled)
2409 .filter(|(_, settled)| !**settled)
2410 {
2411 gap_failed(
2412 unrecovered,
2413 tx,
2414 ExchangeId::BinanceSpot,
2415 inst,
2416 gap,
2417 FillRecoveryFailure::TimedOut {
2418 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2419 },
2420 );
2421 }
2422 }
2423}
2424
2425fn convert_balance_entry(
2439 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
2440 now: chrono::DateTime<Utc>,
2441) -> Option<AssetBalance<AssetNameExchange>> {
2442 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2443 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2444 Some(v) => v,
2445 None => {
2446 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
2447 return None;
2448 }
2449 };
2450 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2451 Some(v) => v,
2452 None => {
2453 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
2454 return None;
2455 }
2456 };
2457 Some(AssetBalance::new(
2458 asset_name,
2459 Balance::new(free + locked, free),
2460 now,
2461 ))
2462}
2463
2464fn filter_and_convert_balances(
2467 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
2468 assets: &[AssetNameExchange],
2469 time: chrono::DateTime<Utc>,
2470) -> Vec<AssetBalance<AssetNameExchange>> {
2471 if assets.is_empty() {
2473 return balances
2474 .into_iter()
2475 .filter_map(|b| convert_balance_entry(b, time))
2476 .collect();
2477 }
2478 if assets.len() <= 16 {
2481 return balances
2482 .into_iter()
2483 .filter_map(|b| {
2484 let asset_name_str = b.asset.as_deref()?;
2485 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
2486 return None;
2487 }
2488 convert_balance_entry(b, time)
2489 })
2490 .collect();
2491 }
2492 use std::collections::HashSet;
2493 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
2494 balances
2495 .into_iter()
2496 .filter_map(|b| {
2497 let asset_name_str = b.asset.as_deref()?;
2498 if !asset_set.contains(asset_name_str) {
2499 return None;
2500 }
2501 convert_balance_entry(b, time)
2502 })
2503 .collect()
2504}
2505
2506fn convert_my_trade(
2508 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
2509 instrument: &InstrumentNameExchange,
2510) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
2511 let trade_id_raw = match t.id {
2512 Some(id) => id,
2513 None => {
2514 warn!(%instrument, "BinanceSpot trade missing id");
2515 return None;
2516 }
2517 };
2518 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
2519 let order_id = match t.order_id {
2520 Some(id) => OrderId(format_smolstr!("{}", id)),
2521 None => {
2522 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
2523 return None;
2524 }
2525 };
2526 let side = match t.is_buyer {
2527 Some(is_buyer) => {
2528 if is_buyer {
2529 Side::Buy
2530 } else {
2531 Side::Sell
2532 }
2533 }
2534 None => {
2535 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
2536 return None;
2537 }
2538 };
2539 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2540 Some(v) => v,
2541 None => {
2542 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
2543 return None;
2544 }
2545 };
2546 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2547 Some(v) => v,
2548 None => {
2549 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2550 return None;
2551 }
2552 };
2553 let commission = t
2554 .commission
2555 .as_deref()
2556 .and_then(|s| Decimal::from_str(s).ok())
2557 .unwrap_or(Decimal::ZERO);
2558 let (time_exchange, time_source) = match t
2561 .time
2562 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2563 {
2564 Some(ts) => (ts, TimeSource::Venue),
2565 None => {
2566 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2567 (Utc::now(), TimeSource::Host)
2568 }
2569 };
2570
2571 let fee_asset = t
2577 .commission_asset
2578 .as_deref()
2579 .map(AssetNameExchange::from)
2580 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2581
2582 Some(
2583 Trade::new(
2584 trade_id,
2585 order_id,
2586 instrument.clone(),
2587 StrategyId::unknown(), time_exchange,
2589 side,
2590 price,
2591 quantity,
2592 None,
2594 AssetFees::new(fee_asset, commission, None),
2595 )
2596 .with_time_source(time_source),
2597 )
2598}
2599
2600fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> UserDataOutcome {
2626 let (event_type, event, event_time) = match parse_user_data_frame(frame) {
2627 UserDataFrame::Response => return UserDataOutcome::default(),
2628 UserDataFrame::Event {
2629 event_type,
2630 event,
2631 event_time,
2632 ..
2633 } => (event_type, event, event_time),
2634 UserDataFrame::Unrecognised => {
2635 static SEEN: AtomicU64 = AtomicU64::new(0);
2636 log_unrecognised_frame("BinanceSpot", &SEEN, frame);
2637 return UserDataOutcome::default();
2638 }
2639 };
2640 UserDataOutcome {
2641 terminated: convert_user_data_event(event_type, event, buf),
2642 event_time,
2643 }
2644}
2645
2646#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
2648struct UserDataOutcome {
2649 terminated: bool,
2651 event_time: Option<DateTime<Utc>>,
2653}
2654
2655fn convert_user_data_event(
2658 event_type: &str,
2659 event: &str,
2660 buf: &mut Vec<UnindexedAccountEvent>,
2661) -> bool {
2662 match event_type {
2664 "executionReport" => {
2665 match serde_json::from_str::<ExecutionReport>(event) {
2668 Ok(report) => {
2669 convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
2670 }
2671 Err(e) => {
2672 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable executionReport, dropping")
2673 }
2674 }
2675 false
2676 }
2677 "outboundAccountPosition" => {
2678 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2679 event,
2680 ) {
2681 Ok(position) => convert_account_position(position, buf),
2682 Err(e) => {
2683 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2684 }
2685 }
2686 false
2687 }
2688 "balanceUpdate" => {
2689 false
2696 }
2697 "eventStreamTerminated" => {
2698 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2702 true
2703 }
2704 "listStatus" | "externalLockUpdate" => {
2705 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2709 false
2710 }
2711 _ => {
2712 static SEEN: UnhandledEvents = UnhandledEvents::new();
2713 log_unhandled_event("BinanceSpot", &SEEN, event_type, event);
2714 false
2715 }
2716 }
2717}
2718
2719fn convert_account_position(
2724 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2725 buf: &mut Vec<UnindexedAccountEvent>,
2726) {
2727 let Some(time_exchange) =
2730 account_position_time(ExchangeId::BinanceSpot, position.u, position.e_uppercase)
2731 else {
2732 return;
2733 };
2734
2735 for b in position.b_uppercase.unwrap_or_default() {
2736 let asset = match b.a {
2737 Some(a) => AssetNameExchange::new(a),
2738 None => {
2739 warn!("BinanceSpot account position entry missing asset name");
2740 continue;
2741 }
2742 };
2743 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2744 Some(v) => v,
2745 None => {
2746 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2747 continue;
2748 }
2749 };
2750 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2751 Some(v) => v,
2752 None => {
2753 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2754 continue;
2755 }
2756 };
2757 let update =
2762 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2763 buf.push(UnindexedAccountEvent::new(
2764 ExchangeId::BinanceSpot,
2765 AccountEventKind::BalanceStreamUpdate(
2766 rustrade_integration::collection::snapshot::Snapshot::new(update),
2767 ),
2768 ));
2769 }
2770}
2771
2772fn convert_order_kind_tif(
2803 kind: OrderKind,
2804 tif: TimeInForce,
2805) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2806 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2810 let type_enum = match binance_type {
2811 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2812 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2813 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2814 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2815 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2816 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2817 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2818 };
2819 let tif_enum = binance_tif.map(|t| match t {
2820 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2821 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2822 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2823 });
2824 Some((type_enum, tif_enum))
2825}
2826
2827#[cfg(test)]
2828#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2830 use super::*;
2831 use crate::client::binance::shared::*;
2832 use crate::client::dedup::{DedupEventKind, DedupKey};
2833 use crate::error::StreamTerminationReason;
2834 use crate::order::{TrailingOffsetType, id::ClientOrderId};
2835 use binance_sdk::common::errors::WebsocketError;
2836 use smol_str::SmolStr;
2837
2838 #[test]
2841 fn an_unconnected_session_fails_an_open_as_not_placed() {
2842 let open = WsApiUnavailable::Connect("refused".into()).into_open_error();
2843 assert!(matches!(
2844 &open,
2845 OrderError::NotPlaced(NotPlacedReason::NotSent(ConnectivityError::Socket(m))) if m == "refused"
2846 ));
2847 assert!(!open.is_outcome_unknown());
2848
2849 let cancel = WsApiUnavailable::Connect("refused".into()).into_order_error();
2850 assert!(matches!(cancel, OrderError::Connectivity(_)));
2851
2852 let limited = || WsApiUnavailable::RateLimited {
2853 msg: "banned".into(),
2854 retry_after: None,
2855 };
2856 assert_eq!(limited().into_open_error(), limited().into_order_error());
2857 assert!(matches!(
2858 limited().into_open_error(),
2859 OrderError::Rejected(ApiError::RateLimit { .. })
2860 ));
2861 }
2862
2863 #[tokio::test]
2866 async fn open_order_refuses_a_borrow_or_repay_effect_unsent() {
2867 use crate::{
2868 client::ExecutionClient,
2869 margin::MarginEffect,
2870 order::{request::RequestOpen, state::InactiveOrderState},
2871 };
2872
2873 let client = BinanceSpot::new(
2874 BinanceSpotConfig::testnet("key".into(), "secret".into()).with_server_time_sync(false),
2875 );
2876 let instrument = InstrumentNameExchange::new("BTCUSDT");
2877
2878 for effect in [MarginEffect::Borrow, MarginEffect::Repay] {
2879 let order = client
2880 .open_order(OrderRequestOpen {
2881 key: OrderKey::new(
2882 ExchangeId::BinanceSpot,
2883 &instrument,
2884 StrategyId::new("strategy"),
2885 ClientOrderId::new("cid"),
2886 ),
2887 state: RequestOpen::new(
2888 Side::Buy,
2889 Some(Decimal::from(50_000)),
2890 Decimal::ONE,
2891 OrderKind::Limit,
2892 TimeInForce::GoodUntilCancelled { post_only: false },
2893 )
2894 .with_margin_effect(effect),
2895 })
2896 .await;
2897
2898 assert!(
2899 matches!(
2900 order.state,
2901 OrderState::Inactive(InactiveOrderState::OpenFailed(
2902 OrderError::UnsupportedMarginEffect(refused)
2903 )) if refused == effect
2904 ),
2905 "{order:?}"
2906 );
2907 }
2908 }
2909
2910 #[tokio::test]
2912 async fn load_rules_returns_the_rules_it_read() {
2913 let server = wiremock::MockServer::start().await;
2914 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
2915 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2916 serde_json::json!({"symbols": [{
2917 "symbol": "BTCUSDT", "status": "TRADING",
2918 "filters": [
2919 {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
2920 ],
2921 }]}),
2922 ))
2923 .mount(&server)
2924 .await;
2925 let mut client = BinanceSpot::new(
2926 BinanceSpotConfig::testnet("key".into(), "secret".into()).with_server_time_sync(false),
2927 );
2928 client.rules = Arc::new(RulesLoader::new(
2929 PreSendRules::Enforce,
2930 Arc::new(SpotRestApi::from_config(
2931 ConfigurationRestApi::builder()
2932 .api_key("key")
2933 .api_secret("secret")
2934 .base_path(server.uri())
2935 .build()
2936 .unwrap(),
2937 )),
2938 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
2939 ));
2940 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
2941
2942 let loaded = client.load_rules(std::slice::from_ref(&btcusdt)).await;
2943 let rules = loaded.ok().and_then(|mut loaded| loaded.remove(&btcusdt));
2944 assert_eq!(
2945 rules.map(|rules| rules.quantity.step),
2946 Some(Some(Decimal::new(1, 3)))
2947 );
2948 }
2949
2950 #[tokio::test]
2953 async fn open_order_refuses_an_order_breaking_the_symbols_rules_unsent() {
2954 use crate::{
2955 client::ExecutionClient,
2956 error::{Bound, BoundsViolation, OrderField},
2957 order::{request::RequestOpen, state::InactiveOrderState},
2958 };
2959
2960 let server = wiremock::MockServer::start().await;
2961 wiremock::Mock::given(wiremock::matchers::method("GET"))
2962 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
2963 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
2964 serde_json::json!({"symbols": [{
2965 "symbol": "BTCUSDT", "status": "TRADING",
2966 "filters": [
2967 {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
2968 ],
2969 }]}),
2970 ))
2971 .expect(1)
2972 .mount(&server)
2973 .await;
2974 let mut client = BinanceSpot::new(
2975 BinanceSpotConfig::testnet("key".into(), "secret".into()).with_server_time_sync(false),
2976 );
2977 client.rules = Arc::new(RulesLoader::new(
2978 PreSendRules::Enforce,
2979 Arc::new(SpotRestApi::from_config(
2980 ConfigurationRestApi::builder()
2981 .api_key("key")
2982 .api_secret("secret")
2983 .base_path(server.uri())
2984 .build()
2985 .unwrap(),
2986 )),
2987 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
2988 ));
2989 let instrument = InstrumentNameExchange::new("BTCUSDT");
2990
2991 let order = client
2992 .open_order(OrderRequestOpen {
2993 key: OrderKey::new(
2994 ExchangeId::BinanceSpot,
2995 &instrument,
2996 StrategyId::new("strategy"),
2997 ClientOrderId::new("cid"),
2998 ),
2999 state: RequestOpen::new(
3000 Side::Buy,
3001 Some(Decimal::from(50_000)),
3002 Decimal::from(101),
3003 OrderKind::Limit,
3004 TimeInForce::GoodUntilCancelled { post_only: false },
3005 ),
3006 })
3007 .await;
3008
3009 assert!(
3010 matches!(
3011 order.state,
3012 OrderState::Inactive(InactiveOrderState::OpenFailed(
3013 OrderError::OutOfBounds(violation)
3014 )) if violation == BoundsViolation::new(
3015 OrderField::Quantity,
3016 Bound::Max,
3017 Decimal::from(100),
3018 Decimal::from(101),
3019 )
3020 ),
3021 "{order:?}"
3022 );
3023 }
3024
3025 #[tokio::test]
3028 async fn open_order_refuses_a_trailing_offset_outside_its_sides_range_unsent() {
3029 use crate::{
3030 client::ExecutionClient,
3031 error::{Bound, BoundsViolation, OrderField},
3032 order::{TrailingOffsetType, request::RequestOpen, state::InactiveOrderState},
3033 };
3034
3035 let server = wiremock::MockServer::start().await;
3036 wiremock::Mock::given(wiremock::matchers::method("GET"))
3037 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
3038 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
3039 serde_json::json!({"symbols": [{
3040 "symbol": "BTCUSDT", "status": "TRADING",
3041 "filters": [
3042 {"filterType": "TRAILING_DELTA", "minTrailingAboveDelta": 10,
3043 "maxTrailingAboveDelta": 2000, "minTrailingBelowDelta": 10,
3044 "maxTrailingBelowDelta": 1000},
3045 ],
3046 }]}),
3047 ))
3048 .expect(1)
3049 .mount(&server)
3050 .await;
3051 let mut client = BinanceSpot::new(
3052 BinanceSpotConfig::testnet("key".into(), "secret".into()).with_server_time_sync(false),
3053 );
3054 client.rules = Arc::new(RulesLoader::new(
3055 PreSendRules::Enforce,
3056 Arc::new(SpotRestApi::from_config(
3057 ConfigurationRestApi::builder()
3058 .api_key("key")
3059 .api_secret("secret")
3060 .base_path(server.uri())
3061 .build()
3062 .unwrap(),
3063 )),
3064 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
3065 ));
3066 let instrument = InstrumentNameExchange::new("BTCUSDT");
3067 let order = |cid: &str, side: Side, offset: Decimal| OrderRequestOpen {
3068 key: OrderKey::new(
3069 ExchangeId::BinanceSpot,
3070 &instrument,
3071 StrategyId::new("strategy"),
3072 ClientOrderId::new(cid),
3073 ),
3074 state: RequestOpen::new(
3075 side,
3076 None,
3077 Decimal::ONE,
3078 OrderKind::TrailingStop {
3079 offset,
3080 offset_type: TrailingOffsetType::BasisPoints,
3081 activation_price: None,
3082 },
3083 TimeInForce::GoodUntilCancelled { post_only: false },
3084 ),
3085 };
3086 let refused_at = |order: Order<_, _, _>| match order.state {
3087 OrderState::Inactive(InactiveOrderState::OpenFailed(OrderError::OutOfBounds(
3088 violation,
3089 ))) => Some(violation),
3090 _ => None,
3091 };
3092
3093 let buy = client
3094 .open_order(order("a", Side::Buy, Decimal::from(2500)))
3095 .await;
3096 assert_eq!(
3097 refused_at(buy),
3098 Some(BoundsViolation::new(
3099 OrderField::TrailingOffset,
3100 Bound::Max,
3101 Decimal::from(2000),
3102 Decimal::from(2500),
3103 ))
3104 );
3105 let sell = client
3106 .open_order(order("b", Side::Sell, Decimal::from(1500)))
3107 .await;
3108 assert_eq!(
3109 refused_at(sell),
3110 Some(BoundsViolation::new(
3111 OrderField::TrailingOffset,
3112 Bound::Max,
3113 Decimal::from(1000),
3114 Decimal::from(1500),
3115 ))
3116 );
3117 }
3118
3119 #[test]
3120 fn test_binance_spot_config_new_uses_testnet_by_default() {
3121 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
3122 assert!(cfg.testnet);
3123 assert_eq!(cfg.api_key(), "my_key");
3124 }
3125
3126 #[test]
3127 fn test_binance_spot_config_production_is_explicit() {
3128 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
3129 assert!(!cfg.testnet);
3130 }
3131
3132 #[test]
3133 fn test_binance_spot_config_debug_redacts_credentials() {
3134 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
3135 let debug = format!("{cfg:?}");
3136
3137 assert!(!debug.contains("my_key"), "api_key should be redacted");
3138 assert!(
3139 !debug.contains("my_secret"),
3140 "secret_key should be redacted"
3141 );
3142 assert!(debug.contains("testnet: true"));
3143 }
3144
3145 #[test]
3146 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
3147 let cfg: BinanceSpotConfig = serde_json::from_str(
3148 r#"{
3149 "api_key": "my_key",
3150 "secret_key": "my_secret"
3151 }"#,
3152 )
3153 .unwrap();
3154
3155 assert!(cfg.testnet);
3156 assert_eq!(cfg.api_key(), "my_key");
3157 }
3158
3159 #[test]
3160 fn test_binance_spot_config_deserialize_testnet_true() {
3161 let cfg: BinanceSpotConfig = serde_json::from_str(
3162 r#"{
3163 "api_key": "my_key",
3164 "secret_key": "my_secret",
3165 "testnet": true
3166 }"#,
3167 )
3168 .unwrap();
3169
3170 assert!(cfg.testnet);
3171 }
3172
3173 #[test]
3174 fn test_binance_spot_config_deserialize_testnet_false() {
3175 let cfg: BinanceSpotConfig = serde_json::from_str(
3176 r#"{
3177 "api_key": "my_key",
3178 "secret_key": "my_secret",
3179 "testnet": false
3180 }"#,
3181 )
3182 .unwrap();
3183
3184 assert!(!cfg.testnet);
3185 }
3186
3187 #[test]
3188 #[serial_test::serial]
3189 fn test_binance_spot_config_from_env_defaults_to_testnet() {
3190 temp_env::with_vars(
3191 [
3192 ("BINANCE_API_KEY", Some("my_key")),
3193 ("BINANCE_SECRET_KEY", Some("my_secret")),
3194 ("BINANCE_TESTNET", None),
3195 ],
3196 || {
3197 let cfg = BinanceSpotConfig::from_env().unwrap();
3198 assert!(cfg.testnet);
3199 assert_eq!(cfg.api_key(), "my_key");
3200 },
3201 );
3202 }
3203
3204 #[test]
3205 #[serial_test::serial]
3206 fn test_binance_spot_config_from_env_accepts_explicit_production() {
3207 temp_env::with_vars(
3208 [
3209 ("BINANCE_API_KEY", Some("my_key")),
3210 ("BINANCE_SECRET_KEY", Some("my_secret")),
3211 ("BINANCE_TESTNET", Some("false")),
3212 ],
3213 || {
3214 let cfg = BinanceSpotConfig::from_env().unwrap();
3215 assert!(!cfg.testnet);
3216 },
3217 );
3218 }
3219
3220 #[test]
3221 #[serial_test::serial]
3222 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
3223 temp_env::with_vars(
3224 [
3225 ("BINANCE_API_KEY", Some("my_key")),
3226 ("BINANCE_SECRET_KEY", Some("my_secret")),
3227 ("BINANCE_TESTNET", Some("maybe")),
3228 ],
3229 || {
3230 let err = BinanceSpotConfig::from_env().unwrap_err();
3231 assert!(
3232 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
3233 );
3234 },
3235 );
3236 }
3237
3238 #[test]
3239 #[serial_test::serial]
3240 fn test_binance_spot_config_from_env_requires_credentials() {
3241 temp_env::with_vars(
3242 [
3243 ("BINANCE_API_KEY", None),
3244 ("BINANCE_SECRET_KEY", Some("my_secret")),
3245 ("BINANCE_TESTNET", None),
3246 ],
3247 || {
3248 let err = BinanceSpotConfig::from_env().unwrap_err();
3249 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
3250 },
3251 );
3252 }
3253
3254 #[test]
3255 fn test_parse_side() {
3256 assert_eq!(parse_side("BUY"), Some(Side::Buy));
3257 assert_eq!(parse_side("SELL"), Some(Side::Sell));
3258 assert_eq!(parse_side("buy"), None);
3259 assert_eq!(parse_side("UNKNOWN"), None);
3260 }
3261
3262 #[test]
3263 fn test_parse_time_in_force() {
3264 assert_eq!(
3265 parse_time_in_force("GTC"),
3266 TimeInForce::GoodUntilCancelled { post_only: false }
3267 );
3268 assert_eq!(
3269 parse_time_in_force("GTX"),
3270 TimeInForce::GoodUntilCancelled { post_only: true }
3271 );
3272 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
3273 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
3274 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
3275 assert_eq!(
3277 parse_time_in_force("UNKNOWN"),
3278 TimeInForce::GoodUntilCancelled { post_only: false }
3279 );
3280 }
3281
3282 #[test]
3283 fn test_convert_order_kind_tif() {
3284 assert!(matches!(
3286 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
3287 Some((OrderPlaceTypeEnum::Market, None))
3288 ));
3289 assert!(matches!(
3290 convert_order_kind_tif(
3291 OrderKind::Limit,
3292 TimeInForce::GoodUntilCancelled { post_only: false }
3293 ),
3294 Some((
3295 OrderPlaceTypeEnum::Limit,
3296 Some(OrderPlaceTimeInForceEnum::Gtc)
3297 ))
3298 ));
3299 assert!(matches!(
3300 convert_order_kind_tif(
3301 OrderKind::Limit,
3302 TimeInForce::GoodUntilCancelled { post_only: true }
3303 ),
3304 Some((OrderPlaceTypeEnum::LimitMaker, None))
3305 ));
3306 assert!(matches!(
3307 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
3308 Some((
3309 OrderPlaceTypeEnum::Limit,
3310 Some(OrderPlaceTimeInForceEnum::Fok)
3311 ))
3312 ));
3313 assert!(matches!(
3314 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
3315 Some((
3316 OrderPlaceTypeEnum::Limit,
3317 Some(OrderPlaceTimeInForceEnum::Ioc)
3318 ))
3319 ));
3320 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
3323 }
3324
3325 #[test]
3326 fn test_convert_order_kind_tif_conditional() {
3327 use rust_decimal::Decimal;
3328
3329 assert!(matches!(
3330 convert_order_kind_tif(
3331 OrderKind::Stop {
3332 trigger_price: Decimal::from(100)
3333 },
3334 TimeInForce::GoodUntilCancelled { post_only: false }
3335 ),
3336 Some((OrderPlaceTypeEnum::StopLoss, None))
3337 ));
3338 assert!(matches!(
3339 convert_order_kind_tif(
3340 OrderKind::StopLimit {
3341 trigger_price: Decimal::from(100)
3342 },
3343 TimeInForce::GoodUntilCancelled { post_only: false }
3344 ),
3345 Some((
3346 OrderPlaceTypeEnum::StopLossLimit,
3347 Some(OrderPlaceTimeInForceEnum::Gtc)
3348 ))
3349 ));
3350 assert!(matches!(
3351 convert_order_kind_tif(
3352 OrderKind::TakeProfit {
3353 trigger_price: Decimal::from(150)
3354 },
3355 TimeInForce::ImmediateOrCancel
3356 ),
3357 Some((OrderPlaceTypeEnum::TakeProfit, None))
3358 ));
3359 assert!(matches!(
3360 convert_order_kind_tif(
3361 OrderKind::TakeProfitLimit {
3362 trigger_price: Decimal::from(150)
3363 },
3364 TimeInForce::FillOrKill
3365 ),
3366 Some((
3367 OrderPlaceTypeEnum::TakeProfitLimit,
3368 Some(OrderPlaceTimeInForceEnum::Fok)
3369 ))
3370 ));
3371 }
3372
3373 #[test]
3374 fn test_convert_order_kind_tif_trailing() {
3375 use rust_decimal::Decimal;
3376
3377 assert!(matches!(
3379 convert_order_kind_tif(
3380 OrderKind::TrailingStop {
3381 offset: Decimal::from(100),
3382 offset_type: TrailingOffsetType::BasisPoints,
3383 activation_price: None,
3384 },
3385 TimeInForce::GoodUntilCancelled { post_only: false }
3386 ),
3387 Some((OrderPlaceTypeEnum::StopLoss, None))
3388 ));
3389 assert!(matches!(
3390 convert_order_kind_tif(
3391 OrderKind::TrailingStop {
3392 offset: Decimal::from(5),
3393 offset_type: TrailingOffsetType::Percentage,
3394 activation_price: None,
3395 },
3396 TimeInForce::GoodUntilCancelled { post_only: false }
3397 ),
3398 Some((OrderPlaceTypeEnum::StopLoss, None))
3399 ));
3400
3401 assert!(
3403 convert_order_kind_tif(
3404 OrderKind::TrailingStop {
3405 offset: Decimal::from(10),
3406 offset_type: TrailingOffsetType::Absolute,
3407 activation_price: None,
3408 },
3409 TimeInForce::GoodUntilCancelled { post_only: false }
3410 )
3411 .is_none()
3412 );
3413
3414 assert!(
3416 convert_order_kind_tif(
3417 OrderKind::TrailingStopLimit {
3418 offset: Decimal::from(100),
3419 offset_type: TrailingOffsetType::BasisPoints,
3420 stop_price: Decimal::from(95),
3421 limit_offset: Decimal::from(10),
3422 },
3423 TimeInForce::GoodUntilCancelled { post_only: false }
3424 )
3425 .is_none()
3426 );
3427 }
3428
3429 #[test]
3432 fn a_fixed_limit_trailing_stop_converts_to_stop_loss_limit() {
3433 let fixed_limit = |offset_type| OrderKind::TrailingStopFixedLimit {
3434 offset: Decimal::from(100),
3435 offset_type,
3436 activation_price: Some(Decimal::from(50_000)),
3437 };
3438 assert!(matches!(
3439 convert_order_kind_tif(
3440 fixed_limit(TrailingOffsetType::BasisPoints),
3441 TimeInForce::GoodUntilCancelled { post_only: false }
3442 ),
3443 Some((
3444 OrderPlaceTypeEnum::StopLossLimit,
3445 Some(OrderPlaceTimeInForceEnum::Gtc)
3446 ))
3447 ));
3448 assert!(
3449 convert_order_kind_tif(
3450 fixed_limit(TrailingOffsetType::Absolute),
3451 TimeInForce::GoodUntilCancelled { post_only: false }
3452 )
3453 .is_none()
3454 );
3455 }
3456
3457 #[test]
3458 fn test_parse_binance_api_error() {
3459 let instrument = InstrumentNameExchange::new("BTCUSDT");
3460
3461 assert!(matches!(
3462 parse_binance_api_error("Insufficient balance".into(), &instrument),
3463 ApiError::BalanceInsufficient(None, _)
3464 ));
3465 assert!(matches!(
3466 parse_binance_api_error("Not enough funds".into(), &instrument),
3467 ApiError::BalanceInsufficient(None, _)
3468 ));
3469 assert_eq!(
3470 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
3471 ApiError::rate_limited(None)
3472 );
3473 assert_eq!(
3474 parse_binance_api_error("Error code -1015".into(), &instrument),
3475 ApiError::rate_limited(None)
3476 );
3477 assert_eq!(
3479 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
3480 ApiError::rate_limited(None)
3481 );
3482 assert_eq!(
3484 parse_binance_api_error("Error code -2011".into(), &instrument),
3485 ApiError::OrderAlreadyCancelled
3486 );
3487 assert!(matches!(
3489 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
3490 ApiError::OrderAlreadyCancelled
3491 ));
3492 assert!(matches!(
3494 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
3495 ApiError::OrderAlreadyCancelled
3496 ));
3497 assert!(matches!(
3499 parse_binance_api_error("Unknown order encountered".into(), &instrument),
3500 ApiError::OrderRejected(_)
3501 ));
3502 assert!(matches!(
3503 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
3504 ApiError::InstrumentInvalid(_, _)
3505 ));
3506 assert!(matches!(
3508 parse_binance_api_error(
3509 "Server-side response error (code -2010): Account has insufficient balance".into(),
3510 &instrument
3511 ),
3512 ApiError::BalanceInsufficient(None, _)
3513 ));
3514 assert!(matches!(
3515 parse_binance_api_error(
3516 "Server-side response error (code -2010): Order would trigger immediately.".into(),
3517 &instrument
3518 ),
3519 ApiError::OrderRejected(_)
3520 ));
3521 assert!(matches!(
3522 parse_binance_api_error("Some other error".into(), &instrument),
3523 ApiError::OrderRejected(_)
3524 ));
3525 }
3526
3527 #[test]
3528 fn test_contains_error_code_suffix_guard() {
3529 assert!(
3531 !contains_error_code("-20130", "-2013"),
3532 "-20130 should not match -2013"
3533 );
3534 assert!(
3535 !contains_error_code("-20131", "-2013"),
3536 "-20131 should not match -2013"
3537 );
3538 assert!(contains_error_code("-2013", "-2013"), "exact match");
3540 assert!(
3541 contains_error_code("Error -2013: text", "-2013"),
3542 "match with trailing text"
3543 );
3544 }
3545
3546 #[test]
3547 fn test_contains_error_code_prefix_guard() {
3548 assert!(
3551 !contains_error_code("1-2013", "-2013"),
3552 "1-2013 should not match -2013"
3553 );
3554 assert!(
3555 !contains_error_code("error 1-2013 text", "-2013"),
3556 "embedded 1-2013 should not match"
3557 );
3558 assert!(
3560 contains_error_code("code=-2013,", "-2013"),
3561 "=-2013 prefix should match"
3562 );
3563 assert!(
3564 contains_error_code(" -2013 ", "-2013"),
3565 "space prefix should match"
3566 );
3567 }
3568
3569 #[test]
3570 fn test_contains_error_code_second_occurrence_valid() {
3571 assert!(
3574 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
3575 "second valid occurrence must match when first fails digit guard"
3576 );
3577 assert!(
3579 contains_error_code("-2013 or -20130", "-2013"),
3580 "first valid occurrence must match when second is a longer code"
3581 );
3582 }
3583
3584 #[test]
3585 fn test_dedup_cache() {
3586 let cache = new_dedup_cache();
3587 let key = DedupKey {
3588 instrument: SmolStr::from("BTCUSDT"),
3589 id: SmolStr::from("12345"),
3590 kind: DedupEventKind::Trade,
3591 };
3592
3593 assert!(!is_duplicate(&cache, key));
3595 let key = DedupKey {
3597 instrument: SmolStr::from("BTCUSDT"),
3598 id: SmolStr::from("12345"),
3599 kind: DedupEventKind::Trade,
3600 };
3601 assert!(is_duplicate(&cache, key));
3602
3603 let key2 = DedupKey {
3605 instrument: SmolStr::from("BTCUSDT"),
3606 id: SmolStr::from("12345"),
3607 kind: DedupEventKind::OrderState {
3608 filled_quantity: Decimal::ZERO,
3609 },
3610 };
3611 assert!(!is_duplicate(&cache, key2));
3612 }
3613
3614 #[test]
3618 fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
3619 let cache = new_dedup_cache();
3620 let state = |filled: &str| DedupKey {
3621 instrument: SmolStr::from("BTCUSDT"),
3622 id: SmolStr::from("12345"),
3623 kind: DedupEventKind::OrderState {
3624 filled_quantity: Decimal::from_str(filled).unwrap(),
3625 },
3626 };
3627
3628 assert!(!is_duplicate(&cache, state("0")), "ack is new");
3630 assert!(
3631 !is_duplicate(&cache, state("1")),
3632 "first fill is not the ack"
3633 );
3634 assert!(
3635 !is_duplicate(&cache, state("2")),
3636 "second fill is not the first"
3637 );
3638
3639 assert!(
3642 is_duplicate(&cache, state("0")),
3643 "replayed ack is a duplicate"
3644 );
3645 assert!(
3646 is_duplicate(&cache, state("2")),
3647 "replayed fill is a duplicate"
3648 );
3649
3650 assert!(
3652 is_duplicate(&cache, state("2.00")),
3653 "2.00 must be the same key as 2"
3654 );
3655 }
3656
3657 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
3664 use crate::client::dedup::dedup_key_from_event;
3665
3666 let cache = new_dedup_cache();
3667 let mut delivered = Vec::new();
3668 let mut buf = Vec::new();
3669 for frame in frames {
3670 let _ = convert_user_data_events(frame, &mut buf);
3671 for ev in buf.drain(..) {
3672 if let Some(key) = dedup_key_from_event(&ev)
3673 && is_duplicate(&cache, key)
3674 {
3675 continue;
3676 }
3677 delivered.push(ev);
3678 }
3679 }
3680 delivered
3681 }
3682
3683 const WS_NEW: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport","s":"BTCUSDT",
3685 "i":12345,"c":"client-1","x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2",
3686 "p":"100","z":"0","T":1700000000000}}"#;
3687
3688 const WS_PARTIAL_FILL: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport",
3689 "s":"BTCUSDT","i":12345,"c":"client-1","x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY",
3690 "o":"LIMIT","f":"GTC","q":"2","p":"100","z":"1","l":"1","L":"100","t":555,"n":"0.1",
3691 "N":"USDT","T":1700000001000}}"#;
3692
3693 #[test]
3701 fn ws_fill_snapshot_survives_the_dedup_gate() {
3702 use crate::order::state::ActiveOrderState;
3703
3704 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
3705
3706 assert_eq!(
3707 delivered.len(),
3708 3,
3709 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
3710 );
3711
3712 let snapshots: Vec<Decimal> = delivered
3713 .iter()
3714 .filter_map(|ev| match &ev.kind {
3715 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
3716 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
3717 _ => None,
3718 },
3719 _ => None,
3720 })
3721 .collect();
3722
3723 assert_eq!(
3724 snapshots,
3725 vec![Decimal::ZERO, Decimal::ONE],
3726 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
3727 );
3728 }
3729
3730 #[test]
3733 fn ws_replayed_frame_is_still_deduplicated() {
3734 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
3735
3736 assert_eq!(
3737 delivered.len(),
3738 3,
3739 "re-delivering both frames must add nothing, got: {delivered:?}"
3740 );
3741 }
3742
3743 #[test]
3744 fn test_is_rate_limit_error() {
3745 assert!(is_rate_limit_error(&anyhow::anyhow!(
3747 "Too many requests. You are being rate-limited. Please slow down."
3748 )));
3749 assert!(is_rate_limit_error(&anyhow::anyhow!(
3751 "The IP address has been banned for exceeding rate limits. Contact support."
3752 )));
3753 assert!(is_rate_limit_error(&anyhow::anyhow!(
3755 "Error -1015: too many new orders"
3756 )));
3757 assert!(is_rate_limit_error(&anyhow::anyhow!(
3758 "Error -1003: too many requests"
3759 )));
3760 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
3762 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
3763 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
3764 assert!(
3766 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
3767 "-10150 should not match -1015"
3768 );
3769 assert!(
3770 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
3771 "-10030 should not match -1003"
3772 );
3773 }
3774
3775 #[test]
3776 fn test_classify_ws_order_error_recognises_venue_responses_only() {
3777 let instrument = InstrumentNameExchange::new("BTCUSDT");
3778
3779 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
3781 code: -2010,
3782 message: "Account has insufficient balance for requested action.".into(),
3783 retry_after: None,
3784 });
3785 assert!(
3786 matches!(
3787 classify_ws_order_error(&rejection, &instrument),
3788 Some(OrderError::Rejected(ApiError::BalanceInsufficient(None, _)))
3789 ),
3790 "ResponseError should be classified as a venue response"
3791 );
3792
3793 let transport = anyhow::anyhow!("connection reset by peer");
3795 assert!(
3796 classify_ws_order_error(&transport, &instrument).is_none(),
3797 "plain transport error should not be classified as a venue response"
3798 );
3799
3800 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
3802 assert!(
3803 classify_ws_order_error(&rate_limit, &instrument).is_none(),
3804 "rate-limit string error should not be classified as a venue response"
3805 );
3806 }
3807
3808 #[test]
3809 fn test_dedup_key_from_event_trade_includes_instrument() {
3810 use crate::order::id::{OrderId, StrategyId};
3811 use crate::trade::{AssetFees, Trade, TradeId};
3812 use chrono::Utc;
3813 use rust_decimal::Decimal;
3814 use rustrade_instrument::Side;
3815
3816 let instrument = InstrumentNameExchange::new("BTCUSDT");
3817 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3818 TradeId::new("9001"),
3819 OrderId::new("4242"),
3820 instrument.clone(),
3821 StrategyId::unknown(),
3822 Utc::now(),
3823 Side::Buy,
3824 Decimal::ZERO,
3825 Decimal::ZERO,
3826 None,
3827 AssetFees::new(
3828 AssetNameExchange::from("USDT"),
3829 Decimal::ZERO,
3830 Some(Decimal::ZERO),
3831 ),
3832 );
3833 let event =
3834 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
3835
3836 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
3837 assert_eq!(key.kind, DedupEventKind::Trade);
3838 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
3839 assert_eq!(
3840 key.instrument.as_str(),
3841 "BTCUSDT",
3842 "key.instrument should be the symbol"
3843 );
3844
3845 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
3847 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3848 TradeId::new("9001"),
3849 OrderId::new("7777"),
3850 instrument2,
3851 StrategyId::unknown(),
3852 Utc::now(),
3853 Side::Buy,
3854 Decimal::ZERO,
3855 Decimal::ZERO,
3856 None,
3857 AssetFees::new(
3858 AssetNameExchange::from("USDT"),
3859 Decimal::ZERO,
3860 Some(Decimal::ZERO),
3861 ),
3862 );
3863 let event2 =
3864 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
3865 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
3866 assert_ne!(
3867 key, key2,
3868 "same trade ID on different symbols must produce distinct keys"
3869 );
3870 }
3871
3872 fn make_balance_inner(
3877 asset: &str,
3878 free: &str,
3879 locked: &str,
3880 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3881 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3882 a: Some(asset.to_string()),
3883 f: Some(free.to_string()),
3884 l: Some(locked.to_string()),
3885 }
3886 }
3887
3888 #[test]
3889 fn test_convert_account_position_happy_path() {
3890 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3891 u: Some(1_700_000_000_000),
3892 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
3893 ..Default::default()
3894 };
3895 let mut buf = Vec::new();
3896 convert_account_position(position, &mut buf);
3897
3898 assert_eq!(buf.len(), 1);
3899 match &buf[0].kind {
3900 AccountEventKind::BalanceStreamUpdate(snap) => {
3901 let update = &snap.0;
3902 assert_eq!(update.asset.as_ref(), "BTC");
3903 let expected_free = Decimal::from_str("1.5").unwrap();
3905 let expected_locked = Decimal::from_str("0.5").unwrap();
3906 assert_eq!(update.update.free, expected_free);
3907 assert_eq!(update.update.locked, expected_locked);
3908 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3909 }
3910 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3911 }
3912 }
3913
3914 #[test]
3916 fn test_convert_account_position_is_at_u_else_at_e() {
3917 let time_of = |u, e| {
3918 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3919 u,
3920 e_uppercase: e,
3921 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3922 };
3923 let mut buf = Vec::new();
3924 convert_account_position(position, &mut buf);
3925 match &buf[..] {
3926 [event] => match &event.kind {
3927 AccountEventKind::BalanceStreamUpdate(snap) => snap.0.time_exchange,
3928 other => panic!("expected BalanceStreamUpdate, got {other:?}"),
3929 },
3930 other => panic!("expected one event, got {other:?}"),
3931 }
3932 };
3933 let at = |ms| Utc.timestamp_millis_opt(ms).unwrap();
3934 assert_eq!(time_of(Some(2_000), Some(3_000)), at(2_000));
3935 assert_eq!(time_of(None, Some(3_000)), at(3_000));
3936 }
3937
3938 #[test]
3939 fn test_convert_account_position_missing_asset_name_skipped() {
3940 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3941 u: Some(1_700_000_000_000),
3942 b_uppercase: Some(vec![
3943 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3944 a: None, f: Some("1.0".to_string()),
3946 l: Some("0.0".to_string()),
3947 },
3948 make_balance_inner("USDT", "100.0", "0.0"), ]),
3950 ..Default::default()
3951 };
3952 let mut buf = Vec::new();
3953 convert_account_position(position, &mut buf);
3954 assert_eq!(buf.len(), 1);
3956 match &buf[0].kind {
3957 AccountEventKind::BalanceStreamUpdate(snap) => {
3958 assert_eq!(snap.0.asset.as_ref(), "USDT");
3959 }
3960 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3961 }
3962 }
3963
3964 #[test]
3965 fn test_convert_account_position_unparseable_free_skipped() {
3966 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3967 u: Some(1_700_000_000_000),
3968 b_uppercase: Some(vec![
3969 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3970 a: Some("BTC".to_string()),
3971 f: Some("not-a-number".to_string()),
3972 l: Some("0.0".to_string()),
3973 },
3974 make_balance_inner("ETH", "1.0", "0.0"), ]),
3976 ..Default::default()
3977 };
3978 let mut buf = Vec::new();
3979 convert_account_position(position, &mut buf);
3980 assert_eq!(buf.len(), 1);
3982 match &buf[0].kind {
3983 AccountEventKind::BalanceStreamUpdate(snap) => {
3984 assert_eq!(snap.0.asset.as_ref(), "ETH");
3985 }
3986 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3987 }
3988 }
3989
3990 #[test]
3991 fn test_convert_account_position_empty_balances() {
3992 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3993 u: Some(1_700_000_000_000),
3994 b_uppercase: Some(vec![]),
3995 ..Default::default()
3996 };
3997 let mut buf = Vec::new();
3998 convert_account_position(position, &mut buf);
3999 assert!(
4000 buf.is_empty(),
4001 "empty balance list should produce no events"
4002 );
4003 }
4004
4005 #[test]
4008 fn an_account_position_without_a_venue_time_is_dropped() {
4009 let converted = |u, e_uppercase| {
4010 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4011 u,
4012 e_uppercase,
4013 b_uppercase: Some(vec![make_balance_inner("USDT", "100.0", "0.0")]),
4014 };
4015 let mut buf = Vec::new();
4016 convert_account_position(position, &mut buf);
4017 buf.len()
4018 };
4019 assert_eq!(converted(None, None), 0);
4020 assert_eq!(converted(Some(i64::MAX), None), 0);
4021 assert_eq!(converted(Some(1_700_000_000_000), None), 1);
4022 assert_eq!(converted(None, Some(1_700_000_000_000)), 1);
4023 }
4024
4025 #[test]
4026 fn test_convert_account_position_b_field_none() {
4027 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4028 u: Some(1_700_000_000_000),
4029 b_uppercase: None, ..Default::default()
4031 };
4032 let mut buf = Vec::new();
4033 convert_account_position(position, &mut buf);
4034 assert!(buf.is_empty(), "None B field should produce no events");
4035 }
4036
4037 fn make_base_report() -> ExecutionReport {
4041 ExecutionReport {
4042 symbol: Some("BTCUSDT".to_string()),
4043 order_id: Some(12345),
4044 client_order_id: Some("client-1".to_string()),
4045 transaction_time: Some(1_700_000_000_000),
4046 ..Default::default()
4047 }
4048 }
4049
4050 fn convert(report: ExecutionReport) -> Vec<UnindexedAccountEvent> {
4052 let mut buf = Vec::new();
4053 convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
4054 buf
4055 }
4056
4057 fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
4062 assert!(
4063 events.len() <= 1,
4064 "expected at most a single event, got: {events:?}"
4065 );
4066 events.pop()
4067 }
4068
4069 #[test]
4070 fn test_convert_execution_report_new() {
4071 let report = ExecutionReport {
4072 execution_type: Some("NEW".to_string()),
4073 side: Some("BUY".to_string()),
4074 order_type: Some("LIMIT".to_string()),
4075 price: Some("50000.00".to_string()),
4076 order_quantity: Some("0.01".to_string()),
4077 time_in_force: Some("GTC".to_string()),
4078 cumulative_filled_quantity: Some("0".to_string()),
4079 ..make_base_report()
4080 };
4081
4082 let event = sole_event(convert(report)).expect("NEW event should produce Some");
4083 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
4084 match &event.kind {
4085 AccountEventKind::OrderSnapshot(snap) => {
4086 let order = &snap.0;
4087 assert_eq!(order.side, Side::Buy);
4088 assert_eq!(order.kind, OrderKind::Limit);
4089 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
4090 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
4091 assert_eq!(order.key.cid.0.as_str(), "client-1");
4092 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
4093 }
4094 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
4095 }
4096 }
4097
4098 #[test]
4101 fn a_trailing_stop_loss_limit_report_keeps_its_limit_and_activation_price() {
4102 let report = ExecutionReport {
4103 execution_type: Some("NEW".to_string()),
4104 side: Some("SELL".to_string()),
4105 order_type: Some("STOP_LOSS_LIMIT".to_string()),
4106 price: Some("49000.00".to_string()),
4107 stop_price: Some("50000.00".to_string()),
4108 trailing_delta: Some(WireInt::Int(25)),
4109 order_quantity: Some("0.01".to_string()),
4110 time_in_force: Some("GTC".to_string()),
4111 cumulative_filled_quantity: Some("0".to_string()),
4112 ..make_base_report()
4113 };
4114
4115 let event = sole_event(convert(report)).expect("NEW event should produce Some");
4116 let AccountEventKind::OrderSnapshot(snap) = &event.kind else {
4117 panic!("NEW should yield OrderSnapshot, got {:?}", event.kind);
4118 };
4119 assert_eq!(
4120 snap.0.kind,
4121 OrderKind::TrailingStopFixedLimit {
4122 offset: Decimal::from(25),
4123 offset_type: TrailingOffsetType::BasisPoints,
4124 activation_price: Some(Decimal::from(50_000)),
4125 }
4126 );
4127 assert_eq!(snap.0.price, Some(Decimal::from(49_000)));
4128 }
4129
4130 #[test]
4134 fn a_report_without_an_order_type_has_no_order_snapshot() {
4135 let new = ExecutionReport {
4136 execution_type: Some("NEW".to_string()),
4137 side: Some("BUY".to_string()),
4138 price: Some("50000.00".to_string()),
4139 order_quantity: Some("0.01".to_string()),
4140 ..make_base_report()
4141 };
4142 assert!(convert(new).is_empty());
4143
4144 let fill = ExecutionReport {
4145 execution_type: Some("TRADE".to_string()),
4146 order_status: Some("PARTIALLY_FILLED".to_string()),
4147 side: Some("BUY".to_string()),
4148 trade_id: Some(9999),
4149 last_executed_price: Some("50000.00".to_string()),
4150 last_executed_quantity: Some("0.005".to_string()),
4151 commission_amount: Some("0".to_string()),
4152 cumulative_filled_quantity: Some("0.005".to_string()),
4153 order_type: Some("LIMIT".to_string()),
4154 price: Some("50000.00".to_string()),
4155 order_quantity: Some("0.01".to_string()),
4156 ..make_base_report()
4157 };
4158 let kinds = |report| {
4159 convert(report)
4160 .into_iter()
4161 .map(|event| match event.kind {
4162 AccountEventKind::Trade(_) => "trade",
4163 AccountEventKind::OrderSnapshot(_) => "snapshot",
4164 _ => "other",
4165 })
4166 .collect::<Vec<_>>()
4167 };
4168 assert_eq!(kinds(fill.clone()), ["trade", "snapshot"]);
4169 let untyped = ExecutionReport {
4170 order_type: None,
4171 ..fill
4172 };
4173 assert_eq!(kinds(untyped), ["trade"]);
4174 }
4175
4176 #[test]
4177 fn test_convert_execution_report_trade() {
4178 let report = ExecutionReport {
4181 execution_type: Some("TRADE".to_string()),
4182 side: Some("BUY".to_string()),
4183 trade_id: Some(9999),
4184 last_executed_price: Some("50000.00".to_string()),
4185 last_executed_quantity: Some("0.01".to_string()),
4186 commission_amount: Some("0.000001".to_string()),
4187 ..make_base_report()
4188 };
4189
4190 let event = sole_event(convert(report)).expect("TRADE event should produce Some");
4191 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
4192 match &event.kind {
4193 AccountEventKind::Trade(trade) => {
4194 assert_eq!(trade.side, Side::Buy);
4195 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
4196 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
4197 assert_eq!(trade.id.0.as_str(), "9999");
4198 assert_eq!(trade.order_id.0.as_str(), "12345");
4199 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
4200 }
4201 other => panic!("TRADE should yield Trade, got {other:?}"),
4202 }
4203 }
4204
4205 #[test]
4208 fn a_fills_time_is_its_transaction_time_then_its_event_time_then_this_hosts() {
4209 let trade = |transaction_time, event_time| {
4210 let report = ExecutionReport {
4211 execution_type: Some("TRADE".to_string()),
4212 side: Some("BUY".to_string()),
4213 trade_id: Some(9999),
4214 last_executed_price: Some("50000.00".to_string()),
4215 last_executed_quantity: Some("0.01".to_string()),
4216 transaction_time,
4217 event_time,
4218 ..make_base_report()
4219 };
4220 match sole_event(convert(report)).map(|event| event.kind) {
4221 Some(AccountEventKind::Trade(trade)) => trade,
4222 other => panic!("TRADE should yield Trade, got {other:?}"),
4223 }
4224 };
4225 let at = |ms| Utc.timestamp_millis_opt(ms).unwrap();
4226
4227 let fill = trade(Some(1_700_000_000_000), Some(1_700_000_000_005));
4228 assert_eq!(
4229 (fill.time_exchange, fill.time_source),
4230 (at(1_700_000_000_000), crate::trade::TimeSource::Venue)
4231 );
4232 for transaction_time in [None, Some(i64::MAX)] {
4233 let fill = trade(transaction_time, Some(1_700_000_000_005));
4234 assert_eq!(
4235 (fill.time_exchange, fill.time_source),
4236 (at(1_700_000_000_005), crate::trade::TimeSource::Venue),
4237 "{transaction_time:?}"
4238 );
4239 }
4240 let before = Utc::now();
4241 let fill = trade(None, None);
4242 assert_eq!(fill.time_source, crate::trade::TimeSource::Host);
4243 assert!((before..=Utc::now()).contains(&fill.time_exchange));
4244 }
4245
4246 fn trade_events(
4248 events: Vec<UnindexedAccountEvent>,
4249 ) -> (
4250 Trade<AssetNameExchange, InstrumentNameExchange>,
4251 crate::order::Order<
4252 ExchangeId,
4253 InstrumentNameExchange,
4254 OrderState<AssetNameExchange, InstrumentNameExchange>,
4255 >,
4256 ) {
4257 let [first, second]: [UnindexedAccountEvent; 2] = events
4258 .try_into()
4259 .expect("a fill report must produce an execution and an order snapshot");
4260 let AccountEventKind::Trade(trade) = first.kind else {
4261 panic!("first event must be the Trade, got {:?}", first.kind);
4262 };
4263 let AccountEventKind::OrderSnapshot(snap) = second.kind else {
4264 panic!(
4265 "second event must be the OrderSnapshot, got {:?}",
4266 second.kind
4267 );
4268 };
4269 (trade, snap.0)
4270 }
4271
4272 #[test]
4276 fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
4277 let report = ExecutionReport {
4278 execution_type: Some("TRADE".to_string()),
4279 order_status: Some("PARTIALLY_FILLED".to_string()),
4280 side: Some("BUY".to_string()),
4281 order_type: Some("LIMIT".to_string()),
4282 price: Some("50000.00".to_string()),
4283 order_quantity: Some("0.01".to_string()),
4284 time_in_force: Some("GTC".to_string()),
4285 trade_id: Some(9999),
4286 last_executed_price: Some("50000.00".to_string()),
4287 last_executed_quantity: Some("0.004".to_string()),
4288 cumulative_filled_quantity: Some("0.004".to_string()),
4289 ..make_base_report()
4290 };
4291
4292 let (trade, order) = trade_events(convert(report));
4293 assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
4294
4295 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4296 else {
4297 panic!("expected an Open snapshot, got {:?}", order.state);
4298 };
4299 assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
4300 assert_eq!(
4301 open.quantity_remaining(order.quantity),
4302 Decimal::from_str("0.006").unwrap()
4303 );
4304 }
4305
4306 #[test]
4309 fn a_filled_report_reports_nothing_left_to_fill() {
4310 let report = ExecutionReport {
4311 execution_type: Some("TRADE".to_string()),
4312 order_status: Some("FILLED".to_string()),
4313 side: Some("BUY".to_string()),
4314 order_type: Some("LIMIT".to_string()),
4315 price: Some("50000.00".to_string()),
4316 order_quantity: Some("0.01".to_string()),
4317 time_in_force: Some("GTC".to_string()),
4318 trade_id: Some(10_000),
4319 last_executed_price: Some("50000.00".to_string()),
4320 last_executed_quantity: Some("0.006".to_string()),
4321 cumulative_filled_quantity: Some("0.01".to_string()),
4322 ..make_base_report()
4323 };
4324
4325 let (_trade, order) = trade_events(convert(report));
4326 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
4327 else {
4328 panic!("expected an Open snapshot, got {:?}", order.state);
4329 };
4330 assert!(
4331 open.quantity_remaining(order.quantity).is_zero(),
4332 "a completed order must report nothing left to fill so the engine retires it"
4333 );
4334 }
4335
4336 #[test]
4340 fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
4341 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
4342 let report = ExecutionReport {
4343 execution_type: Some("TRADE".to_string()),
4344 order_status: Some(status.to_string()),
4345 side: Some("BUY".to_string()),
4346 order_type: Some("LIMIT".to_string()),
4347 price: Some("50000.00".to_string()),
4348 order_quantity: Some("0.01".to_string()),
4349 time_in_force: Some("GTC".to_string()),
4350 trade_id: Some(9999),
4351 last_executed_price: Some("50000.00".to_string()),
4352 last_executed_quantity: Some("0.004".to_string()),
4353 cumulative_filled_quantity: Some("0.004".to_string()),
4354 ..make_base_report()
4355 };
4356 let events = convert(report);
4357 assert_eq!(
4358 events.len(),
4359 1,
4360 "status {status} must produce the execution alone, got: {events:?}"
4361 );
4362 assert!(
4363 matches!(events[0].kind, AccountEventKind::Trade(_)),
4364 "the execution must still be reported for status {status}"
4365 );
4366 }
4367 }
4368
4369 #[test]
4370 fn test_convert_execution_report_trade_missing_last_price() {
4371 let report = ExecutionReport {
4373 execution_type: Some("TRADE".to_string()),
4374 side: Some("BUY".to_string()),
4375 trade_id: Some(9999),
4376 last_executed_price: None, last_executed_quantity: Some("0.01".to_string()),
4378 ..make_base_report()
4379 };
4380 assert!(
4381 sole_event(convert(report)).is_none(),
4382 "missing last price (L) should return None"
4383 );
4384 }
4385
4386 #[test]
4387 fn test_convert_execution_report_trade_missing_last_qty() {
4388 let report = ExecutionReport {
4390 execution_type: Some("TRADE".to_string()),
4391 side: Some("BUY".to_string()),
4392 trade_id: Some(9999),
4393 last_executed_price: Some("50000.00".to_string()),
4394 last_executed_quantity: None, ..make_base_report()
4396 };
4397 assert!(
4398 sole_event(convert(report)).is_none(),
4399 "missing last qty (l) should return None"
4400 );
4401 }
4402
4403 #[test]
4404 fn test_convert_execution_report_canceled() {
4405 let report = ExecutionReport {
4406 execution_type: Some("CANCELED".to_string()),
4407 ..make_base_report()
4408 };
4409
4410 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
4411 assert!(
4412 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
4413 "CANCELED should yield OrderCancelled with Ok state"
4414 );
4415 }
4416
4417 #[test]
4420 fn a_cancel_report_is_keyed_by_the_cancelled_orders_id() {
4421 let cancelled_cid = |orig: Option<&str>| {
4422 let report = ExecutionReport {
4423 execution_type: Some("CANCELED".to_string()),
4424 client_order_id: Some("cancel-request".to_string()),
4425 orig_client_order_id: orig.map(str::to_string),
4426 ..make_base_report()
4427 };
4428 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
4429 let AccountEventKind::OrderCancelled(response) = event.kind else {
4430 panic!("expected OrderCancelled, got {:?}", event.kind);
4431 };
4432 response.key.cid
4433 };
4434
4435 assert_eq!(
4436 cancelled_cid(Some("client-1")),
4437 ClientOrderId::new("client-1")
4438 );
4439 assert_eq!(
4440 cancelled_cid(Some("")),
4441 ClientOrderId::new("cancel-request")
4442 );
4443 assert_eq!(cancelled_cid(None), ClientOrderId::new("cancel-request"));
4444 }
4445
4446 #[test]
4449 fn only_a_cancel_report_reads_the_orders_id_from_c_upper() {
4450 for exec_type in ["NEW", "TRADE", "EXPIRED", "TRADE_PREVENTION", "REPLACED"] {
4451 let report = ExecutionReport {
4452 execution_type: Some(exec_type.to_string()),
4453 orig_client_order_id: Some("client-0".to_string()),
4454 ..make_base_report()
4455 };
4456 assert_eq!(report.order_client_id(), Some("client-1"), "{exec_type}");
4457 }
4458 }
4459
4460 #[test]
4462 fn a_cancel_report_carries_its_fill_or_none() {
4463 let cancelled = |z: Option<&str>| {
4464 let report = ExecutionReport {
4465 execution_type: Some("CANCELED".to_string()),
4466 cumulative_filled_quantity: z.map(str::to_string),
4467 ..make_base_report()
4468 };
4469 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
4470 let AccountEventKind::OrderCancelled(response) = event.kind else {
4471 panic!("expected OrderCancelled, got {:?}", event.kind);
4472 };
4473 let Ok(cancelled) = response.state else {
4474 panic!("expected Ok, got {:?}", response.state);
4475 };
4476 cancelled.filled_quantity
4477 };
4478
4479 assert_eq!(cancelled(Some("0.5")), Some(Decimal::new(5, 1)));
4480 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
4481 assert_eq!(cancelled(None), None);
4482 assert_eq!(cancelled(Some("not a number")), None);
4483 }
4484
4485 #[test]
4486 fn test_convert_execution_report_expired() {
4487 let report = ExecutionReport {
4488 execution_type: Some("EXPIRED".to_string()),
4489 ..make_base_report()
4490 };
4491 let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
4492 assert!(
4493 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
4494 "EXPIRED should yield OrderCancelled with Ok state"
4495 );
4496 }
4497
4498 #[test]
4501 fn a_trade_prevention_report_removes_the_order_only_once_it_has_ended() {
4502 let prevented = |status: Option<&str>| {
4503 convert(ExecutionReport {
4504 execution_type: Some("TRADE_PREVENTION".to_string()),
4505 order_status: status.map(str::to_string),
4506 ..make_base_report()
4507 })
4508 };
4509
4510 for ended in ["EXPIRED_IN_MATCH", "EXPIRED", "FILLED"] {
4511 let event = sole_event(prevented(Some(ended))).expect(ended);
4512 let AccountEventKind::OrderCancelled(response) = event.kind else {
4513 panic!("{ended}: expected OrderCancelled, got {:?}", event.kind);
4514 };
4515 assert_eq!(response.key.cid, ClientOrderId::new("client-1"), "{ended}");
4516 let Ok(cancelled) = response.state else {
4517 panic!("{ended}: expected Ok, got {:?}", response.state);
4518 };
4519 assert_eq!(cancelled.id, OrderId::new("12345"), "{ended}");
4520 }
4521 for not_ended in [
4522 Some("NEW"),
4523 Some("PARTIALLY_FILLED"),
4524 Some("SOME_FUTURE_STATUS"),
4525 None,
4526 ] {
4527 assert!(prevented(not_ended).is_empty(), "{not_ended:?}");
4528 }
4529 }
4530
4531 #[test]
4533 fn expired_in_match_as_an_execution_type_produces_nothing() {
4534 let report = ExecutionReport {
4535 execution_type: Some("EXPIRED_IN_MATCH".to_string()),
4536 ..make_base_report()
4537 };
4538 assert!(convert(report).is_empty());
4539 }
4540
4541 #[test]
4542 fn test_convert_execution_report_rejected() {
4543 let report = ExecutionReport {
4544 execution_type: Some("REJECTED".to_string()),
4545 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
4546 ..make_base_report()
4547 };
4548
4549 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
4550 assert!(
4551 matches!(
4552 event.kind,
4553 AccountEventKind::OrderCancelled(ref r) if matches!(
4554 &r.state,
4555 Err(OrderError::Rejected(ApiError::BalanceInsufficient(None, reason)))
4556 if reason == "INSUFFICIENT_FUNDS"
4557 )
4558 ),
4559 "a REJECTED shortfall should go through the classifier, got {:?}",
4560 event.kind
4561 );
4562 }
4563
4564 #[test]
4565 fn test_convert_execution_report_rejected_for_another_reason() {
4566 let report = ExecutionReport {
4567 execution_type: Some("REJECTED".to_string()),
4568 reject_reason: Some("WOULD_MATCH_IMMEDIATELY".to_string()),
4569 ..make_base_report()
4570 };
4571
4572 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
4573 assert!(
4574 matches!(
4575 event.kind,
4576 AccountEventKind::OrderCancelled(ref r) if matches!(
4577 &r.state,
4578 Err(OrderError::Rejected(ApiError::OrderRejected(reason)))
4579 if reason == "WOULD_MATCH_IMMEDIATELY"
4580 )
4581 ),
4582 "an unrecognised REJECTED reason should stay OrderRejected, got {:?}",
4583 event.kind
4584 );
4585 }
4586
4587 #[test]
4588 fn test_convert_execution_report_missing_exec_type() {
4589 let report = ExecutionReport {
4591 symbol: Some("BTCUSDT".to_string()),
4592 ..Default::default()
4593 };
4594 assert!(
4595 sole_event(convert(report)).is_none(),
4596 "missing execution type should return None"
4597 );
4598 }
4599
4600 #[test]
4601 fn test_convert_execution_report_missing_symbol() {
4602 let report = ExecutionReport {
4604 execution_type: Some("NEW".to_string()),
4605 ..Default::default()
4606 };
4607 assert!(
4608 sole_event(convert(report)).is_none(),
4609 "missing symbol should return None"
4610 );
4611 }
4612
4613 #[test]
4614 fn test_convert_execution_report_missing_order_id() {
4615 let report = ExecutionReport {
4617 execution_type: Some("NEW".to_string()),
4618 symbol: Some("BTCUSDT".to_string()),
4619 order_id: None,
4620 ..Default::default()
4621 };
4622 assert!(
4623 sole_event(convert(report)).is_none(),
4624 "missing orderId should return None"
4625 );
4626 }
4627
4628 #[test]
4629 fn test_convert_execution_report_trade_missing_trade_id() {
4630 let report = ExecutionReport {
4632 execution_type: Some("TRADE".to_string()),
4633 trade_id: None,
4634 ..make_base_report()
4635 };
4636 assert!(
4637 sole_event(convert(report)).is_none(),
4638 "TRADE event missing tradeId should return None"
4639 );
4640 }
4641
4642 #[test]
4644 fn an_amend_report_produces_nothing() {
4645 for exec_type in ["REPLACED", "REPLACE"] {
4646 let report = ExecutionReport {
4647 execution_type: Some(exec_type.to_string()),
4648 order_status: Some("NEW".to_string()),
4649 orig_client_order_id: Some("client-0".to_string()),
4650 ..make_base_report()
4651 };
4652 assert!(convert(report).is_empty(), "{exec_type}");
4653 }
4654 }
4655
4656 fn make_balance(
4661 asset: &str,
4662 free: &str,
4663 locked: &str,
4664 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4665 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4666 asset: Some(asset.to_string()),
4667 free: Some(free.to_string()),
4668 locked: Some(locked.to_string()),
4669 }
4670 }
4671
4672 #[test]
4673 fn test_filter_balances_empty_assets_returns_all() {
4674 let balances = vec![
4675 make_balance("BTC", "1.0", "0.0"),
4676 make_balance("USDT", "500.0", "50.0"),
4677 ];
4678 let result = filter_and_convert_balances(balances, &[], Utc::now());
4679 assert_eq!(result.len(), 2, "empty filter should return all balances");
4680 }
4681
4682 #[test]
4683 fn test_filter_balances_matching_asset_returned() {
4684 let balances = vec![
4685 make_balance("BTC", "1.5", "0.5"),
4686 make_balance("ETH", "10.0", "0.0"),
4687 ];
4688 let assets = vec![AssetNameExchange::new("BTC")];
4689 let result = filter_and_convert_balances(balances, &assets, Utc::now());
4690 assert_eq!(result.len(), 1);
4691 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
4692 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
4694 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
4695 }
4696
4697 #[test]
4698 fn test_filter_balances_non_matching_asset_filtered_out() {
4699 let balances = vec![
4700 make_balance("BTC", "1.0", "0.0"),
4701 make_balance("ETH", "2.0", "0.0"),
4702 ];
4703 let assets = vec![AssetNameExchange::new("USDT")];
4704 let result = filter_and_convert_balances(balances, &assets, Utc::now());
4705 assert!(
4706 result.is_empty(),
4707 "non-matching asset should be filtered out"
4708 );
4709 }
4710
4711 #[test]
4712 fn test_filter_balances_missing_asset_field_skipped() {
4713 let balances = vec![
4714 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4715 asset: None,
4716 free: Some("1.0".to_string()),
4717 locked: Some("0.0".to_string()),
4718 },
4719 make_balance("USDT", "100.0", "0.0"),
4720 ];
4721 let result = filter_and_convert_balances(balances, &[], Utc::now());
4722 assert_eq!(
4723 result.len(),
4724 1,
4725 "entry with missing asset should be skipped"
4726 );
4727 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
4728 }
4729
4730 #[test]
4731 fn test_filter_balances_unparseable_free_skipped() {
4732 let balances = vec![
4733 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
4734 asset: Some("BTC".to_string()),
4735 free: Some("not-a-number".to_string()),
4736 locked: Some("0.0".to_string()),
4737 },
4738 ];
4739 let result = filter_and_convert_balances(balances, &[], Utc::now());
4740 assert!(
4741 result.is_empty(),
4742 "unparseable free field should be skipped"
4743 );
4744 }
4745
4746 #[test]
4747 fn test_filter_balances_zero_balance_included() {
4748 let balances = vec![
4751 make_balance("BTC", "0.00000000", "0.00000000"),
4752 make_balance("USDT", "100.0", "0.0"),
4753 ];
4754 let result = filter_and_convert_balances(balances, &[], Utc::now());
4755 assert_eq!(result.len(), 2, "zero-balance entries must be included");
4756 let btc = result
4757 .iter()
4758 .find(|b| b.asset == AssetNameExchange::new("BTC"))
4759 .unwrap();
4760 assert_eq!(btc.balance.total, Decimal::ZERO);
4761 assert_eq!(btc.balance.free, Decimal::ZERO);
4762 }
4763
4764 #[test]
4765 fn test_filter_balances_duplicate_assets_in_response() {
4766 let balances = vec![
4770 make_balance("BTC", "1.0", "0.0"),
4771 make_balance("BTC", "2.0", "0.0"),
4772 ];
4773 let result = filter_and_convert_balances(balances, &[], Utc::now());
4774 assert_eq!(
4775 result.len(),
4776 2,
4777 "duplicate asset entries produce two AssetBalance entries"
4778 );
4779 }
4780
4781 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
4786 binance_sdk::spot::rest_api::MyTradesResponseInner {
4787 id: Some(9001),
4788 order_id: Some(4242),
4789 price: Some("50000.00".to_string()),
4790 qty: Some("0.01".to_string()),
4791 commission: Some("0.05".to_string()),
4792 time: Some(1_700_000_000_000),
4793 is_buyer: Some(true),
4794 ..Default::default()
4795 }
4796 }
4797
4798 #[test]
4799 fn test_convert_my_trade_happy_path() {
4800 let instrument = InstrumentNameExchange::new("BTCUSDT");
4801 let trade =
4802 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
4803 assert_eq!(trade.instrument, instrument);
4804 assert_eq!(trade.side, Side::Buy);
4805 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
4806 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
4807 }
4808
4809 #[test]
4812 fn a_rest_trade_without_a_time_is_at_this_hosts_time() {
4813 let instrument = InstrumentNameExchange::new("BTCUSDT");
4814 let trade = convert_my_trade(&make_base_trade(), &instrument).unwrap();
4815 assert_eq!(
4816 (trade.time_exchange, trade.time_source),
4817 (
4818 Utc.timestamp_millis_opt(1_700_000_000_000).unwrap(),
4819 TimeSource::Venue
4820 )
4821 );
4822 let before = Utc::now();
4823 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4824 time: None,
4825 ..make_base_trade()
4826 };
4827 let trade = convert_my_trade(&t, &instrument).unwrap();
4828 assert_eq!(trade.time_source, TimeSource::Host);
4829 assert!((before..=Utc::now()).contains(&trade.time_exchange));
4830 }
4831
4832 #[test]
4833 fn test_convert_my_trade_sell_side() {
4834 let instrument = InstrumentNameExchange::new("BTCUSDT");
4835 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4836 is_buyer: Some(false),
4837 ..make_base_trade()
4838 };
4839 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
4840 assert_eq!(trade.side, Side::Sell);
4841 }
4842
4843 #[test]
4844 fn test_convert_my_trade_missing_id_returns_none() {
4845 let instrument = InstrumentNameExchange::new("BTCUSDT");
4846 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4847 id: None,
4848 ..make_base_trade()
4849 };
4850 assert!(
4851 convert_my_trade(&t, &instrument).is_none(),
4852 "missing id should return None"
4853 );
4854 }
4855
4856 #[test]
4857 fn test_convert_my_trade_missing_order_id_returns_none() {
4858 let instrument = InstrumentNameExchange::new("BTCUSDT");
4859 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4860 order_id: None,
4861 ..make_base_trade()
4862 };
4863 assert!(
4864 convert_my_trade(&t, &instrument).is_none(),
4865 "missing orderId should return None"
4866 );
4867 }
4868
4869 #[test]
4870 fn test_convert_my_trade_missing_is_buyer_returns_none() {
4871 let instrument = InstrumentNameExchange::new("BTCUSDT");
4872 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4873 is_buyer: None,
4874 ..make_base_trade()
4875 };
4876 assert!(
4877 convert_my_trade(&t, &instrument).is_none(),
4878 "missing isBuyer should return None"
4879 );
4880 }
4881
4882 #[test]
4883 fn test_convert_my_trade_commission_none_defaults_to_zero() {
4884 let instrument = InstrumentNameExchange::new("BTCUSDT");
4885 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4886 commission: None,
4887 ..make_base_trade()
4888 };
4889 let trade =
4890 convert_my_trade(&t, &instrument).expect("None commission should still convert");
4891 assert_eq!(trade.fees.fees, Decimal::ZERO);
4892 }
4893
4894 fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4899 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4900 order_id: Some(12345),
4901 client_order_id: Some("cid-abc".to_string()),
4902 side: Some("BUY".to_string()),
4903 r#type: Some("LIMIT".to_string()),
4904 price: Some("50000.00".to_string()),
4905 orig_qty: Some("0.01".to_string()),
4906 executed_qty: Some("0.0".to_string()),
4907 time_in_force: Some("GTC".to_string()),
4908 time: Some(1_700_000_000_000),
4909 status: Some("NEW".to_string()),
4910 ..Default::default()
4911 }
4912 }
4913
4914 #[test]
4915 fn test_convert_open_order_all_orders_response_converts_identically() {
4916 let instrument = InstrumentNameExchange::new("BTCUSDT");
4923 let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4924 order_id: Some(12345),
4925 client_order_id: Some("cid-abc".to_string()),
4926 side: Some("BUY".to_string()),
4927 r#type: Some("LIMIT".to_string()),
4928 price: Some("50000.00".to_string()),
4929 orig_qty: Some("0.01".to_string()),
4930 executed_qty: Some("0.0".to_string()),
4931 time_in_force: Some("GTC".to_string()),
4932 time: Some(1_700_000_000_000),
4933 status: Some("NEW".to_string()),
4934 ..Default::default()
4935 };
4936 let from_all_orders =
4937 convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument, &|_| None)
4938 .expect("valid order should convert");
4939 let from_open_orders = convert_open_order(
4940 &make_base_open_order(),
4941 ExchangeId::BinanceSpot,
4942 &instrument,
4943 &|_| None,
4944 )
4945 .expect("valid order should convert");
4946 assert_eq!(from_all_orders, from_open_orders);
4947 }
4948
4949 #[test]
4955 fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
4956 let instrument = InstrumentNameExchange::new("BTCUSDT");
4957 for status in [
4958 "CANCELED",
4959 "FILLED",
4960 "EXPIRED",
4961 "EXPIRED_IN_MATCH",
4962 "REJECTED",
4963 "PENDING_CANCEL",
4964 "SOME_FUTURE_STATUS",
4965 ] {
4966 let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4967 order_id: Some(12345),
4968 client_order_id: Some("cid-abc".to_string()),
4969 side: Some("BUY".to_string()),
4970 r#type: Some("LIMIT".to_string()),
4971 price: Some("50000.00".to_string()),
4972 orig_qty: Some("0.01".to_string()),
4973 executed_qty: Some("0.004".to_string()),
4974 time_in_force: Some("GTC".to_string()),
4975 time: Some(1_700_000_000_000),
4976 status: Some(status.to_string()),
4977 ..Default::default()
4978 };
4979 assert_eq!(
4980 convert_open_order(&row, ExchangeId::BinanceSpot, &instrument, &|_| None),
4981 None,
4982 "{status}"
4983 );
4984 }
4985 }
4986
4987 #[test]
4988 fn test_convert_open_order_admits_every_live_status() {
4989 let instrument = InstrumentNameExchange::new("BTCUSDT");
4990 for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
4991 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4992 status: Some(status.to_string()),
4993 ..make_base_open_order()
4994 };
4995 assert!(
4996 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_some(),
4997 "{status}"
4998 );
4999 }
5000 }
5001
5002 #[test]
5007 fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
5008 let instrument = InstrumentNameExchange::new("BTCUSDT");
5009 let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5010 order_id: Some(67890),
5011 client_order_id: Some("cid-def".to_string()),
5012 ..make_base_open_order()
5013 };
5014
5015 let listing = convert_open_order_listing(
5016 &[make_base_open_order(), second()],
5017 ExchangeId::BinanceSpot,
5018 &instrument,
5019 &|_| None,
5020 );
5021 assert_eq!(listing.orders.len(), 2);
5022 assert!(listing.complete);
5023
5024 let empty = convert_open_order_listing::<
5025 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
5026 >(&[], ExchangeId::BinanceSpot, &instrument, &|_| None);
5027 assert!(empty.orders.is_empty());
5028 assert!(empty.complete, "no open orders is a complete answer");
5029
5030 let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5031 side: None,
5032 ..second()
5033 };
5034 let listing = convert_open_order_listing(
5035 &[make_base_open_order(), dropped],
5036 ExchangeId::BinanceSpot,
5037 &instrument,
5038 &|_| None,
5039 );
5040 assert_eq!(listing.orders.len(), 1);
5041 assert!(!listing.complete, "a dropped row may be a live order");
5042
5043 let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5044 client_order_id: None,
5045 ..second()
5046 };
5047 let listing = convert_open_order_listing(
5048 &[make_base_open_order(), without_cid],
5049 ExchangeId::BinanceSpot,
5050 &instrument,
5051 &|_| None,
5052 );
5053 assert_eq!(listing.orders.len(), 2, "the row is still converted");
5054 assert!(
5055 !listing.complete,
5056 "an order kept under its orderId cannot be found under the cid it was placed with"
5057 );
5058 }
5059
5060 #[test]
5061 fn test_convert_open_order_missing_status_returns_none() {
5062 let instrument = InstrumentNameExchange::new("BTCUSDT");
5063 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5064 status: None,
5065 ..make_base_open_order()
5066 };
5067 assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_none());
5068 }
5069
5070 #[test]
5076 fn test_convert_open_order_prefers_update_time_over_creation_time() {
5077 let instrument = InstrumentNameExchange::new("BTCUSDT");
5078
5079 let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5080 update_time: Some(1_700_000_005_000),
5081 ..make_base_open_order()
5082 };
5083 let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument, &|_| None)
5084 .expect("valid order should convert");
5085 assert_eq!(
5086 order.state.time_exchange,
5087 Utc.timestamp_millis_opt(1_700_000_005_000)
5088 .single()
5089 .unwrap(),
5090 "update_time must win over time"
5091 );
5092
5093 let order = convert_open_order(
5096 &make_base_open_order(),
5097 ExchangeId::BinanceSpot,
5098 &instrument,
5099 &|_| None,
5100 )
5101 .expect("valid order should convert");
5102 assert_eq!(
5103 order.state.time_exchange,
5104 Utc.timestamp_millis_opt(1_700_000_000_000)
5105 .single()
5106 .unwrap(),
5107 "absent update_time falls back to time"
5108 );
5109 }
5110
5111 #[test]
5112 fn test_convert_open_order_happy_path() {
5113 let instrument = InstrumentNameExchange::new("BTCUSDT");
5114 let order = convert_open_order(
5115 &make_base_open_order(),
5116 ExchangeId::BinanceSpot,
5117 &instrument,
5118 &|_| None,
5119 )
5120 .expect("valid order should convert");
5121 assert_eq!(order.key.instrument, instrument);
5122 assert_eq!(order.side, Side::Buy);
5123 assert_eq!(order.kind, OrderKind::Limit);
5124 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
5125 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
5126 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
5127 }
5128
5129 #[test]
5130 fn test_convert_open_order_owned_symbol_recovers_instrument() {
5131 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5133 symbol: Some("ETHUSDT".to_string()),
5134 ..make_base_open_order()
5135 };
5136 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot, &|_| None)
5137 .expect("valid order should convert");
5138 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
5139 assert_eq!(order.side, Side::Buy);
5140 }
5141
5142 #[test]
5143 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
5144 let o = make_base_open_order();
5146 assert!(o.symbol.is_none());
5147 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot, &|_| None).is_none());
5148 }
5149
5150 #[test]
5151 fn test_convert_open_order_missing_order_id_returns_none() {
5152 let instrument = InstrumentNameExchange::new("BTCUSDT");
5153 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5154 order_id: None,
5155 ..make_base_open_order()
5156 };
5157 assert!(
5158 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_none(),
5159 "missing orderId should return None"
5160 );
5161 }
5162
5163 #[test]
5164 fn test_convert_open_order_missing_side_returns_none() {
5165 let instrument = InstrumentNameExchange::new("BTCUSDT");
5166 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5167 side: None,
5168 ..make_base_open_order()
5169 };
5170 assert!(
5171 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_none(),
5172 "missing side should return None"
5173 );
5174 }
5175
5176 #[test]
5177 fn test_convert_open_order_missing_type_returns_none() {
5178 let instrument = InstrumentNameExchange::new("BTCUSDT");
5179 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5180 r#type: None,
5181 ..make_base_open_order()
5182 };
5183 assert!(
5184 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None).is_none(),
5185 "missing type should return None"
5186 );
5187 }
5188
5189 #[test]
5190 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
5191 let instrument = InstrumentNameExchange::new("BTCUSDT");
5192 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5193 executed_qty: None,
5194 ..make_base_open_order()
5195 };
5196 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None)
5197 .expect("None executedQty should still convert");
5198 assert_eq!(
5199 order.state.filled_quantity,
5200 Decimal::ZERO,
5201 "None executedQty should default to zero"
5202 );
5203 }
5204
5205 #[test]
5206 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
5207 let instrument = InstrumentNameExchange::new("BTCUSDT");
5208 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
5209 executed_qty: Some("bad-value".to_string()),
5210 ..make_base_open_order()
5211 };
5212 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument, &|_| None)
5213 .expect("unparseable executedQty should still convert");
5214 assert_eq!(
5215 order.state.filled_quantity,
5216 Decimal::ZERO,
5217 "unparseable executedQty should default to zero"
5218 );
5219 }
5220
5221 #[test]
5226 fn test_dedup_key_from_event_non_open_states_return_none() {
5227 use crate::order::state::{
5228 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
5229 };
5230 use rustrade_integration::collection::snapshot::Snapshot;
5231
5232 let key = OrderKey::new(
5233 ExchangeId::BinanceSpot,
5234 InstrumentNameExchange::new("BTCUSDT"),
5235 StrategyId::unknown(),
5236 ClientOrderId::new("cid1"),
5237 );
5238
5239 let event = UnindexedAccountEvent::new(
5241 ExchangeId::BinanceSpot,
5242 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
5243 key.clone(),
5244 Side::Buy,
5245 None, Decimal::ZERO,
5247 OrderKind::Market,
5248 TimeInForce::ImmediateOrCancel,
5249 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
5250 ActiveOrderState::OpenInFlight(OpenInFlight::new(Utc::now())),
5251 ),
5252 ))),
5253 );
5254 assert!(
5255 dedup_key_from_event(&event).is_none(),
5256 "OpenInFlight should return None — dedup not meaningful before exchange ack"
5257 );
5258
5259 let event = UnindexedAccountEvent::new(
5261 ExchangeId::BinanceSpot,
5262 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
5263 key.clone(),
5264 Side::Buy,
5265 None, Decimal::ZERO,
5267 OrderKind::Market,
5268 TimeInForce::ImmediateOrCancel,
5269 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
5270 ActiveOrderState::CancelInFlight(CancelInFlight::new(None, Utc::now())),
5271 ),
5272 ))),
5273 );
5274 assert!(
5275 dedup_key_from_event(&event).is_none(),
5276 "CancelInFlight should return None"
5277 );
5278
5279 let event = UnindexedAccountEvent::new(
5281 ExchangeId::BinanceSpot,
5282 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
5283 key,
5284 Side::Buy,
5285 None, Decimal::ONE,
5287 OrderKind::Market,
5288 TimeInForce::ImmediateOrCancel,
5289 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
5290 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
5291 OrderId::new("123"),
5292 Utc::now(),
5293 Decimal::ONE, None,
5295 )),
5296 ),
5297 ))),
5298 );
5299 assert!(
5300 dedup_key_from_event(&event).is_none(),
5301 "Inactive state should return None"
5302 );
5303 }
5304
5305 #[test]
5306 fn test_dedup_key_from_event_cancelled_error_returns_none() {
5307 let report = ExecutionReport {
5310 execution_type: Some("REJECTED".to_string()),
5311 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
5312 ..make_base_report()
5313 };
5314 let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
5315 assert!(
5316 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
5317 "prerequisite: event is OrderCancelled with Err"
5318 );
5319 assert!(
5320 dedup_key_from_event(&event).is_none(),
5321 "OrderCancelled with Err state should return None"
5322 );
5323 }
5324
5325 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
5335 let mut value = serde_json::to_value(event).expect("event serializes to Value");
5336 value
5337 .as_object_mut()
5338 .expect("event serializes to a JSON object")
5339 .insert(
5340 "e".to_string(),
5341 serde_json::Value::String(event_type.to_string()),
5342 );
5343 serde_json::json!({ "subscriptionId": 0, "event": value }).to_string()
5344 }
5345
5346 #[test]
5347 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
5348 let report = ExecutionReport {
5349 execution_type: Some("NEW".to_string()),
5350 side: Some("BUY".to_string()),
5351 order_type: Some("LIMIT".to_string()),
5352 price: Some("50000.00".to_string()),
5353 order_quantity: Some("0.01".to_string()),
5354 time_in_force: Some("GTC".to_string()),
5355 cumulative_filled_quantity: Some("0".to_string()),
5356 ..make_base_report()
5357 };
5358 let frame = user_data_frame("executionReport", &report);
5359 let mut buf = Vec::new();
5360 let terminated = convert_user_data_events(&frame, &mut buf).terminated;
5361 assert!(
5362 !terminated,
5363 "ExecutionReport should not signal stream termination"
5364 );
5365 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
5366 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
5367 }
5368
5369 #[test]
5370 fn test_convert_user_data_events_account_position_pushes_to_buf() {
5371 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
5372 u: Some(1_700_000_000_000),
5373 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
5374 ..Default::default()
5375 };
5376 let frame = user_data_frame("outboundAccountPosition", &position);
5377 let mut buf = Vec::new();
5378 let terminated = convert_user_data_events(&frame, &mut buf).terminated;
5379 assert!(
5380 !terminated,
5381 "OutboundAccountPosition should not signal stream termination"
5382 );
5383 assert_eq!(
5384 buf.len(),
5385 1,
5386 "OutboundAccountPosition should push one balance event"
5387 );
5388 }
5389
5390 #[test]
5391 fn test_convert_user_data_events_balance_update_ignored() {
5392 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
5393 ..Default::default()
5394 };
5395 let frame = user_data_frame("balanceUpdate", &update);
5396 let mut buf = Vec::new();
5397 let terminated = convert_user_data_events(&frame, &mut buf).terminated;
5398 assert!(
5399 !terminated,
5400 "BalanceUpdate should not signal stream termination"
5401 );
5402 assert!(buf.is_empty(), "BalanceUpdate should push no events");
5403 }
5404
5405 #[test]
5406 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
5407 let frame =
5409 r#"{"subscriptionId":0,"event":{"e":"eventStreamTerminated","E":1700000000000}}"#;
5410 let mut buf = Vec::new();
5411 let terminated = convert_user_data_events(frame, &mut buf).terminated;
5412 assert!(
5413 terminated,
5414 "eventStreamTerminated must signal stream termination"
5415 );
5416 assert!(
5417 buf.is_empty(),
5418 "eventStreamTerminated should push no events"
5419 );
5420 }
5421
5422 #[test]
5423 fn test_convert_user_data_events_unknown_event_ignored() {
5424 for e in ["listStatus", "externalLockUpdate", "someFutureEvent"] {
5427 let frame = format!(
5428 r#"{{"subscriptionId":0,"event":{{"e":"{e}","E":1700000000000,"s":"BTCUSDT"}}}}"#
5429 );
5430 let mut buf = Vec::new();
5431 let terminated = convert_user_data_events(&frame, &mut buf).terminated;
5432 assert!(!terminated, "{e} must not signal termination");
5433 assert!(buf.is_empty(), "{e} should push no events");
5434 }
5435 }
5436
5437 #[test]
5442 fn test_convert_user_data_events_reads_the_ws_api_envelope() {
5443 let fill = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000001000,
5444 "s":"BTCUSDT","c":"client-1","S":"SELL","o":"MARKET","f":"GTC","q":"0.5","p":"0",
5445 "x":"TRADE","X":"FILLED","i":777,"l":"0.5","z":"0.5","L":"40000","n":"0.02",
5446 "N":"USDT","T":1700000001000,"t":888}}"#;
5447 let mut buf = Vec::new();
5448 assert!(!convert_user_data_events(fill, &mut buf).terminated);
5449 assert!(
5450 buf.iter()
5451 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
5452 "the enveloped fill must produce its trade, got: {buf:?}"
5453 );
5454
5455 let bare = r#"{"e":"executionReport","s":"BTCUSDT","i":777,"x":"NEW","X":"NEW"}"#;
5457 buf.clear();
5458 assert!(!convert_user_data_events(bare, &mut buf).terminated);
5459 assert!(buf.is_empty());
5460 }
5461
5462 #[test]
5465 fn test_convert_user_data_events_returns_the_events_time() {
5466 let new_order = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000002000,
5467 "s":"BTCUSDT","c":"client-1","S":"BUY","o":"LIMIT","f":"GTC","q":"0.5","p":"40000",
5468 "x":"NEW","X":"NEW","i":777,"l":"0","z":"0","L":"0","n":"0","T":1700000001000}}"#;
5469 let mut buf = Vec::new();
5470 let outcome = convert_user_data_events(new_order, &mut buf);
5471 assert_eq!(
5472 outcome.event_time,
5473 DateTime::<Utc>::from_timestamp_millis(1_700_000_002_000)
5474 );
5475
5476 let ack = r#"{"id":"abc","status":200,"result":[]}"#;
5477 assert_eq!(convert_user_data_events(ack, &mut buf).event_time, None);
5478 }
5479
5480 #[test]
5481 fn test_convert_user_data_events_non_user_data_frame_ignored() {
5482 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
5484 let mut buf = Vec::new();
5485 let terminated = convert_user_data_events(frame, &mut buf).terminated;
5486 assert!(
5487 !terminated,
5488 "non-user-data frame must not signal termination"
5489 );
5490 assert!(buf.is_empty(), "non-user-data frame should push no events");
5491 }
5492
5493 #[tokio::test]
5498 async fn test_rate_limit_tracker_not_blocked_initially() {
5499 tokio::time::pause();
5500 let tracker = RateLimitTracker::new(WeightPool::Spot);
5501 tokio::time::timeout(
5503 std::time::Duration::from_millis(1),
5504 tracker.wait_if_blocked(RequestKind::Order),
5505 )
5506 .await
5507 .expect("wait_if_blocked should return immediately when not blocked")
5508 .expect("no cooldown too long to sleep through");
5509 }
5510
5511 #[tokio::test]
5512 async fn test_rate_limit_tracker_blocks_until_deadline() {
5513 tokio::time::pause();
5514 let tracker = RateLimitTracker::new(WeightPool::Spot);
5515 let delay = Duration::from_secs(5);
5516 tracker.on_rate_limited(Some(delay));
5517
5518 assert!(
5520 tokio::time::timeout(
5521 Duration::from_millis(1),
5522 tracker.wait_if_blocked(RequestKind::Order)
5523 )
5524 .await
5525 .is_err(),
5526 "wait_if_blocked should block while cooldown is active"
5527 );
5528
5529 tokio::time::advance(delay + Duration::from_millis(1)).await;
5531 tokio::time::timeout(
5532 Duration::from_millis(1),
5533 tracker.wait_if_blocked(RequestKind::Order),
5534 )
5535 .await
5536 .expect("wait_if_blocked should return after cooldown expires")
5537 .expect("no cooldown too long to sleep through");
5538 }
5539
5540 #[tokio::test]
5541 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
5542 tokio::time::pause();
5543 let tracker = RateLimitTracker::new(WeightPool::Spot);
5544
5545 tracker.on_rate_limited(Some(Duration::from_secs(5)));
5547 tracker.on_rate_limited(Some(Duration::from_secs(10)));
5549
5550 tokio::time::advance(Duration::from_secs(6)).await;
5552 assert!(
5553 tokio::time::timeout(
5554 Duration::from_millis(1),
5555 tracker.wait_if_blocked(RequestKind::Order)
5556 )
5557 .await
5558 .is_err(),
5559 "cooldown should have been extended to 10s"
5560 );
5561
5562 tokio::time::advance(Duration::from_secs(5)).await;
5564 tokio::time::timeout(
5565 Duration::from_millis(1),
5566 tracker.wait_if_blocked(RequestKind::Order),
5567 )
5568 .await
5569 .expect("wait_if_blocked should return after extended cooldown expires")
5570 .expect("no cooldown too long to sleep through");
5571 }
5572
5573 #[tokio::test]
5574 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
5575 tokio::time::pause();
5576 let tracker = RateLimitTracker::new(WeightPool::Spot);
5577
5578 tracker.on_rate_limited(Some(Duration::from_secs(10)));
5580 tracker.on_rate_limited(Some(Duration::from_secs(2)));
5581
5582 tokio::time::advance(Duration::from_secs(3)).await;
5584 assert!(
5585 tokio::time::timeout(
5586 Duration::from_millis(1),
5587 tracker.wait_if_blocked(RequestKind::Order)
5588 )
5589 .await
5590 .is_err(),
5591 "shorter on_rate_limited must not shorten existing cooldown"
5592 );
5593 }
5594
5595 #[test]
5600 fn test_classify_ws_order_error_with_wrapped_error_chain() {
5601 let instrument = InstrumentNameExchange::new("BTCUSDT");
5606 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
5607 code: -2010,
5608 message: "insufficient balance".into(),
5609 retry_after: None,
5610 });
5611 assert!(
5613 classify_ws_order_error(&raw, &instrument).is_some(),
5614 "unwrapped ResponseError at root must be detected"
5615 );
5616 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
5618 assert!(
5619 classify_ws_order_error(&wrapped, &instrument).is_some(),
5620 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
5621 );
5622 }
5623
5624 #[tokio::test]
5627 async fn rest_used_weight_near_the_limit_pauses_queries() {
5628 for (used, pauses) in [(5_399, false), (5_400, true)] {
5629 let server = wiremock::MockServer::start().await;
5630 wiremock::Mock::given(wiremock::matchers::method("GET"))
5631 .and(wiremock::matchers::path("/api/v3/openOrders"))
5632 .respond_with(
5633 wiremock::ResponseTemplate::new(200)
5634 .insert_header("x-mbx-used-weight-1m", used.to_string())
5635 .set_body_json(serde_json::json!([])),
5636 )
5637 .mount(&server)
5638 .await;
5639 let rest = Arc::new(SpotRestApi::from_config(
5640 ConfigurationRestApi::builder()
5641 .api_key("key")
5642 .api_secret("secret")
5643 .base_path(server.uri())
5644 .build()
5645 .unwrap(),
5646 ));
5647 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5648
5649 fetch_all_open_orders(rest, Arc::clone(&tracker))
5650 .await
5651 .unwrap();
5652
5653 let query_waits = tokio::time::timeout(
5654 Duration::from_millis(50),
5655 tracker.wait_if_blocked(RequestKind::Query),
5656 )
5657 .await
5658 .is_err();
5659 assert_eq!(query_waits, pauses, "used weight {used}");
5660 tokio::time::timeout(
5661 Duration::from_millis(50),
5662 tracker.wait_if_blocked(RequestKind::Order),
5663 )
5664 .await
5665 .expect("orders never wait on the pause")
5666 .expect("no cooldown too long to sleep through");
5667 }
5668 }
5669
5670 fn client_with_ws_api(ws_url: String) -> BinanceSpot {
5675 let mut client = <BinanceSpot as ExecutionClient>::new(
5676 BinanceSpotConfig::new("key".into(), "secret".into())
5677 .with_server_time_sync(false)
5678 .with_pre_send_rules(PreSendRules::Off),
5679 );
5680 client.rest = Arc::new(SpotRestApi::from_config(
5681 ConfigurationRestApi::builder()
5682 .api_key("key")
5683 .api_secret("secret")
5684 .base_path("http://127.0.0.1:9")
5685 .build()
5686 .unwrap(),
5687 ));
5688 client.ws_handle = SpotWsApi::from_config(
5689 ConfigurationWebsocketApi::builder()
5690 .api_key("key")
5691 .api_secret("secret")
5692 .ws_url(ws_url)
5693 .build()
5694 .unwrap(),
5695 );
5696 client
5697 }
5698
5699 fn cancel_request(
5701 instrument: &InstrumentNameExchange,
5702 ) -> OrderRequestCancel<ExchangeId, &InstrumentNameExchange> {
5703 crate::order::OrderEvent {
5704 key: OrderKey {
5705 exchange: ExchangeId::BinanceSpot,
5706 instrument,
5707 strategy: StrategyId::new("strategy"),
5708 cid: ClientOrderId::new("cid"),
5709 },
5710 state: crate::order::request::RequestCancel { id: None },
5711 }
5712 }
5713
5714 async fn ws_server<F, Fut>(serve: F) -> String
5716 where
5717 F: Fn(tokio::net::TcpStream) -> Fut + Send + 'static,
5718 Fut: std::future::Future<Output = ()> + Send + 'static,
5719 {
5720 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5721 let url = format!("ws://{}", listener.local_addr().unwrap());
5722 tokio::spawn(async move {
5723 while let Ok((stream, _)) = listener.accept().await {
5724 tokio::spawn(serve(stream));
5725 }
5726 });
5727 url
5728 }
5729
5730 #[tokio::test]
5733 async fn cancel_without_a_session_during_a_cooldown_is_rejected_unsent() {
5734 let client = <BinanceSpot as ExecutionClient>::new(
5735 BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
5736 );
5737 client
5738 .rate_limiter
5739 .on_rate_limited(Some(Duration::from_secs(60)));
5740 let instrument = InstrumentNameExchange::new("BTCUSDT");
5741 let response = client.cancel_order(cancel_request(&instrument)).await;
5742
5743 assert_rate_limited_for(&response.state, Duration::from_secs(60));
5744 assert!(
5745 client.ws_api.read().await.is_none(),
5746 "no session was opened"
5747 );
5748 }
5749
5750 fn assert_rate_limited_for<T: std::fmt::Debug>(
5753 state: &Result<T, UnindexedOrderError>,
5754 wait: Duration,
5755 ) {
5756 let retry_after = match state {
5757 Err(UnindexedOrderError::Rejected(api @ ApiError::RateLimit { .. })) => {
5758 api.retry_after()
5759 }
5760 other => panic!("expected a rate-limit rejection, got {other:?}"),
5761 };
5762 assert!(
5763 retry_after.is_some_and(|got| got <= wait && got > wait - Duration::from_secs(5)),
5764 "expected a wait of about {wait:?}, got {retry_after:?}"
5765 );
5766 }
5767
5768 async fn ws_api_answering(
5771 answer: fn(&serde_json::Value) -> serde_json::Value,
5772 ) -> (String, Arc<std::sync::atomic::AtomicU32>) {
5773 use futures::{SinkExt as _, StreamExt as _};
5774 use tokio_tungstenite::tungstenite::Message;
5775
5776 let requests = Arc::new(std::sync::atomic::AtomicU32::new(0));
5777 let url = ws_server({
5778 let requests = Arc::clone(&requests);
5779 move |stream| {
5780 let requests = Arc::clone(&requests);
5781 async move {
5782 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
5783 return;
5784 };
5785 while let Some(Ok(message)) = ws.next().await {
5786 let Message::Text(text) = message else {
5787 continue;
5788 };
5789 requests.fetch_add(1, Ordering::SeqCst);
5790 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
5791 let response = answer(&request);
5792 if ws.send(Message::text(response.to_string())).await.is_err() {
5793 return;
5794 }
5795 }
5796 }
5797 }
5798 })
5799 .await;
5800 (url, requests)
5801 }
5802
5803 fn cancel_accepted(request: &serde_json::Value) -> serde_json::Value {
5805 serde_json::json!({
5806 "id": request["id"],
5807 "status": 200,
5808 "result": {
5809 "symbol": "BTCUSDT",
5810 "origClientOrderId": "cid",
5811 "orderId": 7,
5812 "transactTime": 1_700_000_000_000_i64,
5813 "executedQty": "0",
5814 },
5815 })
5816 }
5817
5818 #[tokio::test]
5821 async fn cancel_on_an_open_session_during_a_long_cooldown_is_rejected_unsent() {
5822 let (url, requests) = ws_api_answering(cancel_accepted).await;
5823 let client = client_with_ws_api(url);
5824 let instrument = InstrumentNameExchange::new("BTCUSDT");
5825 let response = client.cancel_order(cancel_request(&instrument)).await;
5826 assert!(response.state.is_ok(), "{:?}", response.state);
5827
5828 client
5829 .rate_limiter
5830 .on_rate_limited(Some(Duration::from_secs(60)));
5831 let response = client.cancel_order(cancel_request(&instrument)).await;
5832
5833 assert_rate_limited_for(&response.state, Duration::from_secs(60));
5834 assert_eq!(
5835 requests.load(Ordering::SeqCst),
5836 1,
5837 "the second was not sent"
5838 );
5839 assert!(
5840 client.ws_api.read().await.is_some(),
5841 "the session stays open"
5842 );
5843 }
5844
5845 #[tokio::test]
5849 async fn a_ws_api_ban_reports_its_wait_and_holds_back_later_requests() {
5850 let (url, requests) = ws_api_answering(banned_for_two_minutes).await;
5851 let client = client_with_ws_api(url);
5852 let instrument = InstrumentNameExchange::new("BTCUSDT");
5853
5854 let response = client.cancel_order(cancel_request(&instrument)).await;
5855 assert_rate_limited_for(&response.state, TWO_MINUTE_BAN);
5856 assert_cooldown_about(&client, TWO_MINUTE_BAN);
5857
5858 let response = client.cancel_order(cancel_request(&instrument)).await;
5859 assert_rate_limited_for(&response.state, TWO_MINUTE_BAN);
5860 assert_eq!(
5861 requests.load(Ordering::SeqCst),
5862 1,
5863 "the second was not sent"
5864 );
5865 }
5866
5867 const TWO_MINUTE_BAN: Duration = Duration::from_millis(120_000 + CLOCK_SKEW_SLACK_MS);
5869
5870 fn banned_for_two_minutes(request: &serde_json::Value) -> serde_json::Value {
5872 let until = unix_ms() + 120_000;
5873 serde_json::json!({
5874 "id": request["id"],
5875 "status": 418,
5876 "error": {
5877 "code": -1003,
5878 "msg": format!("Way too much request weight used; IP banned until {until}."),
5879 "data": { "serverTime": unix_ms(), "retryAfter": until },
5880 },
5881 })
5882 }
5883
5884 fn assert_cooldown_about(client: &BinanceSpot, wait: Duration) {
5887 let left = client.rate_limiter.cooldown_remaining();
5888 assert!(
5889 left.is_some_and(|left| left <= wait && left > wait - Duration::from_secs(5)),
5890 "expected a cooldown of about {wait:?}, got {left:?}"
5891 );
5892 }
5893
5894 #[tokio::test]
5897 async fn a_ws_api_ban_on_an_order_holds_back_later_requests() {
5898 let (url, requests) = ws_api_answering(banned_for_two_minutes).await;
5899 let client = client_with_ws_api(url);
5900 let instrument = InstrumentNameExchange::new("BTCUSDT");
5901 let request = crate::order::OrderEvent {
5902 key: OrderKey {
5903 exchange: ExchangeId::BinanceSpot,
5904 instrument: &instrument,
5905 strategy: StrategyId::new("strategy"),
5906 cid: ClientOrderId::new("cid"),
5907 },
5908 state: crate::order::request::RequestOpen::new(
5909 Side::Buy,
5910 Some(Decimal::new(50_000, 0)),
5911 Decimal::new(1, 3),
5912 OrderKind::Limit,
5913 TimeInForce::GoodUntilCancelled { post_only: false },
5914 ),
5915 };
5916
5917 let order = client.open_order(request).await;
5918
5919 let state = match order.state {
5920 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(error)) => {
5921 Err::<(), _>(error)
5922 }
5923 other => panic!("expected the open to fail, got {other:?}"),
5924 };
5925 assert_rate_limited_for(&state, TWO_MINUTE_BAN);
5926 assert_cooldown_about(&client, TWO_MINUTE_BAN);
5927 assert_eq!(requests.load(Ordering::SeqCst), 1);
5928 }
5929
5930 fn refused_timestamp(request: &serde_json::Value) -> serde_json::Value {
5932 serde_json::json!({
5933 "id": request["id"],
5934 "status": 400,
5935 "error": {
5936 "code": -1021,
5937 "msg": "Timestamp for this request was 1000ms ahead of the server's time.",
5938 },
5939 })
5940 }
5941
5942 #[tokio::test]
5945 async fn a_ws_api_refused_timestamp_reads_the_server_time_again() {
5946 let (url, _) = ws_api_answering(refused_timestamp).await;
5947 let mut client = client_with_ws_api(url);
5948 let reads = Arc::new(std::sync::atomic::AtomicU32::new(0));
5949 let read: crate::client::binance::server_time::ReadServerTime = {
5950 let reads = Arc::clone(&reads);
5951 Arc::new(move || {
5952 reads.fetch_add(1, Ordering::SeqCst);
5953 Box::pin(async { Ok(i64::try_from(unix_ms()).unwrap()) })
5954 })
5955 };
5956 client.rate_limiter = Arc::new(
5957 RateLimitTracker::new(WeightPool::Spot)
5958 .with_server_time(Some(ServerTime::new(SigningClock::default(), read))),
5959 );
5960 let instrument = InstrumentNameExchange::new("BTCUSDT");
5961 let open = crate::order::OrderEvent {
5962 key: OrderKey {
5963 exchange: ExchangeId::BinanceSpot,
5964 instrument: &instrument,
5965 strategy: StrategyId::new("strategy"),
5966 cid: ClientOrderId::new("cid"),
5967 },
5968 state: crate::order::request::RequestOpen::new(
5969 Side::Buy,
5970 Some(Decimal::new(50_000, 0)),
5971 Decimal::new(1, 3),
5972 OrderKind::Limit,
5973 TimeInForce::GoodUntilCancelled { post_only: false },
5974 ),
5975 };
5976
5977 let order = client.open_order(open).await;
5978 assert!(
5979 matches!(order.state, OrderState::Inactive(_)),
5980 "{:?}",
5981 order.state
5982 );
5983 assert_eq!(reads.load(Ordering::SeqCst), 2);
5985
5986 let cancel = client.cancel_order(cancel_request(&instrument)).await;
5987 assert!(cancel.state.is_err(), "{:?}", cancel.state);
5988 assert_eq!(reads.load(Ordering::SeqCst), 3);
5989 }
5990
5991 fn answered_without_an_order_id(request: &serde_json::Value) -> serde_json::Value {
5993 serde_json::json!({
5994 "id": request["id"],
5995 "status": 200,
5996 "result": { "symbol": "BTCUSDT", "clientOrderId": "cid" },
5997 })
5998 }
5999
6000 fn answered_undecodably(request: &serde_json::Value) -> serde_json::Value {
6002 serde_json::json!({
6003 "id": request["id"],
6004 "status": 200,
6005 "result": { "symbol": "BTCUSDT", "orderId": "not a number" },
6006 })
6007 }
6008
6009 #[tokio::test]
6012 async fn an_open_answered_unreadably_is_an_unknown_outcome() {
6013 for answer in [answered_without_an_order_id, answered_undecodably] {
6014 let (url, _) = ws_api_answering(answer).await;
6015 let client = client_with_ws_api(url);
6016 let instrument = InstrumentNameExchange::new("BTCUSDT");
6017 let request = crate::order::OrderEvent {
6018 key: OrderKey {
6019 exchange: ExchangeId::BinanceSpot,
6020 instrument: &instrument,
6021 strategy: StrategyId::new("strategy"),
6022 cid: ClientOrderId::new("cid"),
6023 },
6024 state: crate::order::request::RequestOpen::new(
6025 Side::Buy,
6026 Some(Decimal::new(50_000, 0)),
6027 Decimal::new(1, 3),
6028 OrderKind::Limit,
6029 TimeInForce::GoodUntilCancelled { post_only: false },
6030 ),
6031 };
6032
6033 let order = client.open_order(request).await;
6034
6035 assert!(
6036 matches!(
6037 order.state,
6038 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
6039 OrderError::Connectivity(_)
6040 ))
6041 ),
6042 "{:?}",
6043 order.state
6044 );
6045 }
6046 }
6047
6048 #[tokio::test]
6051 async fn a_cancel_answered_unreadably_is_an_unknown_outcome() {
6052 for answer in [answered_without_an_order_id, answered_undecodably] {
6053 let (url, _) = ws_api_answering(answer).await;
6054 let client = client_with_ws_api(url);
6055 let instrument = InstrumentNameExchange::new("BTCUSDT");
6056
6057 let response = client.cancel_order(cancel_request(&instrument)).await;
6058
6059 assert!(
6060 matches!(response.state, Err(OrderError::Connectivity(_))),
6061 "{:?}",
6062 response.state
6063 );
6064 }
6065 }
6066
6067 fn subscribed(request: &serde_json::Value) -> serde_json::Value {
6069 serde_json::json!({
6070 "id": request["id"],
6071 "status": 200,
6072 "result": { "subscriptionId": 0 },
6073 })
6074 }
6075
6076 fn rate_limited_wait<T>(opened: Result<T, UnindexedClientError>) -> Option<Duration> {
6078 match opened {
6079 Err(UnindexedClientError::Api(api @ ApiError::RateLimit { .. })) => api.retry_after(),
6080 Err(other) => panic!("expected a rate limit, got {other:?}"),
6081 Ok(_) => panic!("expected the open to fail"),
6082 }
6083 }
6084
6085 #[tokio::test]
6088 async fn opening_the_account_stream_during_a_long_cooldown_fails_unsent() {
6089 let (url, requests) = ws_api_answering(subscribed).await;
6090 let client = client_with_ws_api(url);
6091 client
6092 .rate_limiter
6093 .on_rate_limited(Some(Duration::from_secs(60)));
6094
6095 let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
6096
6097 assert!(
6098 wait.is_some_and(
6099 |wait| wait <= Duration::from_secs(60) && wait > Duration::from_secs(55)
6100 ),
6101 "{wait:?}"
6102 );
6103 assert_eq!(requests.load(Ordering::SeqCst), 0);
6104 }
6105
6106 #[tokio::test]
6109 async fn a_ban_on_the_account_stream_subscribe_starts_its_cooldown() {
6110 let (url, _) = ws_api_answering(banned_for_two_minutes).await;
6111 let client = client_with_ws_api(url);
6112
6113 let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
6114
6115 assert!(
6116 wait.is_some_and(
6117 |wait| wait <= TWO_MINUTE_BAN && wait > TWO_MINUTE_BAN - Duration::from_secs(5)
6118 ),
6119 "{wait:?}"
6120 );
6121 assert_cooldown_about(&client, TWO_MINUTE_BAN);
6122 }
6123
6124 #[tokio::test]
6127 async fn a_refused_account_stream_handshake_starts_a_cooldown() {
6128 use tokio_tungstenite::tungstenite::http;
6129
6130 let url = ws_server(|stream| async move {
6131 #[allow(clippy::result_large_err)]
6133 let refuse = |_: &http::Request<()>, _| {
6134 Err(http::Response::builder()
6135 .status(http::StatusCode::IM_A_TEAPOT)
6136 .body(None)
6137 .unwrap())
6138 };
6139 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
6140 })
6141 .await;
6142 let client = client_with_ws_api(url);
6143
6144 let wait = rate_limited_wait(client.account_stream(&[], &[]).await);
6145
6146 assert!(
6148 wait.is_some_and(
6149 |wait| wait <= MIN_IP_BAN && wait > MIN_IP_BAN - Duration::from_secs(5)
6150 ),
6151 "{wait:?}"
6152 );
6153 assert_cooldown_about(&client, MIN_IP_BAN);
6154 }
6155
6156 #[tokio::test]
6159 async fn a_drop_during_a_long_cooldown_ends_the_account_stream_rate_limited() {
6160 use futures::{SinkExt as _, StreamExt as _};
6161 use tokio_tungstenite::tungstenite::Message;
6162
6163 let subscribes = Arc::new(std::sync::atomic::AtomicU32::new(0));
6164 let url = ws_server({
6165 let subscribes = Arc::clone(&subscribes);
6166 move |stream| {
6167 let subscribes = Arc::clone(&subscribes);
6168 async move {
6169 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
6170 return;
6171 };
6172 while let Some(Ok(message)) = ws.next().await {
6175 let Message::Text(text) = message else {
6176 continue;
6177 };
6178 subscribes.fetch_add(1, Ordering::SeqCst);
6179 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
6180 let answer = subscribed(&request).to_string();
6181 if ws.send(Message::text(answer)).await.is_err() {
6182 return;
6183 }
6184 tokio::time::sleep(Duration::from_secs(1)).await;
6185 let _ = ws.close(None).await;
6186 return;
6187 }
6188 }
6189 }
6190 })
6191 .await;
6192 let client = client_with_ws_api(url);
6193 let mut stream = client.account_stream(&[], &[]).await.unwrap();
6194 client
6195 .rate_limiter
6196 .on_rate_limited(Some(Duration::from_secs(60)));
6197
6198 let event = tokio::time::timeout(Duration::from_secs(5), stream.next())
6199 .await
6200 .expect("the stream ends rate-limited within the timeout")
6201 .expect("it reports why before it ends");
6202
6203 match event.kind {
6204 AccountEventKind::StreamTerminated(StreamTerminationReason::RateLimited {
6205 retry_after,
6206 }) => assert!(
6207 retry_after <= Duration::from_secs(60) && retry_after > Duration::from_secs(55),
6208 "{retry_after:?}"
6209 ),
6210 other => panic!("expected a rate-limited termination, got {other:?}"),
6211 }
6212 assert!(stream.next().await.is_none(), "the stream has ended");
6213 assert_eq!(subscribes.load(Ordering::SeqCst), 1);
6214 }
6215
6216 #[tokio::test]
6219 async fn a_reconnect_waits_out_a_short_cooldown() {
6220 use futures::{SinkExt as _, StreamExt as _};
6221 use tokio_tungstenite::tungstenite::Message;
6222
6223 let subscribed_at = Arc::new(parking_lot::Mutex::new(Vec::new()));
6224 let url = ws_server({
6225 let subscribed_at = Arc::clone(&subscribed_at);
6226 move |stream| {
6227 let subscribed_at = Arc::clone(&subscribed_at);
6228 async move {
6229 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
6230 return;
6231 };
6232 while let Some(Ok(message)) = ws.next().await {
6233 let Message::Text(text) = message else {
6234 continue;
6235 };
6236 subscribed_at.lock().push(tokio::time::Instant::now());
6237 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
6238 if ws
6239 .send(Message::text(subscribed(&request).to_string()))
6240 .await
6241 .is_err()
6242 {
6243 return;
6244 }
6245 if subscribed_at.lock().len() == 1 {
6248 tokio::time::sleep(Duration::from_millis(500)).await;
6249 let _ = ws.close(None).await;
6250 return;
6251 }
6252 }
6253 }
6254 }
6255 })
6256 .await;
6257 let client = client_with_ws_api(url);
6258 let _stream = client.account_stream(&[], &[]).await.unwrap();
6259 let cooldown = Duration::from_secs(3);
6260 let cooled = tokio::time::Instant::now() + cooldown;
6262 client.rate_limiter.on_rate_limited(Some(cooldown));
6263
6264 let deadline = tokio::time::Instant::now() + Duration::from_secs(10);
6265 while subscribed_at.lock().len() < 2 {
6266 assert!(tokio::time::Instant::now() < deadline, "no reconnect");
6267 tokio::time::sleep(Duration::from_millis(20)).await;
6268 }
6269
6270 assert!(subscribed_at.lock()[1] >= cooled);
6272 }
6273
6274 #[tokio::test(start_paused = true)]
6279 async fn the_reconnect_budget_resets_only_once_a_connection_proves_stable() {
6280 async fn run(pinging: bool) {
6281 let lifetime = if pinging {
6282 test_ws_api::dropped_then_pinging
6283 } else {
6284 test_ws_api::dropped_then_silent
6285 };
6286 let (url, requests) = test_ws_api::serve(subscribed, lifetime).await;
6287 let client = client_with_ws_api(url);
6288 let _stream = client.account_stream(&[], &[]).await.unwrap();
6289 if pinging {
6290 test_ws_api::assert_reset_once_pinged(&requests, "spot").await;
6291 } else {
6292 test_ws_api::assert_no_reset_when_silent(&requests, "spot").await;
6293 }
6294 }
6295
6296 tokio::join!(run(true), run(false));
6297 }
6298
6299 #[tokio::test]
6302 async fn a_reconnect_restates_the_balances() {
6303 fn dropped_once(n: usize) -> test_ws_api::Lifetime {
6304 match n {
6305 0 => test_ws_api::Lifetime::Silent(Duration::from_millis(100)),
6306 _ => test_ws_api::Lifetime::Open,
6307 }
6308 }
6309 let venue = wiremock::MockServer::start().await;
6310 wiremock::Mock::given(wiremock::matchers::method("GET"))
6311 .and(wiremock::matchers::path("/api/v3/account"))
6312 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6313 serde_json::json!({ "balances": [
6314 { "asset": "BTC", "free": "1", "locked": "0" },
6315 { "asset": "ETH", "free": "2", "locked": "0" },
6316 ] }),
6317 ))
6318 .mount(&venue)
6319 .await;
6320 let (url, _) = test_ws_api::serve(subscribed, dropped_once).await;
6321 let mut client = client_with_ws_api(url);
6322 client.rest = rest_at(&venue);
6323 let btc = AssetNameExchange::new("BTC");
6324 let mut stream = client
6325 .account_stream(std::slice::from_ref(&btc), &[])
6326 .await
6327 .unwrap();
6328
6329 let restated = tokio::time::timeout(Duration::from_secs(10), async {
6330 loop {
6331 let event = futures::StreamExt::next(&mut stream).await.unwrap();
6332 if let AccountEventKind::BalanceSnapshot(balance) = event.kind {
6333 break balance.0;
6334 }
6335 }
6336 })
6337 .await
6338 .expect("the balances are restated after the reconnect");
6339 assert_eq!(restated.asset, btc);
6340 assert_eq!(restated.balance, Balance::new(Decimal::ONE, Decimal::ONE));
6341 }
6342
6343 #[tokio::test]
6348 async fn a_restatement_is_stamped_no_earlier_than_the_last_balance_delivered() {
6349 use crate::client::binance::shared::REST_BALANCE_LAG;
6350 fn ahead_ms() -> i64 {
6351 (Utc::now() + chrono::Duration::seconds(60)).timestamp_millis()
6352 }
6353 fn position() -> String {
6354 let time = ahead_ms();
6355 serde_json::json!({
6356 "subscriptionId": 0,
6357 "event": {
6358 "e": "outboundAccountPosition", "E": time, "u": time,
6359 "B": [{ "a": "BTC", "f": "1", "l": "0" }],
6360 },
6361 })
6362 .to_string()
6363 }
6364 fn pushed_then_open(n: usize) -> test_ws_api::Lifetime {
6365 match n {
6366 0 => test_ws_api::Lifetime::Pushing(position, Duration::from_millis(300)),
6367 _ => test_ws_api::Lifetime::Open,
6368 }
6369 }
6370 let venue = wiremock::MockServer::start().await;
6371 wiremock::Mock::given(wiremock::matchers::method("GET"))
6372 .and(wiremock::matchers::path("/api/v3/account"))
6373 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
6374 serde_json::json!({ "balances": [{ "asset": "BTC", "free": "1", "locked": "0" }] }),
6375 ))
6376 .mount(&venue)
6377 .await;
6378 let (url, _) = test_ws_api::serve(subscribed, pushed_then_open).await;
6379 let mut client = client_with_ws_api(url);
6380 client.rest = rest_at(&venue);
6381 let mut stream = client.account_stream(&[], &[]).await.unwrap();
6382
6383 let (pushed, restated) = tokio::time::timeout(Duration::from_secs(15), async {
6384 let mut pushed = None;
6385 loop {
6386 let event = futures::StreamExt::next(&mut stream).await.unwrap();
6387 match event.kind {
6388 AccountEventKind::BalanceStreamUpdate(update) => {
6389 pushed = Some((update.0.time_exchange, tokio::time::Instant::now()));
6390 }
6391 AccountEventKind::BalanceSnapshot(balance) => {
6392 break (
6393 pushed.expect("the pushed balance comes first"),
6394 (balance.0.time_exchange, tokio::time::Instant::now()),
6395 );
6396 }
6397 _ => {}
6398 }
6399 }
6400 })
6401 .await
6402 .expect("the balances are restated after the reconnect");
6403
6404 assert_eq!(restated.0, pushed.0);
6405 let allowance = Duration::from_secs(1);
6409 assert!(
6410 restated.1 + allowance >= pushed.1 + REST_BALANCE_LAG.to_std().unwrap(),
6411 "restated {:?} after the balance event, before the REST lag",
6412 restated.1 - pushed.1
6413 );
6414 }
6415
6416 #[tokio::test]
6419 async fn a_cancel_queued_behind_a_refused_handshake_reports_the_cooldown() {
6420 use tokio_tungstenite::tungstenite::http;
6421
6422 let handshakes = Arc::new(std::sync::atomic::AtomicU32::new(0));
6423 let url = ws_server({
6424 let handshakes = Arc::clone(&handshakes);
6425 move |stream| {
6426 let handshakes = Arc::clone(&handshakes);
6427 async move {
6428 handshakes.fetch_add(1, Ordering::SeqCst);
6429 tokio::time::sleep(Duration::from_millis(200)).await;
6431 #[allow(clippy::result_large_err)]
6434 let refuse = |_: &http::Request<()>, _| {
6435 Err(http::Response::builder()
6436 .status(http::StatusCode::TOO_MANY_REQUESTS)
6437 .body(None)
6438 .unwrap())
6439 };
6440 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
6441 }
6442 }
6443 })
6444 .await;
6445 let client = client_with_ws_api(url);
6446 let instrument = InstrumentNameExchange::new("BTCUSDT");
6447
6448 let (first, second) = tokio::join!(
6449 client.cancel_order(cancel_request(&instrument)),
6450 client.cancel_order(cancel_request(&instrument)),
6451 );
6452
6453 assert_eq!(handshakes.load(Ordering::SeqCst), 1);
6454 let waits: Vec<_> = [&first.state, &second.state]
6455 .into_iter()
6456 .map(|state| match state {
6457 Err(UnindexedOrderError::Rejected(api @ ApiError::RateLimit { .. })) => {
6458 api.retry_after()
6459 }
6460 other => panic!("expected a rate-limit rejection, got {other:?}"),
6461 })
6462 .collect();
6463 let default = Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS);
6465 assert!(
6466 waits.contains(&None)
6467 && waits
6468 .iter()
6469 .flatten()
6470 .any(|wait| { *wait <= default && *wait > default - Duration::from_secs(5) }),
6471 "{waits:?}"
6472 );
6473 }
6474
6475 fn rest_against(server: &wiremock::MockServer) -> (Arc<RestApi>, Arc<RateLimitTracker>) {
6477 let rest = Arc::new(SpotRestApi::from_config(
6478 ConfigurationRestApi::builder()
6479 .api_key("key")
6480 .api_secret("secret")
6481 .base_path(server.uri())
6482 .build()
6483 .unwrap(),
6484 ));
6485 (rest, Arc::new(RateLimitTracker::new(WeightPool::Spot)))
6486 }
6487
6488 async fn mount_refusal(
6491 server: &wiremock::MockServer,
6492 status: u16,
6493 retry_after: Option<&str>,
6494 times: u64,
6495 ) {
6496 let mut refusal = wiremock::ResponseTemplate::new(status).set_body_json(
6497 serde_json::json!({ "code": -1003, "msg": "Too much request weight used." }),
6498 );
6499 if let Some(retry_after) = retry_after {
6500 refusal = refusal.insert_header("retry-after", retry_after);
6501 }
6502 wiremock::Mock::given(wiremock::matchers::method("GET"))
6503 .and(wiremock::matchers::path("/api/v3/openOrders"))
6504 .respond_with(refusal)
6505 .up_to_n_times(times)
6506 .mount(server)
6507 .await;
6508 }
6509
6510 fn query_retry_after<T: std::fmt::Debug>(
6512 result: &Result<T, UnindexedClientError>,
6513 ) -> Option<Duration> {
6514 match result {
6515 Err(UnindexedClientError::Api(api @ ApiError::RateLimit { .. })) => api.retry_after(),
6516 other => panic!("expected a rate-limit refusal, got {other:?}"),
6517 }
6518 }
6519
6520 #[tokio::test]
6523 async fn a_rest_ban_is_not_retried_and_holds_back_later_requests() {
6524 let server = wiremock::MockServer::start().await;
6525 mount_refusal(&server, 418, Some("120"), u64::MAX).await;
6526 let (rest, tracker) = rest_against(&server);
6527
6528 let result = fetch_all_open_orders(Arc::clone(&rest), Arc::clone(&tracker)).await;
6529 assert_eq!(query_retry_after(&result), Some(Duration::from_secs(120)));
6530 assert!(
6531 tracker
6532 .cooldown_remaining()
6533 .is_some_and(|left| left > Duration::from_secs(115)),
6534 "{:?}",
6535 tracker.cooldown_remaining()
6536 );
6537
6538 let result = fetch_all_open_orders(rest, tracker).await;
6539 assert!(
6540 query_retry_after(&result).is_some_and(
6541 |left| left > Duration::from_secs(115) && left <= Duration::from_secs(120)
6542 ),
6543 "{result:?}"
6544 );
6545 assert_eq!(server.received_requests().await.unwrap().len(), 1);
6546 }
6547
6548 #[tokio::test]
6551 async fn a_rest_ban_without_retry_after_is_not_retried() {
6552 let server = wiremock::MockServer::start().await;
6553 mount_refusal(&server, 418, None, u64::MAX).await;
6554 let (rest, tracker) = rest_against(&server);
6555
6556 let result = fetch_all_open_orders(rest, Arc::clone(&tracker)).await;
6557 assert_eq!(query_retry_after(&result), None);
6558 assert_eq!(server.received_requests().await.unwrap().len(), 1);
6559 assert!(
6560 tracker
6561 .cooldown_remaining()
6562 .is_some_and(|left| left > Duration::from_secs(DEFAULT_RATE_LIMIT_DELAY_SECS - 5)),
6563 "{:?}",
6564 tracker.cooldown_remaining()
6565 );
6566 }
6567
6568 #[tokio::test]
6570 async fn a_rest_throttle_with_a_long_retry_after_is_not_retried() {
6571 let server = wiremock::MockServer::start().await;
6572 mount_refusal(&server, 429, Some("30"), u64::MAX).await;
6573 let (rest, tracker) = rest_against(&server);
6574
6575 let result = fetch_all_open_orders(rest, tracker).await;
6576 assert_eq!(query_retry_after(&result), Some(Duration::from_secs(30)));
6577 assert_eq!(server.received_requests().await.unwrap().len(), 1);
6578 }
6579
6580 #[tokio::test]
6583 async fn a_rest_throttle_with_a_short_retry_after_is_retried_after_it() {
6584 let server = wiremock::MockServer::start().await;
6585 mount_refusal(&server, 429, Some("2"), 1).await;
6586 wiremock::Mock::given(wiremock::matchers::method("GET"))
6587 .and(wiremock::matchers::path("/api/v3/openOrders"))
6588 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6589 .mount(&server)
6590 .await;
6591 let (rest, tracker) = rest_against(&server);
6592
6593 let started = std::time::Instant::now();
6594 let orders = fetch_all_open_orders(rest, tracker).await.unwrap();
6595
6596 assert!(orders.is_empty());
6597 assert_eq!(server.received_requests().await.unwrap().len(), 2);
6598 assert!(
6599 started.elapsed() >= Duration::from_secs(2),
6600 "{:?}",
6601 started.elapsed()
6602 );
6603 }
6604
6605 #[tokio::test]
6608 async fn a_handshake_refused_with_429_starts_a_cooldown() {
6609 use tokio_tungstenite::tungstenite::http;
6610
6611 let url = ws_server(|stream| async move {
6612 #[allow(clippy::result_large_err)]
6614 let refuse = |_: &http::Request<()>, _| {
6615 Err(http::Response::builder()
6616 .status(http::StatusCode::TOO_MANY_REQUESTS)
6617 .body(None)
6618 .unwrap())
6619 };
6620 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
6621 })
6622 .await;
6623 let client = client_with_ws_api(url);
6624 let instrument = InstrumentNameExchange::new("BTCUSDT");
6625
6626 let response = client.cancel_order(cancel_request(&instrument)).await;
6627
6628 assert!(
6629 matches!(
6630 response.state,
6631 Err(UnindexedOrderError::Rejected(ApiError::RateLimit { .. }))
6632 ),
6633 "{:?}",
6634 response.state
6635 );
6636 assert!(client.rate_limiter.is_blocked());
6637 }
6638
6639 #[tokio::test]
6642 async fn a_handshake_refused_with_418_starts_the_shortest_ban() {
6643 use tokio_tungstenite::tungstenite::http;
6644
6645 let url = ws_server(|stream| async move {
6646 #[allow(clippy::result_large_err)]
6648 let refuse = |_: &http::Request<()>, _| {
6649 Err(http::Response::builder()
6650 .status(http::StatusCode::IM_A_TEAPOT)
6651 .body(None)
6652 .unwrap())
6653 };
6654 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
6655 })
6656 .await;
6657 let client = client_with_ws_api(url);
6658 let instrument = InstrumentNameExchange::new("BTCUSDT");
6659
6660 let response = client.cancel_order(cancel_request(&instrument)).await;
6661
6662 assert_rate_limited_for(&response.state, MIN_IP_BAN);
6663 assert_cooldown_about(&client, MIN_IP_BAN);
6664 }
6665
6666 #[tokio::test]
6669 async fn a_ws_api_response_near_the_weight_limit_pauses_queries() {
6670 use futures::{SinkExt as _, StreamExt as _};
6671 use tokio_tungstenite::tungstenite::Message;
6672
6673 let url = ws_server(|stream| async move {
6674 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
6675 return;
6676 };
6677 while let Some(Ok(message)) = ws.next().await {
6678 let Message::Text(text) = message else {
6679 continue;
6680 };
6681 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
6682 let response = serde_json::json!({
6683 "id": request["id"],
6684 "status": 200,
6685 "result": {
6686 "symbol": "BTCUSDT",
6687 "origClientOrderId": "cid",
6688 "orderId": 7,
6689 "transactTime": 1_700_000_000_000_i64,
6690 "executedQty": "0",
6691 },
6692 "rateLimits": [{
6693 "rateLimitType": "REQUEST_WEIGHT",
6694 "interval": "MINUTE",
6695 "intervalNum": 1,
6696 "limit": 1_000,
6697 "count": 950,
6698 }],
6699 });
6700 if ws.send(Message::text(response.to_string())).await.is_err() {
6701 return;
6702 }
6703 }
6704 })
6705 .await;
6706 let client = client_with_ws_api(url);
6707 let instrument = InstrumentNameExchange::new("BTCUSDT");
6708
6709 let response = client.cancel_order(cancel_request(&instrument)).await;
6710
6711 assert!(response.state.is_ok(), "{:?}", response.state);
6712 assert_eq!(client.rate_limiter.weight_limit(), 1_000);
6713 assert!(
6714 tokio::time::timeout(
6715 Duration::from_millis(50),
6716 client.rate_limiter.wait_if_blocked(RequestKind::Query),
6717 )
6718 .await
6719 .is_err(),
6720 "queries pause"
6721 );
6722 tokio::time::timeout(
6723 Duration::from_millis(50),
6724 client.rate_limiter.wait_if_blocked(RequestKind::Order),
6725 )
6726 .await
6727 .expect("orders never wait on the pause")
6728 .expect("no cooldown too long to sleep through");
6729 }
6730
6731 async fn ws_server_reporting_close(
6734 result: serde_json::Value,
6735 ) -> (String, mpsc::UnboundedReceiver<()>) {
6736 use futures::{SinkExt as _, StreamExt as _};
6737 use tokio_tungstenite::tungstenite::Message;
6738
6739 let (closed_tx, closed_rx) = mpsc::unbounded_channel();
6740 let url = ws_server(move |stream| {
6741 let result = result.clone();
6742 let closed_tx = closed_tx.clone();
6743 async move {
6744 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
6745 return;
6746 };
6747 while let Some(Ok(message)) = ws.next().await {
6748 match message {
6749 Message::Text(text) => {
6750 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
6751 let response = serde_json::json!({
6752 "id": request["id"],
6753 "status": 200,
6754 "result": result,
6755 });
6756 if ws.send(Message::text(response.to_string())).await.is_err() {
6757 return;
6758 }
6759 }
6760 Message::Close(_) => {
6761 let _ = closed_tx.send(());
6762 return;
6763 }
6764 _ => {}
6765 }
6766 }
6767 }
6768 })
6769 .await;
6770 (url, closed_rx)
6771 }
6772
6773 #[tokio::test]
6779 async fn dropping_the_account_stream_closes_its_connection() {
6780 for running in [Duration::ZERO, Duration::from_millis(100)] {
6781 let (url, mut closed) =
6782 ws_server_reporting_close(serde_json::json!({ "subscriptionId": 0 })).await;
6783 let client = client_with_ws_api(url);
6784 let holders = client.known_live.holders();
6785
6786 let stream = client.account_stream(&[], &[]).await.unwrap();
6787 tokio::time::sleep(running).await;
6788 drop(stream);
6789
6790 tokio::time::timeout(Duration::from_secs(5), closed.recv())
6791 .await
6792 .expect("the dropped stream closes its connection")
6793 .unwrap();
6794 for _ in 0..10 {
6796 tokio::task::yield_now().await;
6797 }
6798 assert_eq!(
6799 client.known_live.holders(),
6800 holders,
6801 "the stream's state is released"
6802 );
6803 }
6804 }
6805
6806 #[tokio::test]
6809 async fn dropping_the_last_client_closes_its_order_session() {
6810 let (url, mut closed) = ws_server_reporting_close(serde_json::json!({
6811 "symbol": "BTCUSDT",
6812 "origClientOrderId": "cid",
6813 "orderId": 7,
6814 "transactTime": 1_700_000_000_000_i64,
6815 "executedQty": "0",
6816 }))
6817 .await;
6818 let client = client_with_ws_api(url);
6819 let clone = client.clone();
6820 let instrument = InstrumentNameExchange::new("BTCUSDT");
6821 let response = client.cancel_order(cancel_request(&instrument)).await;
6822 assert!(response.state.is_ok(), "{:?}", response.state);
6823
6824 drop(client);
6825 assert!(
6826 tokio::time::timeout(Duration::from_millis(200), closed.recv())
6827 .await
6828 .is_err(),
6829 "a clone still holds the session"
6830 );
6831
6832 drop(clone);
6833 tokio::time::timeout(Duration::from_secs(5), closed.recv())
6834 .await
6835 .expect("the last client's drop closes the session")
6836 .unwrap();
6837 }
6838
6839 #[tokio::test]
6842 async fn fill_recovery_does_not_wait_for_the_weight_pause() {
6843 let server = wiremock::MockServer::start().await;
6844 wiremock::Mock::given(wiremock::matchers::method("GET"))
6845 .and(wiremock::matchers::path("/api/v3/myTrades"))
6846 .respond_with(
6848 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
6849 "symbol": "BTCUSDT",
6850 "id": 1,
6851 "orderId": 7,
6852 "price": "100",
6853 "qty": "1",
6854 "commission": "0",
6855 "commissionAsset": "USDT",
6856 "time": Utc::now().timestamp_millis(),
6857 "isBuyer": true,
6858 "isMaker": false,
6859 }])),
6860 )
6861 .expect(2)
6862 .mount(&server)
6863 .await;
6864 let rest = Arc::new(SpotRestApi::from_config(
6865 ConfigurationRestApi::builder()
6866 .api_key("key")
6867 .api_secret("secret")
6868 .base_path(server.uri())
6869 .build()
6870 .unwrap(),
6871 ));
6872 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6873 tracker.throttle(Duration::from_secs(60));
6874 let (tx, _rx) = mpsc::unbounded_channel();
6875 let mut unrecovered = UnrecoveredFills::default();
6876 unrecovered.open(
6877 &[InstrumentNameExchange::new("BTCUSDT")],
6878 Utc::now() - chrono::Duration::minutes(1),
6879 Utc::now(),
6880 );
6881
6882 tokio::time::timeout(
6884 Duration::from_millis(800),
6885 recover_fills(
6886 &rest,
6887 &tracker,
6888 &mut unrecovered,
6889 &tx,
6890 &new_dedup_cache(),
6891 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6892 ),
6893 )
6894 .await
6895 .expect("recovery does not wait for the pause");
6896 }
6897
6898 #[tokio::test]
6900 async fn a_gap_whose_every_read_fails_is_reported_when_given_up() {
6901 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6902 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6903 let server = wiremock::MockServer::start().await;
6904 wiremock::Mock::given(wiremock::matchers::method("GET"))
6905 .and(wiremock::matchers::path("/api/v3/myTrades"))
6906 .respond_with(
6907 wiremock::ResponseTemplate::new(400)
6908 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6909 )
6910 .mount(&server)
6911 .await;
6912 let rest = Arc::new(SpotRestApi::from_config(
6913 ConfigurationRestApi::builder()
6914 .api_key("key")
6915 .api_secret("secret")
6916 .base_path(server.uri())
6917 .build()
6918 .expect("valid config"),
6919 ));
6920 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
6921 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
6922 let dedup = new_dedup_cache();
6923 let btc = InstrumentNameExchange::new("BTCUSDT");
6924 let mut unrecovered = UnrecoveredFills::default();
6925 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
6926
6927 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
6928 recover_fills(
6929 &rest,
6930 &tracker,
6931 &mut unrecovered,
6932 &tx,
6933 &dedup,
6934 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
6935 )
6936 .await;
6937 unrecovered.make_due();
6938 }
6939
6940 assert!(unrecovered.is_empty(), "the gap is given up");
6941 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6942 let [event] = forwarded.as_slice() else {
6943 panic!("only the give-up is sent: {forwarded:?}");
6944 };
6945 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
6946 panic!("expected FillRecoveryGaveUp, got {event:?}");
6947 };
6948 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
6949 assert_eq!(
6950 gave_up.scope,
6951 crate::FillRecoveryScope::Instruments(vec![btc])
6952 );
6953 assert_eq!(
6954 gave_up.start.timestamp_millis(),
6955 disconnect.timestamp_millis()
6956 );
6957 assert_eq!(
6958 gave_up.attempts,
6959 crate::client::order_recovery::MAX_GAP_RETRIES + 1
6960 );
6961 assert!(
6962 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
6963 "the last read's error: {:?}",
6964 gave_up.reason
6965 );
6966 }
6967
6968 #[tokio::test]
6971 async fn a_failed_recovery_keeps_its_closed_gap_for_the_retry() {
6972 let disconnect = Utc::now() - chrono::Duration::minutes(10);
6973 let reconnect = Utc::now() - chrono::Duration::minutes(5);
6974 let trade = |id: i64, time: DateTime<Utc>| {
6975 serde_json::json!({
6976 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
6977 "commission": "0", "commissionAsset": "USDT", "time": time.timestamp_millis(),
6978 "isBuyer": true, "isMaker": false, "isBestMatch": true,
6979 })
6980 };
6981 let server = wiremock::MockServer::start().await;
6982 wiremock::Mock::given(wiremock::matchers::method("GET"))
6983 .and(wiremock::matchers::path("/api/v3/myTrades"))
6984 .respond_with(
6985 wiremock::ResponseTemplate::new(400)
6986 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
6987 )
6988 .up_to_n_times(1)
6989 .with_priority(1)
6990 .mount(&server)
6991 .await;
6992 wiremock::Mock::given(wiremock::matchers::method("GET"))
6993 .and(wiremock::matchers::path("/api/v3/myTrades"))
6994 .respond_with(
6995 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
6996 trade(1, disconnect + chrono::Duration::minutes(1)),
6997 trade(2, Utc::now() - chrono::Duration::minutes(1)),
6998 ])),
6999 )
7000 .mount(&server)
7001 .await;
7002 let rest = Arc::new(SpotRestApi::from_config(
7003 ConfigurationRestApi::builder()
7004 .api_key("key")
7005 .api_secret("secret")
7006 .base_path(server.uri())
7007 .build()
7008 .unwrap(),
7009 ));
7010 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
7011 let (tx, mut rx) = mpsc::unbounded_channel();
7012 let dedup = new_dedup_cache();
7013 let mut unrecovered = UnrecoveredFills::default();
7014 unrecovered.open(
7015 &[InstrumentNameExchange::new("BTCUSDT")],
7016 disconnect,
7017 reconnect,
7018 );
7019
7020 recover_fills(
7021 &rest,
7022 &tracker,
7023 &mut unrecovered,
7024 &tx,
7025 &dedup,
7026 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7027 )
7028 .await;
7029 assert!(!unrecovered.is_empty(), "the failed gap is kept");
7030 assert!(rx.try_recv().is_err(), "nothing forwarded");
7031 assert!(
7032 unrecovered.due(tokio::time::Instant::now()).is_empty(),
7033 "and waits for its retry"
7034 );
7035
7036 unrecovered.make_due();
7037 recover_fills(
7038 &rest,
7039 &tracker,
7040 &mut unrecovered,
7041 &tx,
7042 &dedup,
7043 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7044 )
7045 .await;
7046 assert!(unrecovered.is_empty(), "the retry recovered it");
7047 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7048 assert_eq!(
7049 forwarded.len(),
7050 1,
7051 "only the fill inside the gap: {forwarded:?}"
7052 );
7053
7054 let start_times: Vec<String> = server
7055 .received_requests()
7056 .await
7057 .unwrap()
7058 .iter()
7059 .filter_map(|request| {
7060 request
7061 .url
7062 .query_pairs()
7063 .find(|(key, _)| key == "startTime")
7064 .map(|(_, value)| value.into_owned())
7065 })
7066 .collect();
7067 let start = disconnect.timestamp_millis().to_string();
7068 assert_eq!(start_times, [start.clone(), start]);
7069 }
7070
7071 #[tokio::test]
7074 async fn recovered_fills_carry_their_orders_client_order_ids() {
7075 let disconnect = Utc::now() - chrono::Duration::minutes(10);
7076 let reconnect = Utc::now() - chrono::Duration::minutes(5);
7077 let trade = |id: i64| {
7078 serde_json::json!({
7079 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
7080 "commission": "0", "commissionAsset": "USDT",
7081 "time": (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
7082 "isBuyer": true, "isMaker": false, "isBestMatch": true,
7083 })
7084 };
7085 let server = wiremock::MockServer::start().await;
7086 for id in [1, 2] {
7088 wiremock::Mock::given(wiremock::matchers::method("GET"))
7089 .and(wiremock::matchers::path("/api/v3/myTrades"))
7090 .and(wiremock::matchers::query_param("orderId", id.to_string()))
7091 .respond_with(
7092 wiremock::ResponseTemplate::new(200)
7093 .set_body_json(serde_json::json!([trade(id)])),
7094 )
7095 .with_priority(1)
7096 .mount(&server)
7097 .await;
7098 }
7099 wiremock::Mock::given(wiremock::matchers::method("GET"))
7100 .and(wiremock::matchers::path("/api/v3/myTrades"))
7101 .respond_with(
7102 wiremock::ResponseTemplate::new(200)
7103 .set_body_json(serde_json::json!([trade(1), trade(2)])),
7104 )
7105 .mount(&server)
7106 .await;
7107 wiremock::Mock::given(wiremock::matchers::method("GET"))
7108 .and(wiremock::matchers::path("/api/v3/order"))
7109 .and(wiremock::matchers::query_param("orderId", "1"))
7110 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7111 serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "sweep-1"}),
7112 ))
7113 .mount(&server)
7114 .await;
7115 wiremock::Mock::given(wiremock::matchers::method("GET"))
7116 .and(wiremock::matchers::path("/api/v3/order"))
7117 .and(wiremock::matchers::query_param("orderId", "2"))
7118 .respond_with(
7119 wiremock::ResponseTemplate::new(400).set_body_json(
7120 serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
7121 ),
7122 )
7123 .mount(&server)
7124 .await;
7125 let rest = Arc::new(SpotRestApi::from_config(
7126 ConfigurationRestApi::builder()
7127 .api_key("key")
7128 .api_secret("secret")
7129 .base_path(server.uri())
7130 .build()
7131 .unwrap(),
7132 ));
7133 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
7134 let (tx, mut rx) = mpsc::unbounded_channel();
7135 let mut unrecovered = UnrecoveredFills::default();
7136 unrecovered.open(
7137 &[InstrumentNameExchange::new("BTCUSDT")],
7138 disconnect,
7139 reconnect,
7140 );
7141
7142 recover_fills(
7143 &rest,
7144 &tracker,
7145 &mut unrecovered,
7146 &tx,
7147 &new_dedup_cache(),
7148 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7149 )
7150 .await;
7151
7152 let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
7153 .filter_map(|event| match event.kind {
7154 AccountEventKind::Trade(trade) => Some((
7155 trade.id.0.to_string(),
7156 trade.client_order_id,
7157 trade.order_filled_quantity,
7158 )),
7159 _ => None,
7160 })
7161 .collect();
7162 cids.sort();
7163 assert_eq!(
7165 cids,
7166 [
7167 (
7168 "1".to_string(),
7169 Some(ClientOrderId::new("sweep-1")),
7170 Some(Decimal::ONE)
7171 ),
7172 ("2".to_string(), None, Some(Decimal::ONE)),
7173 ]
7174 );
7175 let order_reads = server
7176 .received_requests()
7177 .await
7178 .unwrap()
7179 .iter()
7180 .filter(|request| request.url.path() == "/api/v3/order")
7181 .count();
7182 assert_eq!(order_reads, 2, "one read per order");
7183 }
7184
7185 #[tokio::test]
7188 async fn a_span_walk_pages_by_id_and_stops_past_the_end() {
7189 let start = Utc::now().timestamp_millis() - 600_000;
7190 let end = start + 10_000;
7191 let trade = |id: i64, time: i64| {
7192 serde_json::json!({
7193 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
7194 "commission": "0", "commissionAsset": "USDT", "time": time,
7195 "isBuyer": true, "isMaker": false, "isBestMatch": true,
7196 })
7197 };
7198 let server = wiremock::MockServer::start().await;
7199 wiremock::Mock::given(wiremock::matchers::method("GET"))
7200 .and(wiremock::matchers::path("/api/v3/myTrades"))
7201 .and(wiremock::matchers::query_param("fromId", "1001"))
7202 .respond_with(
7204 wiremock::ResponseTemplate::new(200).set_body_json(
7205 (1_001..=2_000)
7206 .map(|id| trade(id, if id == 1_001 { end } else { end + 1 }))
7207 .collect::<Vec<_>>(),
7208 ),
7209 )
7210 .with_priority(1)
7211 .mount(&server)
7212 .await;
7213 let full_page: Vec<_> = (1..=1_000).map(|id| trade(id, start + id)).collect();
7214 wiremock::Mock::given(wiremock::matchers::method("GET"))
7215 .and(wiremock::matchers::path("/api/v3/myTrades"))
7216 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(full_page))
7217 .mount(&server)
7218 .await;
7219 let rest = Arc::new(SpotRestApi::from_config(
7220 ConfigurationRestApi::builder()
7221 .api_key("key")
7222 .api_secret("secret")
7223 .base_path(server.uri())
7224 .build()
7225 .unwrap(),
7226 ));
7227
7228 let read = paginate_my_trades(
7229 &rest,
7230 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7231 &InstrumentNameExchange::new("BTCUSDT"),
7232 MyTradesFrom::Span { start, end },
7233 RequestKind::Query,
7234 )
7235 .await
7236 .unwrap();
7237
7238 let ids: Vec<_> = read.iter().filter_map(|t| t.id).collect();
7239 assert_eq!(ids, (1..=1_001).collect::<Vec<_>>());
7240 assert_eq!(server.received_requests().await.unwrap().len(), 2);
7241 }
7242
7243 #[tokio::test]
7246 async fn fetch_trades_returns_only_the_span() {
7247 let start = DateTime::from_timestamp_millis(
7249 (Utc::now() - chrono::Duration::minutes(10)).timestamp_millis(),
7250 )
7251 .unwrap()
7252 + chrono::Duration::microseconds(500);
7253 let end = start + chrono::Duration::seconds(10);
7254 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
7255 let trade = |id: i64, time: i64| {
7256 serde_json::json!({
7257 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
7258 "commission": "0", "commissionAsset": "USDT", "time": time,
7259 "isBuyer": true, "isMaker": false, "isBestMatch": true,
7260 })
7261 };
7262 let server = wiremock::MockServer::start().await;
7263 wiremock::Mock::given(wiremock::matchers::method("GET"))
7264 .and(wiremock::matchers::path("/api/v3/myTrades"))
7265 .and(wiremock::matchers::query_param(
7266 "startTime",
7267 start_ms.to_string(),
7268 ))
7269 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(vec![
7270 trade(1, start_ms),
7271 trade(2, start_ms + 1),
7272 trade(3, end_ms),
7273 trade(4, end_ms + 1),
7274 ]))
7275 .mount(&server)
7276 .await;
7277 let mut client = <BinanceSpot as ExecutionClient>::new(
7278 BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7279 );
7280 client.rest = Arc::new(SpotRestApi::from_config(
7281 ConfigurationRestApi::builder()
7282 .api_key("key")
7283 .api_secret("secret")
7284 .base_path(server.uri())
7285 .build()
7286 .unwrap(),
7287 ));
7288 let btc = [InstrumentNameExchange::new("BTCUSDT")];
7289
7290 let read = client.fetch_trades(start, end, &btc).await.unwrap();
7291 let ids: Vec<_> = read.trades.iter().map(|t| t.id.0.as_str()).collect();
7292 assert_eq!(ids, ["2", "3"]);
7293 assert_eq!(read.resume, None, "spot reads a span whole");
7294
7295 let empty = client.fetch_trades(end, start, &btc).await.unwrap();
7296 assert!(empty.trades.is_empty() && empty.resume.is_none());
7297 assert_eq!(server.received_requests().await.unwrap().len(), 1);
7298 }
7299
7300 #[tokio::test]
7302 async fn a_gap_whose_every_read_times_out_is_reported_when_given_up() {
7303 let server = wiremock::MockServer::start().await;
7304 wiremock::Mock::given(wiremock::matchers::method("GET"))
7305 .and(wiremock::matchers::path("/api/v3/myTrades"))
7306 .respond_with(
7307 wiremock::ResponseTemplate::new(200)
7308 .set_body_json(serde_json::json!([]))
7309 .set_delay(Duration::from_secs(3_600)),
7310 )
7311 .mount(&server)
7312 .await;
7313 let rest = Arc::new(SpotRestApi::from_config(
7314 ConfigurationRestApi::builder()
7315 .api_key("key")
7316 .api_secret("secret")
7317 .base_path(server.uri())
7318 .timeout(3_600_000_u64)
7319 .retries(0_u32)
7320 .build()
7321 .expect("valid config"),
7322 ));
7323 let btc = InstrumentNameExchange::new("BTCUSDT");
7324 let mut unrecovered = UnrecoveredFills::default();
7325 unrecovered.open(
7326 std::slice::from_ref(&btc),
7327 Utc::now() - chrono::Duration::minutes(10),
7328 Utc::now(),
7329 );
7330 let (tx, mut rx) = mpsc::unbounded_channel();
7331 tokio::time::pause();
7333
7334 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
7335 recover_fills(
7336 &rest,
7337 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7338 &mut unrecovered,
7339 &tx,
7340 &new_dedup_cache(),
7341 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7342 )
7343 .await;
7344 unrecovered.make_due();
7345 }
7346
7347 assert!(unrecovered.is_empty(), "the gap is given up");
7348 let Ok(event) = rx.try_recv() else {
7349 panic!("the given-up gap is reported");
7350 };
7351 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
7352 panic!("expected FillRecoveryGaveUp, got {event:?}");
7353 };
7354 assert_eq!(
7355 gave_up.scope,
7356 crate::FillRecoveryScope::Instruments(vec![btc])
7357 );
7358 assert_eq!(
7359 gave_up.reason,
7360 FillRecoveryFailure::TimedOut {
7361 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS
7362 }
7363 );
7364 assert!(rx.try_recv().is_err(), "reported once");
7365 }
7366
7367 #[tokio::test]
7370 async fn a_timed_out_recovery_charges_only_the_gaps_it_started() {
7371 let server = wiremock::MockServer::start().await;
7372 wiremock::Mock::given(wiremock::matchers::method("GET"))
7373 .and(wiremock::matchers::path("/api/v3/myTrades"))
7374 .respond_with(
7375 wiremock::ResponseTemplate::new(200)
7376 .set_body_json(serde_json::json!([]))
7377 .set_delay(Duration::from_secs(3_600)),
7378 )
7379 .mount(&server)
7380 .await;
7381 let rest = Arc::new(SpotRestApi::from_config(
7382 ConfigurationRestApi::builder()
7383 .api_key("key")
7384 .api_secret("secret")
7385 .base_path(server.uri())
7386 .timeout(3_600_000_u64)
7387 .retries(0_u32)
7388 .build()
7389 .unwrap(),
7390 ));
7391 let instruments: Vec<_> = (0..9)
7392 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
7393 .collect();
7394 let mut unrecovered = UnrecoveredFills::default();
7395 unrecovered.open(
7396 &instruments,
7397 Utc::now() - chrono::Duration::minutes(10),
7398 Utc::now(),
7399 );
7400 let (tx, _rx) = mpsc::unbounded_channel();
7401 tokio::time::pause();
7403
7404 recover_fills(
7405 &rest,
7406 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7407 &mut unrecovered,
7408 &tx,
7409 &new_dedup_cache(),
7410 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
7411 )
7412 .await;
7413
7414 let now = tokio::time::Instant::now();
7415 assert_eq!(
7416 unrecovered.due(now).len(),
7417 1,
7418 "the gap never started stays due"
7419 );
7420 assert_eq!(
7421 unrecovered
7422 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
7423 .len(),
7424 9,
7425 "the eight started wait for their first retry"
7426 );
7427 }
7428
7429 fn order_row(cid: &str, status: &str) -> serde_json::Value {
7435 serde_json::json!({
7436 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
7437 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
7438 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "time": 1_700_000_000_000_i64,
7439 "updateTime": 1_700_000_060_000_i64,
7440 })
7441 }
7442
7443 fn get_order_row(row: serde_json::Value) -> binance_sdk::spot::rest_api::GetOrderResponse {
7444 serde_json::from_value(row).unwrap()
7445 }
7446
7447 fn spot_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
7448 OrderKey::new(
7449 ExchangeId::BinanceSpot,
7450 InstrumentNameExchange::new(instrument),
7451 StrategyId::new("strategy"),
7452 ClientOrderId::new(cid),
7453 )
7454 }
7455
7456 fn ended_state(
7457 key: &UnindexedOrderKey,
7458 row: serde_json::Value,
7459 ) -> Option<crate::order::state::UnindexedInactiveOrderState> {
7460 let order = convert_ended_order(&get_order_row(row), ExchangeId::BinanceSpot, key, None)?;
7461 assert_eq!(&order.key, key, "reported under the key asked");
7462 assert_eq!(order.quantity, Decimal::TWO);
7463 Some(order.state)
7464 }
7465
7466 #[test]
7469 fn an_ended_order_row_without_a_fill_reports_it_unknown() {
7470 use crate::order::state::{Expired, Filled, InactiveOrderState};
7471 let key = spot_key("BTCUSDT", "a");
7472 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
7473 let id = OrderId::new("7");
7474 let row = |status: &str, executed_qty: Option<&str>| {
7475 let mut row = order_row("a", status);
7476 row["executedQty"] = executed_qty.map_or(serde_json::Value::Null, Into::into);
7477 row
7478 };
7479
7480 for executed_qty in [None, Some("not a number")] {
7481 assert_eq!(
7482 ended_state(&key, row("CANCELED", executed_qty)),
7483 Some(InactiveOrderState::Cancelled(Cancelled::new(
7484 id.clone(),
7485 ended,
7486 None
7487 ))),
7488 "{executed_qty:?}"
7489 );
7490 assert_eq!(
7491 ended_state(&key, row("EXPIRED", executed_qty)),
7492 Some(InactiveOrderState::Expired(Expired::new(
7493 id.clone(),
7494 ended,
7495 None
7496 ))),
7497 "{executed_qty:?}"
7498 );
7499 assert_eq!(
7500 ended_state(&key, row("FILLED", executed_qty)),
7501 Some(InactiveOrderState::FullyFilled(Filled::new(
7502 id.clone(),
7503 ended,
7504 Decimal::TWO,
7505 Some(Decimal::new(525, 1)),
7506 ))),
7507 "the whole quantity, at the quote over it: {executed_qty:?}"
7508 );
7509 }
7510 }
7511
7512 #[test]
7513 fn an_ended_order_row_says_how_the_order_ended() {
7514 use crate::order::state::{Expired, Filled, InactiveOrderState};
7515 let key = spot_key("BTCUSDT", "a");
7516 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
7517 let id = OrderId::new("7");
7518
7519 let mut filled = order_row("a", "FILLED");
7520 filled["executedQty"] = "2".into();
7521 filled["cummulativeQuoteQty"] = "210".into();
7522 assert_eq!(
7523 ended_state(&key, filled),
7524 Some(InactiveOrderState::FullyFilled(Filled::new(
7525 id.clone(),
7526 ended,
7527 Decimal::TWO,
7528 Some(Decimal::from(105)),
7529 )))
7530 );
7531 assert_eq!(
7532 ended_state(&key, order_row("a", "CANCELED")),
7533 Some(InactiveOrderState::Cancelled(Cancelled::new(
7534 id.clone(),
7535 ended,
7536 Some(Decimal::ONE)
7537 )))
7538 );
7539 for status in ["EXPIRED", "EXPIRED_IN_MATCH"] {
7540 assert_eq!(
7541 ended_state(&key, order_row("a", status)),
7542 Some(InactiveOrderState::Expired(Expired::new(
7543 id.clone(),
7544 ended,
7545 Some(Decimal::ONE)
7546 ))),
7547 "{status}"
7548 );
7549 }
7550 assert!(matches!(
7551 ended_state(&key, order_row("a", "REJECTED")),
7552 Some(InactiveOrderState::OpenFailed(OrderError::Rejected(
7553 ApiError::OrderRejected(_)
7554 )))
7555 ));
7556 }
7557
7558 #[test]
7559 fn a_live_or_unreadable_order_row_is_not_ended() {
7560 let key = spot_key("BTCUSDT", "a");
7561 for status in [
7562 "NEW",
7563 "PARTIALLY_FILLED",
7564 "PENDING_NEW",
7565 "PENDING_CANCEL",
7566 "SOMETHING_NEW",
7567 ] {
7568 assert_eq!(ended_state(&key, order_row("a", status)), None, "{status}");
7569 }
7570 let mut no_status = order_row("a", "FILLED");
7571 no_status["status"] = serde_json::Value::Null;
7572 assert_eq!(ended_state(&key, no_status), None);
7573 let mut no_side = order_row("a", "FILLED");
7574 no_side["side"] = serde_json::Value::Null;
7575 assert_eq!(ended_state(&key, no_side), None);
7576 }
7577
7578 #[test]
7579 fn an_unknown_order_or_symbol_is_recognised_by_its_code() {
7580 use binance_sdk::common::errors::ConnectorError;
7581 let bad = |code| {
7582 anyhow::Error::new(ConnectorError::BadRequestError {
7583 msg: "no".into(),
7584 code: Some(code),
7585 })
7586 };
7587 assert!(is_unknown_order(&bad(-2013)));
7588 assert!(is_unknown_order(&bad(-1121)));
7589 assert!(!is_unknown_order(&bad(-1100)));
7590 assert!(is_unknown_order(&anyhow::Error::new(
7591 ConnectorError::NotFoundError {
7592 msg: "no".into(),
7593 code: Some(-2013),
7594 }
7595 )));
7596 assert!(!is_unknown_order(&anyhow::anyhow!("-2013 in text only")));
7597 }
7598
7599 #[tokio::test(start_paused = true)]
7602 async fn the_host_clock_lead_is_the_clients_server_time_reads() {
7603 let config = BinanceSpotConfig::testnet("key".into(), "secret".into());
7604 assert!(config.server_time_sync);
7605 assert!(
7606 BinanceSpot::new(config.clone())
7607 .rate_limiter
7608 .signing_clock()
7609 .is_some()
7610 );
7611 let mut client = BinanceSpot::new(config.with_server_time_sync(false));
7612 assert!(client.rate_limiter.signing_clock().is_none());
7613 assert_eq!(client.host_clock_lead(), None);
7614
7615 let host_ms = Utc::now().timestamp_millis();
7616 let read: crate::client::binance::server_time::ReadServerTime =
7617 Arc::new(move || Box::pin(async move { Ok(host_ms - 2_000) }));
7618 client.rate_limiter = Arc::new(
7619 RateLimitTracker::new(WeightPool::Spot)
7620 .with_server_time(Some(ServerTime::new(SigningClock::default(), read))),
7621 );
7622 client
7623 .rate_limiter
7624 .wait_if_blocked(RequestKind::Query)
7625 .await
7626 .unwrap();
7627
7628 let lead = client.host_clock_lead().unwrap();
7629 assert!(
7630 lead >= TimeDelta::seconds(2) && lead < TimeDelta::seconds(3),
7631 "{lead}"
7632 );
7633 }
7634
7635 fn rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
7636 Arc::new(SpotRestApi::from_config(
7637 ConfigurationRestApi::builder()
7638 .api_key("key")
7639 .api_secret("secret")
7640 .base_path(server.uri())
7641 .build()
7642 .unwrap(),
7643 ))
7644 }
7645
7646 async fn mount_order(
7648 server: &wiremock::MockServer,
7649 cid: &str,
7650 response: wiremock::ResponseTemplate,
7651 ) {
7652 wiremock::Mock::given(wiremock::matchers::method("GET"))
7653 .and(wiremock::matchers::path("/api/v3/order"))
7654 .and(wiremock::matchers::query_param("origClientOrderId", cid))
7655 .respond_with(response)
7656 .mount(server)
7657 .await;
7658 }
7659
7660 fn venue_error(code: i64) -> wiremock::ResponseTemplate {
7661 wiremock::ResponseTemplate::new(400)
7662 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
7663 }
7664
7665 fn row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
7666 wiremock::ResponseTemplate::new(200).set_body_json(order_row(cid, status))
7667 }
7668
7669 async fn looked_up(server: &wiremock::MockServer) -> Vec<String> {
7671 server
7672 .received_requests()
7673 .await
7674 .unwrap()
7675 .iter()
7676 .filter(|request| request.url.path() == "/api/v3/order")
7677 .filter_map(|request| {
7678 request
7679 .url
7680 .query_pairs()
7681 .find(|(key, _)| key == "origClientOrderId")
7682 .map(|(_, value)| value.into_owned())
7683 })
7684 .collect()
7685 }
7686
7687 #[tokio::test]
7692 async fn a_balance_read_is_stamped_the_rest_lag_before_its_request_was_sent() {
7693 use crate::client::binance::shared::REST_BALANCE_LAG;
7694 const ANSWER_DELAY: Duration = Duration::from_millis(200);
7695 let server = wiremock::MockServer::start().await;
7696 wiremock::Mock::given(wiremock::matchers::method("GET"))
7697 .and(wiremock::matchers::path("/api/v3/account"))
7698 .respond_with(
7699 wiremock::ResponseTemplate::new(429)
7700 .insert_header("retry-after", "1")
7701 .set_body_json(serde_json::json!({ "code": -1003, "msg": "Too much weight." })),
7702 )
7703 .up_to_n_times(1)
7704 .mount(&server)
7705 .await;
7706 wiremock::Mock::given(wiremock::matchers::method("GET"))
7707 .and(wiremock::matchers::path("/api/v3/account"))
7708 .respond_with(
7709 wiremock::ResponseTemplate::new(200)
7710 .set_body_json(serde_json::json!({
7711 "balances": [{ "asset": "BTC", "free": "1", "locked": "0" }],
7712 }))
7713 .set_delay(ANSWER_DELAY),
7714 )
7715 .mount(&server)
7716 .await;
7717 let mut client = <BinanceSpot as ExecutionClient>::new(
7718 BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7719 );
7720 client.rest = rest_at(&server);
7721
7722 let before = Utc::now();
7723 let fetched = client.fetch_balances(&[]).await.unwrap();
7724 let retried = Utc::now();
7725 let snapshot = client.account_snapshot(&[], &[]).await.unwrap();
7726 let after = Utc::now();
7727
7728 let within = |time, from, to| {
7729 assert!(
7730 from <= time + REST_BALANCE_LAG && time + REST_BALANCE_LAG + ANSWER_DELAY <= to,
7731 "{time} + the lag not within {from} ..= {to} - {ANSWER_DELAY:?}"
7732 );
7733 };
7734 assert_eq!((fetched.len(), snapshot.balances.len()), (1, 1));
7735 within(
7737 fetched[0].time_exchange,
7738 before + Duration::from_secs(1),
7739 retried,
7740 );
7741 within(snapshot.balances[0].time_exchange, retried, after);
7742 }
7743
7744 #[tokio::test]
7749 async fn a_balance_read_is_stamped_on_binances_clock_when_the_host_clock_leads() {
7750 use crate::client::binance::shared::REST_BALANCE_LAG;
7751 const LEAD: TimeDelta = TimeDelta::seconds(60);
7752 let server = wiremock::MockServer::start().await;
7753 wiremock::Mock::given(wiremock::matchers::method("GET"))
7754 .and(wiremock::matchers::path("/api/v3/account"))
7755 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7756 serde_json::json!({ "balances": [{ "asset": "BTC", "free": "1", "locked": "0" }] }),
7757 ))
7758 .mount(&server)
7759 .await;
7760 let mut client = <BinanceSpot as ExecutionClient>::new(
7761 BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7762 );
7763 client.rest = rest_at(&server);
7764 client.rate_limiter = Arc::new(
7765 RateLimitTracker::new(WeightPool::Spot)
7766 .with_server_time(Some(ServerTime::trailing_host(LEAD))),
7767 );
7768 let clock = StreamClock::shared(client.rate_limiter.signing_clock());
7769
7770 let before = Utc::now();
7771 let fetched = client.fetch_balances(&[]).await.unwrap();
7772 let snapshot = client.account_snapshot(&[], &[]).await.unwrap();
7773 let restated = read_spot_restatement(&client.rest, &client.rate_limiter, &[], &clock, None)
7774 .await
7775 .unwrap();
7776 let after = Utc::now();
7777
7778 let [restated] = restated.as_slice() else {
7779 panic!("one balance: {restated:?}");
7780 };
7781 let AccountEventKind::BalanceSnapshot(restated) = &restated.kind else {
7782 panic!("a balance snapshot: {restated:?}");
7783 };
7784 assert_eq!((fetched.len(), snapshot.balances.len()), (1, 1));
7785 for time in [
7786 fetched[0].time_exchange,
7787 snapshot.balances[0].time_exchange,
7788 restated.0.time_exchange,
7789 ] {
7790 let sent = time + REST_BALANCE_LAG + LEAD;
7793 assert!(
7794 before - ServerTime::TRAILING_HOST_BEHIND <= sent && sent <= after,
7795 "{time} + the lag and the lead not within {before} ..= {after}"
7796 );
7797 }
7798 }
7799
7800 #[tokio::test]
7803 async fn a_restatement_read_returns_the_requested_balances_as_snapshots() {
7804 use crate::client::binance::shared::REST_BALANCE_LAG;
7805 let server = wiremock::MockServer::start().await;
7806 wiremock::Mock::given(wiremock::matchers::method("GET"))
7807 .and(wiremock::matchers::path("/api/v3/account"))
7808 .respond_with(wiremock::ResponseTemplate::new(500))
7809 .up_to_n_times(1)
7810 .mount(&server)
7811 .await;
7812 wiremock::Mock::given(wiremock::matchers::method("GET"))
7813 .and(wiremock::matchers::path("/api/v3/account"))
7814 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7815 serde_json::json!({ "balances": [
7816 { "asset": "BTC", "free": "1", "locked": "0.5" },
7817 { "asset": "ETH", "free": "2", "locked": "0" },
7818 ] }),
7819 ))
7820 .mount(&server)
7821 .await;
7822 let (rest, limiter) = (
7823 rest_at(&server),
7824 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7825 );
7826 let clock = SharedStreamClock::default();
7827 let btc = [AssetNameExchange::new("BTC")];
7828
7829 let failed = read_spot_restatement(&rest, &limiter, &btc, &clock, None).await;
7830 assert!(failed.is_err(), "{failed:?}");
7831
7832 let before = Utc::now();
7833 let sent = read_spot_restatement(&rest, &limiter, &btc, &clock, None)
7834 .await
7835 .unwrap();
7836 let after = Utc::now();
7837
7838 let [event] = sent.as_slice() else {
7839 panic!("one balance: {sent:?}");
7840 };
7841 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
7842 let AccountEventKind::BalanceSnapshot(balance) = &event.kind else {
7843 panic!("a balance snapshot: {event:?}");
7844 };
7845 assert_eq!(balance.0.asset, btc[0]);
7846 assert_eq!(
7847 balance.0.balance,
7848 Balance::new(Decimal::new(15, 1), Decimal::ONE)
7849 );
7850 let read = balance.0.time_exchange + REST_BALANCE_LAG;
7851 assert!(
7852 before <= read && read <= after,
7853 "{read} not within {before} ..= {after}"
7854 );
7855 }
7856
7857 #[tokio::test]
7858 async fn fetch_ended_orders_reports_each_ended_order_under_its_key_and_omits_the_rest() {
7859 let server = wiremock::MockServer::start().await;
7860 mount_order(&server, "filled", row_response("filled", "FILLED")).await;
7861 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
7862 mount_order(&server, "live", row_response("live", "PARTIALLY_FILLED")).await;
7863 mount_order(&server, "unknown", venue_error(-2013)).await;
7864 mount_order(&server, "no-symbol", venue_error(-1121)).await;
7865 let mut client = <BinanceSpot as ExecutionClient>::new(
7866 BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7867 );
7868 client.rest = rest_at(&server);
7869 let keys = ["filled", "cancelled", "live", "unknown"].map(|cid| spot_key("BTCUSDT", cid));
7870
7871 let mut ended = client
7872 .fetch_ended_orders(&[keys.to_vec(), vec![spot_key("NOSUCH", "no-symbol")]].concat())
7873 .await
7874 .unwrap();
7875
7876 ended.sort_by(|a, b| a.key.cid.cmp(&b.key.cid));
7877 let reported: Vec<_> = ended.iter().map(|order| &order.key).collect();
7878 assert_eq!(
7879 reported,
7880 [&keys[1], &keys[0]],
7881 "the strategy asked survives"
7882 );
7883 assert!(matches!(
7884 ended[0].state,
7885 crate::order::state::InactiveOrderState::Cancelled(_)
7886 ));
7887 assert!(matches!(
7888 ended[1].state,
7889 crate::order::state::InactiveOrderState::FullyFilled(_)
7890 ));
7891 assert!(client.fetch_ended_orders(&[]).await.unwrap().is_empty());
7892 }
7893
7894 #[tokio::test]
7895 async fn fetch_ended_orders_fails_when_any_lookup_fails() {
7896 let server = wiremock::MockServer::start().await;
7897 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
7898 mount_order(&server, "refused", venue_error(-1100)).await;
7899 let mut client = <BinanceSpot as ExecutionClient>::new(
7900 BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
7901 );
7902 client.rest = rest_at(&server);
7903
7904 let result = client
7905 .fetch_ended_orders(&[
7906 spot_key("BTCUSDT", "cancelled"),
7907 spot_key("BTCUSDT", "refused"),
7908 ])
7909 .await;
7910
7911 assert!(
7912 matches!(
7913 result,
7914 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
7915 ),
7916 "{result:?}"
7917 );
7918 }
7919
7920 async fn venue_after_a_disconnect(delay: Duration) -> wiremock::MockServer {
7923 let server = wiremock::MockServer::start().await;
7924 wiremock::Mock::given(wiremock::matchers::method("GET"))
7925 .and(wiremock::matchers::path("/api/v3/openOrders"))
7926 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
7927 .respond_with(
7928 wiremock::ResponseTemplate::new(200)
7929 .set_body_json(serde_json::json!([order_row("live", "NEW")])),
7930 )
7931 .mount(&server)
7932 .await;
7933 mount_order(
7934 &server,
7935 "gone",
7936 row_response("gone", "CANCELED").set_delay(delay),
7937 )
7938 .await;
7939 mount_order(&server, "unknown", venue_error(-2013)).await;
7940 server
7941 }
7942
7943 fn held_orders() -> SharedKnownLiveOrders {
7945 let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
7946 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
7947 known.lock().live(
7948 &spot_key("BTCUSDT", cid),
7949 Decimal::TWO,
7950 OrderKind::Limit,
7951 &Open::new(
7952 VenueOrderId::Assigned(OrderId::new(n.to_string())),
7953 Utc::now(),
7954 Decimal::ZERO,
7955 ),
7956 );
7957 }
7958 known
7959 }
7960
7961 fn btcusdt_unchecked() -> UncheckedOrders {
7962 let mut unchecked = UncheckedOrders::default();
7963 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
7964 unchecked
7965 }
7966
7967 #[tokio::test]
7968 async fn a_reconnect_reports_how_an_unlisted_order_ended_and_forgets_an_unknown_one() {
7969 let server = venue_after_a_disconnect(Duration::ZERO).await;
7970 let known = held_orders();
7971 let mut unchecked = btcusdt_unchecked();
7972 let (tx, mut rx) = mpsc::unbounded_channel();
7973
7974 recover_spot_ended_orders(
7975 &rest_at(&server),
7976 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
7977 &known,
7978 &mut unchecked,
7979 &UnrecoveredFills::default(),
7980 &tx,
7981 )
7982 .await;
7983
7984 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7985 let [event] = sent.as_slice() else {
7986 panic!("one order ended: {sent:?}");
7987 };
7988 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
7989 panic!("an order snapshot: {event:?}");
7990 };
7991 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
7992 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
7993 assert!(matches!(
7994 snapshot.0.state,
7995 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
7996 ));
7997 let mut asked = looked_up(&server).await;
7998 asked.sort();
7999 assert_eq!(
8000 asked,
8001 ["gone", "unknown"],
8002 "a listed order is not looked up"
8003 );
8004 let known = known.lock();
8005 assert!(known.contains(&ClientOrderId::new("live")));
8006 assert!(!known.contains(&ClientOrderId::new("gone")));
8007 assert!(
8008 !known.contains(&ClientOrderId::new("unknown")),
8009 "asking again cannot help"
8010 );
8011 assert!(unchecked.is_empty());
8012 }
8013
8014 #[tokio::test]
8015 async fn an_order_the_stream_reports_ending_during_the_check_is_not_reported_again() {
8016 let server = venue_after_a_disconnect(Duration::from_millis(300)).await;
8017 let known = held_orders();
8018 let mut unchecked = btcusdt_unchecked();
8019 let (tx, mut rx) = mpsc::unbounded_channel();
8020 let (rest, tracker) = (
8021 rest_at(&server),
8022 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8023 );
8024 let unrecovered = UnrecoveredFills::default();
8025
8026 tokio::join!(
8027 recover_spot_ended_orders(&rest, &tracker, &known, &mut unchecked, &unrecovered, &tx,),
8028 async {
8029 tokio::time::sleep(Duration::from_millis(100)).await;
8030 known.lock().ended(&ClientOrderId::new("gone"));
8031 }
8032 );
8033
8034 assert!(rx.try_recv().is_err(), "the stream already reported it");
8035 assert!(unchecked.is_empty());
8036 }
8037
8038 #[tokio::test]
8039 async fn an_instrument_with_unrecovered_fills_is_not_checked_yet() {
8040 let server = venue_after_a_disconnect(Duration::ZERO).await;
8041 let known = held_orders();
8042 let mut unchecked = btcusdt_unchecked();
8043 let mut unrecovered = UnrecoveredFills::default();
8044 unrecovered.open(
8045 &[InstrumentNameExchange::new("BTCUSDT")],
8046 Utc::now() - chrono::Duration::minutes(1),
8047 Utc::now(),
8048 );
8049 let (tx, mut rx) = mpsc::unbounded_channel();
8050
8051 recover_spot_ended_orders(
8052 &rest_at(&server),
8053 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8054 &known,
8055 &mut unchecked,
8056 &unrecovered,
8057 &tx,
8058 )
8059 .await;
8060
8061 assert!(rx.try_recv().is_err());
8062 assert!(server.received_requests().await.unwrap().is_empty());
8063 assert!(!unchecked.is_empty(), "it waits for its fills");
8064 assert_eq!(unchecked.next_due(&unrecovered), None, "woken by the gap");
8065 }
8066
8067 #[tokio::test]
8068 async fn a_failed_check_sends_nothing_and_waits_for_its_retry() {
8069 let server = wiremock::MockServer::start().await;
8070 wiremock::Mock::given(wiremock::matchers::method("GET"))
8071 .and(wiremock::matchers::path("/api/v3/openOrders"))
8072 .respond_with(venue_error(-1100))
8073 .mount(&server)
8074 .await;
8075 let server_rest = rest_at(&server);
8076 let known = held_orders();
8077 let mut unchecked = btcusdt_unchecked();
8078 let (tx, mut rx) = mpsc::unbounded_channel();
8079
8080 recover_spot_ended_orders(
8081 &server_rest,
8082 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8083 &known,
8084 &mut unchecked,
8085 &UnrecoveredFills::default(),
8086 &tx,
8087 )
8088 .await;
8089
8090 assert!(rx.try_recv().is_err());
8091 assert!(!unchecked.is_empty());
8092 assert!(
8093 unchecked
8094 .next_due(&UnrecoveredFills::default())
8095 .is_some_and(|due| due > tokio::time::Instant::now()),
8096 "retried after a backoff"
8097 );
8098 assert!(
8099 known.lock().contains(&ClientOrderId::new("gone")),
8100 "still held"
8101 );
8102 }
8103
8104 #[tokio::test]
8105 async fn an_unlisted_order_the_lookup_finds_live_stays_held() {
8106 let server = wiremock::MockServer::start().await;
8107 wiremock::Mock::given(wiremock::matchers::method("GET"))
8108 .and(wiremock::matchers::path("/api/v3/openOrders"))
8109 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8110 .mount(&server)
8111 .await;
8112 for (cid, status) in [
8113 ("live", "NEW"),
8114 ("gone", "PENDING_CANCEL"),
8115 ("unknown", "WHAT"),
8116 ] {
8117 mount_order(&server, cid, row_response(cid, status)).await;
8118 }
8119 let known = held_orders();
8120 let mut unchecked = btcusdt_unchecked();
8121 let (tx, mut rx) = mpsc::unbounded_channel();
8122
8123 recover_spot_ended_orders(
8124 &rest_at(&server),
8125 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8126 &known,
8127 &mut unchecked,
8128 &UnrecoveredFills::default(),
8129 &tx,
8130 )
8131 .await;
8132
8133 assert!(rx.try_recv().is_err(), "none has ended");
8134 let known = known.lock();
8135 for cid in ["live", "gone", "unknown"] {
8136 assert!(
8137 known.contains(&ClientOrderId::new(cid)),
8138 "{cid}: only an order Binance does not know is dropped"
8139 );
8140 }
8141 assert!(unchecked.is_empty());
8142 }
8143
8144 #[tokio::test]
8145 async fn one_instrument_failing_does_not_hold_up_another() {
8146 let server = venue_after_a_disconnect(Duration::ZERO).await;
8147 wiremock::Mock::given(wiremock::matchers::method("GET"))
8148 .and(wiremock::matchers::path("/api/v3/openOrders"))
8149 .and(wiremock::matchers::query_param("symbol", "ETHUSDT"))
8150 .respond_with(venue_error(-1100))
8151 .mount(&server)
8152 .await;
8153 let known = held_orders();
8154 let eth = spot_key("ETHUSDT", "eth");
8155 known.lock().live(
8156 ð,
8157 Decimal::TWO,
8158 OrderKind::Limit,
8159 &Open::new(
8160 VenueOrderId::Assigned(OrderId::new("9")),
8161 Utc::now(),
8162 Decimal::ZERO,
8163 ),
8164 );
8165 let mut unchecked = btcusdt_unchecked();
8166 unchecked.open([eth.instrument.clone()]);
8167 let (tx, mut rx) = mpsc::unbounded_channel();
8168
8169 recover_spot_ended_orders(
8170 &rest_at(&server),
8171 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8172 &known,
8173 &mut unchecked,
8174 &UnrecoveredFills::default(),
8175 &tx,
8176 )
8177 .await;
8178
8179 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
8180 assert_eq!(sent.len(), 1, "BTCUSDT's ended order: {sent:?}");
8181 assert!(!unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
8182 assert!(
8183 unchecked.contains(ð.instrument),
8184 "ETHUSDT waits for its retry"
8185 );
8186 assert!(known.lock().contains(ð.cid));
8187 }
8188
8189 #[tokio::test]
8190 async fn a_check_dropped_part_way_changes_nothing() {
8191 let server = venue_after_a_disconnect(Duration::from_millis(500)).await;
8192 let known = held_orders();
8193 let mut unchecked = btcusdt_unchecked();
8194 let (tx, mut rx) = mpsc::unbounded_channel();
8195
8196 let dropped = tokio::time::timeout(
8197 Duration::from_millis(100),
8198 recover_spot_ended_orders(
8199 &rest_at(&server),
8200 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8201 &known,
8202 &mut unchecked,
8203 &UnrecoveredFills::default(),
8204 &tx,
8205 ),
8206 )
8207 .await;
8208
8209 assert!(dropped.is_err(), "the lookup of `gone` was still waiting");
8210 assert!(rx.try_recv().is_err());
8211 assert!(known.lock().contains(&ClientOrderId::new("gone")));
8212 assert!(unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
8213 assert!(
8214 unchecked
8215 .next_due(&UnrecoveredFills::default())
8216 .is_some_and(|due| due <= tokio::time::Instant::now()),
8217 "still due, not charged a failure"
8218 );
8219 }
8220
8221 fn commission_answer(symbol: &str) -> serde_json::Value {
8223 let part = |maker: &str, taker: &str, buyer: &str, seller: &str| serde_json::json!({"maker": maker, "taker": taker, "buyer": buyer, "seller": seller});
8224 serde_json::json!({
8225 "symbol": symbol,
8226 "standardCommission": part("0.00000010", "0.00000020", "0.00000030", "0.00000040"),
8227 "specialCommission": part("0.01000000", "0.02000000", "0.03000000", "0.04000000"),
8228 "taxCommission": part("0.00000112", "0.00000114", "0.00000118", "0.00000116"),
8229 "discount": {
8230 "enabledForAccount": true,
8231 "enabledForSymbol": true,
8232 "discountAsset": "BNB",
8233 "discount": "0.75000000"
8234 }
8235 })
8236 }
8237
8238 #[tokio::test]
8239 async fn spot_commission_rates_are_read_signed_for_the_symbol() {
8240 use crate::client::binance::{CommissionDiscount, CommissionPart};
8241 use rust_decimal_macros::dec;
8242 use rustrade_instrument::asset::name::AssetNameExchange;
8243 let server = wiremock::MockServer::start().await;
8244 wiremock::Mock::given(wiremock::matchers::method("GET"))
8245 .and(wiremock::matchers::path("/api/v3/account/commission"))
8246 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
8247 .and(wiremock::matchers::header("X-MBX-APIKEY", "key"))
8248 .respond_with(
8249 wiremock::ResponseTemplate::new(200).set_body_json(commission_answer("BTCUSDT")),
8250 )
8251 .expect(1)
8252 .mount(&server)
8253 .await;
8254 let mut client = <BinanceSpot as ExecutionClient>::new(
8255 BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
8256 );
8257 client.rest = rest_at(&server);
8258 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8259
8260 let rates = client.fetch_commission_rates(&btcusdt).await.unwrap();
8261 let requests = server.received_requests().await.unwrap();
8262 assert!(
8263 requests[0]
8264 .url
8265 .query_pairs()
8266 .any(|(key, _)| key == "signature"),
8267 "signed: {}",
8268 requests[0].url
8269 );
8270 assert_eq!(rates.instrument, btcusdt);
8271 assert_eq!(
8272 rates.special,
8273 CommissionPart::new(dec!(0.01), dec!(0.02), dec!(0.03), dec!(0.04))
8274 );
8275 assert_eq!(
8276 rates.discount,
8277 Some(CommissionDiscount::new(
8278 true,
8279 true,
8280 Some(AssetNameExchange::new("BNB")),
8281 dec!(0.75)
8282 ))
8283 );
8284 }
8285
8286 #[tokio::test]
8287 async fn spot_commission_rates_of_an_unlisted_symbol_name_it() {
8288 let server = wiremock::MockServer::start().await;
8289 wiremock::Mock::given(wiremock::matchers::path("/api/v3/account/commission"))
8290 .respond_with(
8291 wiremock::ResponseTemplate::new(400)
8292 .set_body_json(serde_json::json!({"code": -1121, "msg": "Invalid symbol."})),
8293 )
8294 .mount(&server)
8295 .await;
8296 let mut client = <BinanceSpot as ExecutionClient>::new(
8297 BinanceSpotConfig::new("key".into(), "secret".into()).with_server_time_sync(false),
8298 );
8299 client.rest = rest_at(&server);
8300 let nope = InstrumentNameExchange::new("NOPEUSDT");
8301 match client.fetch_commission_rates(&nope).await {
8302 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(instrument, _))) => {
8303 assert_eq!(instrument, nope);
8304 }
8305 other => panic!("{other:?}"),
8306 }
8307 }
8308}