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