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