1use super::shared::{
38 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
39 CONNECT_TIMEOUT_SECS, ExecutionReport, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS,
40 HEARTBEAT_TIMEOUT_SECS, MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing,
41 PlacementResponse, RateLimitTracker, RequestKind, SIGNAL_RECOVERY_LOOKBACK_MS,
42 SharedDedupCache, UnhandledEvents, UnrecoveredFills, UserDataFrame, WeightPool,
43 binance_filled_qty, classify_order_kind_tif, classify_rest_query_error,
44 classify_ws_order_error, convert_ended_order, convert_execution_report,
45 convert_open_order_listing, convert_open_order_owned_symbol, dedup_key_from_event, drop_after,
46 frame_excerpt, gap_failed, gap_time, is_duplicate, is_handshake_rate_limit, is_unknown_order,
47 log_unhandled_event, log_unrecognised_frame, new_dedup_cache, parse_user_data_frame,
48 placed_order_state, recovered_order_cids, recovered_order_totals, response_decode_error,
49 rest_call_with_retry, trailing_params, unix_ms,
50};
51use crate::{
52 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
53 UnindexedAccountEvent, UnindexedAccountSnapshot,
54 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
55 client::{
56 ExecutionClient, OrderStatusClient,
57 order_recovery::{
58 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
59 fetch_ended_by_key, recover_ended_orders,
60 },
61 },
62 emit_stream_terminated,
63 error::{
64 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
65 UnindexedOrderError,
66 },
67 order::{
68 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
69 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
70 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
71 state::{Cancelled, Open, OrderState, UnindexedOrderState},
72 },
73 parse_env_bool,
74 position::PositionReport,
75 trade::{AssetFees, Trade, TradeId, TradesRead},
76};
77use binance_sdk::{
78 common::{
79 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
80 models::WebsocketEvent,
81 },
82 spot::{
83 SpotRestApi, SpotWsApi,
84 rest_api::{
85 GetAccountParams, GetOpenOrdersParams, GetOrderParams, MyTradesParams, RestApi,
86 },
87 websocket_api::{
88 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
89 OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
90 WebsocketApiHandle,
91 },
92 },
93};
94use chrono::{DateTime, TimeZone, Utc};
95use fnv::FnvHashSet;
96use futures::stream::BoxStream;
97use rust_decimal::Decimal;
98use rustrade_instrument::{
99 Side,
100 asset::name::AssetNameExchange,
101 exchange::ExchangeId,
102 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
103};
104use serde::Deserialize;
105use smol_str::format_smolstr;
106use std::{
107 str::FromStr,
108 sync::{
109 Arc,
110 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
111 },
112 time::Duration,
113};
114use tokio::sync::{RwLock, mpsc, oneshot};
115use tracing::{debug, error, info, trace, warn};
116
117#[derive(Clone, Deserialize)]
124pub struct BinanceSpotConfig {
125 api_key: String,
128 secret_key: String,
129
130 #[serde(default = "default_testnet")]
132 pub testnet: bool,
133}
134
135fn default_testnet() -> bool {
141 true
142}
143
144impl std::fmt::Debug for BinanceSpotConfig {
146 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
147 f.debug_struct("BinanceSpotConfig")
148 .field("api_key", &"***")
149 .field("secret_key", &"***")
150 .field("testnet", &self.testnet)
151 .finish()
152 }
153}
154
155impl BinanceSpotConfig {
156 pub fn new(api_key: String, secret_key: String) -> Self {
159 Self::testnet(api_key, secret_key)
160 }
161
162 pub fn testnet(api_key: String, secret_key: String) -> Self {
164 Self {
165 api_key,
166 secret_key,
167 testnet: true,
168 }
169 }
170
171 pub fn production(api_key: String, secret_key: String) -> Self {
176 Self {
177 api_key,
178 secret_key,
179 testnet: false,
180 }
181 }
182
183 pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
202 let api_key = match std::env::var("BINANCE_API_KEY") {
203 Ok(value) => value,
204 Err(std::env::VarError::NotPresent) => {
205 return Err(BinanceSpotConfigError::MissingApiKey);
206 }
207 Err(std::env::VarError::NotUnicode(_)) => {
208 return Err(BinanceSpotConfigError::InvalidApiKey);
209 }
210 };
211 let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
212 Ok(value) => value,
213 Err(std::env::VarError::NotPresent) => {
214 return Err(BinanceSpotConfigError::MissingSecretKey);
215 }
216 Err(std::env::VarError::NotUnicode(_)) => {
217 return Err(BinanceSpotConfigError::InvalidSecretKey);
218 }
219 };
220 let testnet = match std::env::var("BINANCE_TESTNET") {
221 Ok(value) => {
222 parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
223 }
224 Err(std::env::VarError::NotPresent) => true,
225 Err(std::env::VarError::NotUnicode(value)) => {
228 return Err(BinanceSpotConfigError::InvalidTestnet(
229 value.to_string_lossy().into_owned(),
230 ));
231 }
232 };
233
234 if testnet {
235 Ok(Self::testnet(api_key, secret_key))
236 } else {
237 Ok(Self::production(api_key, secret_key))
238 }
239 }
240
241 pub fn api_key(&self) -> &str {
243 &self.api_key
244 }
245}
246
247#[derive(Debug, PartialEq, thiserror::Error)]
248pub enum BinanceSpotConfigError {
249 #[error("BINANCE_API_KEY environment variable not set")]
250 MissingApiKey,
251
252 #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
255 InvalidApiKey,
256
257 #[error("BINANCE_SECRET_KEY environment variable not set")]
258 MissingSecretKey,
259
260 #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
261 InvalidSecretKey,
262
263 #[error("BINANCE_TESTNET must be true or false, got {0}")]
264 InvalidTestnet(String),
265}
266
267#[derive(Debug)]
274enum WsApiUnavailable {
275 RateLimited(String),
277 Connect(String),
279}
280
281impl WsApiUnavailable {
282 fn into_order_error(self) -> UnindexedOrderError {
285 match self {
286 Self::RateLimited(msg) => {
287 warn!(%msg, "BinanceSpot WS-API rate-limited, request not sent");
288 UnindexedOrderError::Rejected(ApiError::rate_limited(None))
289 }
290 Self::Connect(msg) => UnindexedOrderError::Connectivity(ConnectivityError::Socket(msg)),
291 }
292 }
293}
294
295#[derive(Clone)]
318pub struct BinanceSpot {
319 config: Arc<BinanceSpotConfig>,
320 rest: Arc<RestApi>,
321 ws_handle: WebsocketApiHandle,
323 ws_api: Arc<RwLock<Option<WebsocketApi>>>,
329 rate_limiter: Arc<RateLimitTracker>,
331 known_live: SharedKnownLiveOrders,
334}
335
336impl std::fmt::Debug for BinanceSpot {
337 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338 f.debug_struct("BinanceSpot")
339 .field("testnet", &self.config.testnet)
340 .finish_non_exhaustive()
341 }
342}
343
344impl BinanceSpot {
345 fn remember_live<'a>(
347 &self,
348 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
349 ) {
350 let mut known = self.known_live.lock();
351 for order in orders {
352 known.live(&order.key, order.quantity, order.kind, &order.state);
353 }
354 }
355
356 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
360 let rest_config = ConfigurationRestApi::builder()
361 .api_key(config.api_key.clone())
362 .api_secret(config.secret_key.clone())
363 .build()
364 .expect("failed to build Binance REST configuration");
365
366 Arc::new(if config.testnet {
367 SpotRestApi::testnet(rest_config)
368 } else {
369 SpotRestApi::production(rest_config)
370 })
371 }
372
373 #[allow(clippy::expect_used)] fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
377 let ws_config = ConfigurationWebsocketApi::builder()
378 .api_key(config.api_key.clone())
379 .api_secret(config.secret_key.clone())
380 .build()
381 .expect("failed to build Binance WebSocket configuration");
382
383 if config.testnet {
384 SpotWsApi::testnet(ws_config)
385 } else {
386 SpotWsApi::production(ws_config)
387 }
388 }
389
390 async fn get_ws_api(&self) -> Result<WebsocketApi, WsApiUnavailable> {
399 {
401 let guard = self.ws_api.read().await;
402 if let Some(ref ws) = *guard {
403 return Ok(ws.clone());
404 }
405 }
406 let mut guard = self.ws_api.write().await;
413 if let Some(ref ws) = *guard {
415 return Ok(ws.clone());
416 }
417 if self.rate_limiter.is_blocked() {
418 return Err(WsApiUnavailable::RateLimited(
419 "rate-limit cooldown active, not connecting the WS-API session".into(),
420 ));
421 }
422 let ws = match tokio::time::timeout(
423 Duration::from_secs(CONNECT_TIMEOUT_SECS),
424 self.ws_handle.connect(),
425 )
426 .await
427 {
428 Ok(Ok(ws)) => ws,
429 Ok(Err(e)) if is_handshake_rate_limit(&e) => {
430 self.rate_limiter.on_rate_limited(None);
431 return Err(WsApiUnavailable::RateLimited(format!("{e:#}")));
432 }
433 Ok(Err(e)) => return Err(WsApiUnavailable::Connect(format!("{e:#}"))),
434 Err(_) => {
435 return Err(WsApiUnavailable::Connect(format!(
436 "BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s"
437 )));
438 }
439 };
440 *guard = Some(ws.clone());
441 Ok(ws)
442 }
443
444 async fn clear_ws_api(&self) {
447 let ws = {
458 let mut guard = self.ws_api.write().await;
459 guard.take()
460 };
461 if let Some(ws) = ws {
462 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
463 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
464 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
465 Ok(Ok(())) => {}
466 }
467 }
468 }
469}
470
471async fn fetch_open_orders_for_instrument(
478 rest: Arc<RestApi>,
479 rate_limiter: Arc<RateLimitTracker>,
480 instrument: InstrumentNameExchange,
481 kind: RequestKind,
482) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
483 let symbol_str = instrument.name().to_string();
485 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
486 let sym = symbol_str.clone();
487 Box::pin(async move {
488 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
489 rest.get_open_orders(params).await
490 })
491 })
492 .await
493 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
494
495 let orders_data = response.data().await.map_err(response_decode_error)?;
496
497 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceSpot, &instrument);
498
499 Ok((instrument, listing))
500}
501
502async fn fetch_all_open_orders(
507 rest: Arc<RestApi>,
508 rate_limiter: Arc<RateLimitTracker>,
509) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
510 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
511 Box::pin(async move {
512 let params = GetOpenOrdersParams::builder().build()?;
513 rest.get_open_orders(params).await
514 })
515 })
516 .await
517 .map_err(|e| classify_rest_query_error(&e, None))?;
518
519 let orders_data = response.data().await.map_err(response_decode_error)?;
520
521 let orders = orders_data
522 .into_iter()
523 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot))
524 .collect();
525
526 Ok(orders)
527}
528
529async fn fetch_order_lookup(
531 rest: Arc<RestApi>,
532 rate_limiter: Arc<RateLimitTracker>,
533 key: UnindexedOrderKey,
534 kind: RequestKind,
535) -> Result<OrderLookup, UnindexedClientError> {
536 let symbol = key.instrument.name().to_string();
538 let cid = key.cid.0.to_string();
539 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
540 let (symbol, cid) = (symbol.clone(), cid.clone());
541 Box::pin(async move {
542 let params = GetOrderParams::builder(symbol)
543 .orig_client_order_id(cid)
544 .build()?;
545 rest.get_order(params).await
546 })
547 })
548 .await
549 {
550 Ok(response) => response,
551 Err(e) if is_unknown_order(&e) => {
552 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceSpot does not know this order");
553 return Ok(OrderLookup::Unknown);
554 }
555 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
556 };
557
558 let row = response.data().await.map_err(response_decode_error)?;
559
560 Ok(
562 convert_ended_order(&row, ExchangeId::BinanceSpot, &key, None)
563 .map_or(OrderLookup::NotEnded, |order| {
564 OrderLookup::Ended(Box::new(order))
565 }),
566 )
567}
568
569async fn fetch_order_cid(
573 rest: &Arc<RestApi>,
574 rate_limiter: &RateLimitTracker,
575 instrument: &InstrumentNameExchange,
576 order_id: i64,
577) -> Result<Option<ClientOrderId>, UnindexedClientError> {
578 let symbol = instrument.name().to_string();
579 let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
580 let symbol = symbol.clone();
581 Box::pin(async move {
582 let params = GetOrderParams::builder(symbol).order_id(order_id).build()?;
583 rest.get_order(params).await
584 })
585 })
586 .await
587 {
588 Ok(response) => response,
589 Err(e) if is_unknown_order(&e) => {
590 debug!(%instrument, order_id, error = %e, "BinanceSpot does not know this order");
591 return Ok(None);
592 }
593 Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
594 };
595 let row = response.data().await.map_err(response_decode_error)?;
596 Ok(row
597 .client_order_id
598 .filter(|cid| !cid.is_empty())
599 .map(ClientOrderId::new))
600}
601
602async fn listed_open_cids(
605 rest: Arc<RestApi>,
606 rate_limiter: Arc<RateLimitTracker>,
607 instruments: Vec<InstrumentNameExchange>,
608) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
609 let mut listed = FnvHashSet::default();
610 for instrument in instruments {
611 let (_, listing) = fetch_open_orders_for_instrument(
612 rest.clone(),
613 rate_limiter.clone(),
614 instrument,
615 RequestKind::Essential,
616 )
617 .await?;
618 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
619 }
620 Ok(listed)
621}
622
623async fn recover_spot_ended_orders(
626 rest: &Arc<RestApi>,
627 rate_limiter: &Arc<RateLimitTracker>,
628 known: &SharedKnownLiveOrders,
629 unchecked: &mut UncheckedOrders,
630 unrecovered: &UnrecoveredFills,
631 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
632) {
633 recover_ended_orders(
634 ExchangeId::BinanceSpot,
635 known,
636 unchecked,
637 unrecovered,
638 tx,
639 OpenListing::PerInstrument,
640 |instruments| listed_open_cids(rest.clone(), rate_limiter.clone(), instruments),
641 |key| {
642 fetch_order_lookup(
643 rest.clone(),
644 rate_limiter.clone(),
645 key,
646 RequestKind::Essential,
647 )
648 },
649 )
650 .await;
651}
652
653async fn paginate_my_trades(
664 rest: &Arc<RestApi>,
665 rate_limiter: &Arc<RateLimitTracker>,
666 instrument: &InstrumentNameExchange,
667 from: MyTradesFrom,
668 kind: RequestKind,
669) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
670 let symbol_str = instrument.name().to_string();
672 let mut all_pages = Vec::new();
677 let mut cursor: Option<i64> = None;
678 loop {
679 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
681 let sym = symbol_str.clone();
682 Box::pin(async move {
683 #[allow(clippy::cast_possible_truncation)]
685 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
686 let params = match (from, fid) {
687 (MyTradesFrom::Span { start, .. }, None) => builder.start_time(start),
688 (MyTradesFrom::Span { .. }, Some(id)) => builder.from_id(id),
689 (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
690 (MyTradesFrom::Order(order_id), Some(id)) => {
691 builder.order_id(order_id).from_id(id)
692 }
693 }
694 .build()?;
695 rest.my_trades(params).await
696 })
697 })
698 .await
699 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
700
701 let mut page = response.data().await.map_err(response_decode_error)?;
702
703 let page_len = page.len();
704 let last_id = page.last().and_then(|t| t.id);
705 let past_end = match from {
707 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
708 MyTradesFrom::Order(_) => false,
709 };
710 all_pages.extend(page);
711
712 if past_end || page_len < BINANCE_MAX_TRADES {
713 break;
714 }
715 match last_id {
716 Some(id) => {
717 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
718 match id.checked_add(1) {
719 Some(next) => cursor = Some(next),
720 None => break, }
722 }
723 None => {
724 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
725 break;
726 }
727 }
728 }
729 Ok(all_pages)
730}
731
732impl ExecutionClient for BinanceSpot {
737 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
738
739 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
742 &[InstrumentKindDiscriminant::Spot];
743 type Config = BinanceSpotConfig;
744 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
745
746 fn new(config: Self::Config) -> Self {
751 let rest = Self::build_rest(&config);
752 let ws_handle = Self::build_ws_handle(&config);
753 Self {
754 config: Arc::new(config),
755 rest,
756 ws_handle,
757 ws_api: Arc::new(RwLock::new(None)),
758 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Spot)),
759 known_live: KnownLiveOrders::shared(ExchangeId::BinanceSpot),
760 }
761 }
762
763 async fn account_snapshot(
764 &self,
765 assets: &[AssetNameExchange],
766 instruments: &[InstrumentNameExchange],
767 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
768 let response =
770 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
771 Box::pin(async move {
772 let params = GetAccountParams::builder().build()?;
773 rest.get_account(params).await
774 })
775 })
776 .await
777 .map_err(|e| classify_rest_query_error(&e, None))?;
778
779 let account = response.data().await.map_err(response_decode_error)?;
780
781 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
783
784 use futures::{StreamExt as _, TryStreamExt};
790 let instrument_snapshots: Vec<_> =
791 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
792 fetch_open_orders_for_instrument(
793 self.rest.clone(),
794 self.rate_limiter.clone(),
795 instrument,
796 RequestKind::Query,
797 )
798 }))
799 .buffer_unordered(8)
800 .map(|result| {
801 let (inst, listing) = result?;
802 self.remember_live(&listing.orders);
803 let wrapped = listing
804 .orders
805 .into_iter()
806 .map(|o| Order {
807 key: o.key,
808 side: o.side,
809 price: o.price,
810 quantity: o.quantity,
811 kind: o.kind,
812 time_in_force: o.time_in_force,
813 state: OrderState::active(o.state),
814 })
815 .collect();
816 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
817 inst,
818 wrapped,
819 listing.complete,
820 PositionReport::Unreported,
821 None,
822 ))
823 })
824 .try_collect()
825 .await?;
826
827 Ok(AccountSnapshot::new(
828 ExchangeId::BinanceSpot,
829 balances,
830 instrument_snapshots,
831 ))
832 }
833
834 async fn account_stream(
908 &self,
909 _assets: &[AssetNameExchange],
913 instruments: &[InstrumentNameExchange],
914 ) -> Result<Self::AccountStream, UnindexedClientError> {
915 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
930 let dedup = new_dedup_cache();
931 let ws_handle = self.ws_handle.clone();
932 let rest = self.rest.clone();
933 let rate_limiter = self.rate_limiter.clone();
934 let known_live = self.known_live.clone();
935 let instruments = instruments.to_vec();
936 debug_assert!(
940 instruments.iter().all(|i| i.name().len() <= 23),
941 "instrument name exceeds SmolStr inline capacity: {:?}",
942 instruments.iter().find(|i| i.name().len() > 23)
943 );
944
945 let initial_ws = ws_handle.connect().await.map_err(|e| {
949 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
950 })?;
951
952 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
954 .build()
955 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
956
957 match initial_ws
958 .user_data_stream_subscribe_signature(params)
959 .await
960 {
961 Ok(_) => {}
962 Err(e) => {
963 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
968 Ok(Err(de)) => {
969 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
970 }
971 Err(_) => {
972 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
973 }
974 Ok(Ok(())) => {}
975 }
976 return Err(UnindexedClientError::Internal(e.to_string()));
977 }
978 }
979
980 let cm_handle = tokio::spawn(connection_manager(
992 tx,
993 dedup,
994 ws_handle,
995 rest,
996 rate_limiter,
997 known_live,
998 instruments,
999 Some(initial_ws),
1000 ));
1001
1002 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1006 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1007 Ok(futures::StreamExt::boxed(guarded_stream))
1008 }
1009
1010 async fn cancel_order(
1011 &self,
1012 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1013 ) -> UnindexedOrderResponseCancel {
1014 let instrument = request.key.instrument.clone();
1015 let key = OrderKey {
1016 exchange: request.key.exchange,
1017 instrument: instrument.clone(),
1018 strategy: request.key.strategy.clone(),
1019 cid: request.key.cid.clone(),
1020 };
1021
1022 let ws = match self.get_ws_api().await {
1023 Ok(ws) => ws,
1024 Err(unavailable) => {
1025 return UnindexedOrderResponseCancel {
1026 key,
1027 state: Err(unavailable.into_order_error()),
1028 };
1029 }
1030 };
1031
1032 let mut params_builder =
1035 OrderCancelParams::builder(request.key.instrument.name().to_string());
1036
1037 if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
1039 if let Ok(id) = order_id.0.parse::<i64>() {
1040 params_builder = params_builder.order_id(id);
1041 } else {
1042 error!(
1047 order_id = %order_id.0,
1048 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
1049 );
1050 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1051 }
1052 } else {
1053 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
1054 }
1055
1056 let params = match params_builder.build() {
1057 Ok(p) => p,
1058 Err(e) => {
1059 error!(%e, "BinanceSpot failed to build cancel order params");
1060 return UnindexedOrderResponseCancel {
1061 key,
1062 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1063 e.to_string(),
1064 ))),
1065 };
1066 }
1067 };
1068
1069 let sent_ms = unix_ms();
1070 let result = ws.order_cancel(params).await;
1071 if let Ok(response) = &result {
1073 self.rate_limiter
1074 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1075 }
1076 match result {
1077 Ok(response) => match response.data() {
1078 Ok(data) => {
1079 let time_exchange = data
1080 .transact_time
1081 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1082 .unwrap_or_else(Utc::now);
1083
1084 let exchange_order_id = match data.order_id {
1085 Some(id) => OrderId(format_smolstr!("{id}")),
1086 None => {
1087 error!("BinanceSpot cancel response missing orderId");
1088 return UnindexedOrderResponseCancel {
1089 key,
1090 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1091 "cancel response missing orderId".into(),
1092 ))),
1093 };
1094 }
1095 };
1096
1097 let filled_qty = binance_filled_qty(
1098 ExchangeId::BinanceSpot,
1099 &exchange_order_id,
1100 data.executed_qty.as_deref(),
1101 );
1102
1103 self.known_live.lock().ended(&key.cid);
1104 UnindexedOrderResponseCancel {
1105 key,
1106 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1107 }
1108 }
1109 Err(e) => {
1110 UnindexedOrderResponseCancel {
1112 key,
1113 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1114 e.to_string(),
1115 ))),
1116 }
1117 }
1118 },
1119 Err(e) => {
1120 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1128 if matches!(order_err, OrderError::Rejected(ApiError::RateLimit { .. })) {
1132 self.rate_limiter.on_rate_limited(None);
1133 }
1134 UnindexedOrderResponseCancel {
1135 key,
1136 state: Err(order_err),
1137 }
1138 } else {
1139 self.clear_ws_api().await;
1142 UnindexedOrderResponseCancel {
1143 key,
1144 state: Err(UnindexedOrderError::Connectivity(
1145 ConnectivityError::Socket(format!("{e:#}")),
1146 )),
1147 }
1148 }
1149 }
1150 }
1151 }
1152
1153 async fn open_order(
1154 &self,
1155 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
1156 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
1157 let instrument = request.key.instrument.clone();
1158 let side = request.state.side;
1159 let price = request.state.price;
1160 let quantity = request.state.quantity;
1161 let kind = request.state.kind;
1162 let time_in_force = request.state.time_in_force;
1163 let cid = request.key.cid.clone();
1164
1165 let order_key = OrderKey::new(
1166 ExchangeId::BinanceSpot,
1167 instrument.clone(),
1168 request.key.strategy.clone(),
1169 cid.clone(),
1170 );
1171
1172 let ws = match self.get_ws_api().await {
1173 Ok(ws) => ws,
1174 Err(unavailable) => {
1175 return Order {
1176 key: order_key,
1177 side,
1178 price,
1179 quantity,
1180 kind,
1181 time_in_force,
1182 state: OrderState::inactive(unavailable.into_order_error()),
1183 };
1184 }
1185 };
1186
1187 let binance_side = match side {
1188 Side::Buy => OrderPlaceSideEnum::Buy,
1189 Side::Sell => OrderPlaceSideEnum::Sell,
1190 };
1191
1192 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
1193 Some(converted) => converted,
1194 None => {
1195 return Order {
1196 key: order_key,
1197 side,
1198 price,
1199 quantity,
1200 kind,
1201 time_in_force,
1202 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
1203 "Binance Spot does not yet support OrderKind::{kind:?}"
1204 ))),
1205 };
1206 }
1207 };
1208
1209 let mut params_builder =
1216 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
1217 .quantity(quantity)
1218 .new_client_order_id(cid.0.to_string());
1219
1220 match trailing_params(&kind, price) {
1222 Ok(Some(trailing)) => {
1223 params_builder = params_builder.trailing_delta(trailing.trailing_delta);
1224 if let Some(limit) = trailing.price {
1225 params_builder = params_builder.price(limit);
1226 }
1227 if let Some(activation_price) = trailing.stop_price {
1228 params_builder = params_builder.stop_price(activation_price);
1230 }
1231 }
1232 Err(refused) => {
1233 return Order {
1234 key: order_key,
1235 side,
1236 price,
1237 quantity,
1238 kind,
1239 time_in_force,
1240 state: OrderState::inactive(refused.into_order_error()),
1241 };
1242 }
1243 Ok(None) => {}
1244 }
1245
1246 match kind {
1249 OrderKind::Limit => {
1250 params_builder = params_builder.price(price);
1251 }
1252 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
1253 params_builder = params_builder.stop_price(trigger_price);
1254 }
1255 OrderKind::StopLimit { trigger_price }
1256 | OrderKind::TakeProfitLimit { trigger_price } => {
1257 params_builder = params_builder.price(price).stop_price(trigger_price);
1258 }
1259 OrderKind::TrailingStop { .. }
1262 | OrderKind::TrailingStopFixedLimit { .. }
1263 | OrderKind::Market
1264 | OrderKind::TrailingStopLimit { .. } => {}
1265 }
1266
1267 if let Some(tif) = binance_tif {
1268 params_builder = params_builder.time_in_force(tif);
1269 }
1270
1271 let params = match params_builder.build() {
1272 Ok(p) => p,
1273 Err(e) => {
1274 error!(%e, "BinanceSpot failed to build new order params");
1275 return Order {
1276 key: order_key,
1277 side,
1278 price,
1279 quantity,
1280 kind,
1281 time_in_force,
1282 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1283 e.to_string(),
1284 ))),
1285 };
1286 }
1287 };
1288
1289 let sent_ms = unix_ms();
1290 let result = ws.order_place(params).await;
1291 if let Ok(response) = &result {
1293 self.rate_limiter
1294 .observe_ws_api(response.rate_limits.as_deref(), sent_ms);
1295 }
1296 match result {
1297 Ok(response) => match response.data() {
1298 Ok(data) => {
1299 let time_exchange = data
1300 .transact_time
1301 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1302 .unwrap_or_else(Utc::now);
1303
1304 let exchange_order_id = match data.order_id {
1305 Some(id) => OrderId(format_smolstr!("{id}")),
1306 None => {
1307 error!("BinanceSpot open_order response missing orderId");
1308 return Order {
1309 key: order_key,
1310 side,
1311 price,
1312 quantity,
1313 kind,
1314 time_in_force,
1315 state: OrderState::inactive(OrderError::Rejected(
1316 ApiError::OrderRejected(
1317 "open_order response missing orderId".into(),
1318 ),
1319 )),
1320 };
1321 }
1322 };
1323
1324 let state = placed_order_state(
1329 ExchangeId::BinanceSpot,
1330 &instrument,
1331 quantity,
1332 PlacementResponse {
1333 status: data.status.as_deref(),
1334 order_id: exchange_order_id,
1335 time_exchange,
1336 executed_qty: data.executed_qty.as_deref(),
1337 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
1338 },
1339 );
1340 self.known_live
1341 .lock()
1342 .placed(&order_key, quantity, kind, &state);
1343
1344 Order {
1345 key: order_key,
1346 side,
1347 price,
1348 quantity,
1349 kind,
1350 time_in_force,
1351 state,
1352 }
1353 }
1354 Err(e) => {
1355 Order {
1357 key: order_key,
1358 side,
1359 price,
1360 quantity,
1361 kind,
1362 time_in_force,
1363 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1364 e.to_string(),
1365 ))),
1366 }
1367 }
1368 },
1369 Err(e) => {
1370 if let Some(order_err) = classify_ws_order_error(&e, &instrument) {
1378 if matches!(order_err, OrderError::Rejected(ApiError::RateLimit { .. })) {
1382 self.rate_limiter.on_rate_limited(None);
1383 }
1384 Order {
1385 key: order_key,
1386 side,
1387 price,
1388 quantity,
1389 kind,
1390 time_in_force,
1391 state: OrderState::inactive(order_err),
1392 }
1393 } else {
1394 self.clear_ws_api().await;
1397 Order {
1398 key: order_key,
1399 side,
1400 price,
1401 quantity,
1402 kind,
1403 time_in_force,
1404 state: OrderState::inactive(OrderError::Connectivity(
1405 ConnectivityError::Socket(format!("{e:#}")),
1406 )),
1407 }
1408 }
1409 }
1410 }
1411 }
1412
1413 async fn fetch_balances(
1414 &self,
1415 assets: &[AssetNameExchange],
1416 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1417 let response =
1418 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1419 Box::pin(async move {
1420 let params = GetAccountParams::builder().build()?;
1421 rest.get_account(params).await
1422 })
1423 })
1424 .await
1425 .map_err(|e| classify_rest_query_error(&e, None))?;
1426
1427 let account = response.data().await.map_err(response_decode_error)?;
1428
1429 Ok(filter_and_convert_balances(
1430 account.balances.unwrap_or_default(),
1431 assets,
1432 ))
1433 }
1434
1435 async fn fetch_open_orders(
1440 &self,
1441 instruments: &[InstrumentNameExchange],
1442 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1443 if instruments.is_empty() {
1446 let orders =
1447 fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await?;
1448 self.remember_live(&orders);
1449 return Ok(orders);
1450 }
1451 use futures::{StreamExt as _, TryStreamExt as _};
1456 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1457 fetch_open_orders_for_instrument(
1458 self.rest.clone(),
1459 self.rate_limiter.clone(),
1460 instrument,
1461 RequestKind::Query,
1462 )
1463 }))
1464 .buffer_unordered(8)
1465 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1466 acc.extend(listing.orders);
1467 Ok(acc)
1468 })
1469 .await
1470 .inspect(|orders| self.remember_live(orders))
1471 }
1472
1473 #[allow(clippy::redundant_iter_cloned)]
1484 async fn fetch_trades(
1485 &self,
1486 start: DateTime<Utc>,
1487 end: DateTime<Utc>,
1488 instruments: &[InstrumentNameExchange],
1489 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1490 use futures::StreamExt;
1491
1492 if instruments.is_empty() {
1493 debug!(
1494 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1495 );
1496 return Ok(TradesRead::complete(Vec::new()));
1497 }
1498 if start > end {
1499 return Ok(TradesRead::complete(Vec::new()));
1500 }
1501 let span = MyTradesFrom::Span {
1503 start: start.timestamp_millis(),
1504 end: end.timestamp_millis(),
1505 };
1506 let mut all_trades = Vec::new();
1509
1510 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1514 let rest = self.rest.clone();
1515 let rate_limiter = self.rate_limiter.clone();
1516 async move {
1517 let pages =
1518 paginate_my_trades(&rest, &rate_limiter, &inst, span, RequestKind::Query)
1519 .await?;
1520 Ok::<_, UnindexedClientError>((inst, pages))
1521 }
1522 }))
1523 .buffer_unordered(8);
1524 while let Some(result) = stream.next().await {
1525 let (instrument, trades_data) = result?;
1526 for t in trades_data {
1527 if let Some(trade) = convert_my_trade(&t, &instrument)
1528 && (start..=end).contains(&trade.time_exchange)
1529 {
1530 all_trades.push(trade);
1531 }
1532 }
1533 }
1534
1535 Ok(TradesRead::complete(all_trades))
1536 }
1537}
1538
1539impl OrderStatusClient for BinanceSpot {
1554 async fn fetch_ended_orders(
1555 &self,
1556 orders: &[UnindexedOrderKey],
1557 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1558 fetch_ended_by_key(orders, |key| {
1559 fetch_order_lookup(
1560 self.rest.clone(),
1561 self.rate_limiter.clone(),
1562 key,
1563 RequestKind::Query,
1564 )
1565 })
1566 .await
1567 }
1568}
1569
1570async fn connect_and_subscribe(ws_handle: &WebsocketApiHandle) -> anyhow::Result<WebsocketApi> {
1573 let ws = ws_handle.connect().await?;
1574 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1576 .build()
1577 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1578 match ws.user_data_stream_subscribe_signature(params).await {
1579 Ok(_) => Ok(ws),
1580 Err(e) => {
1581 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1582 let ws_cleanup = ws;
1583 tokio::spawn(async move {
1593 match tokio::time::timeout(Duration::from_secs(5), ws_cleanup.disconnect()).await {
1594 Ok(Err(dc_err)) => warn!(%dc_err, "BinanceSpot WS cleanup disconnect failed"),
1595 Err(_) => warn!("BinanceSpot WS cleanup disconnect timed out (5s)"),
1596 Ok(Ok(())) => {}
1597 }
1598 });
1599 Err(e)
1600 }
1601 }
1602}
1603
1604#[allow(clippy::cognitive_complexity)]
1626async fn connection_manager(
1627 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1628 dedup: SharedDedupCache,
1629 ws_handle: WebsocketApiHandle,
1630 rest: Arc<RestApi>,
1631 rate_limiter: Arc<RateLimitTracker>,
1632 known: SharedKnownLiveOrders,
1633 instruments: Vec<InstrumentNameExchange>,
1634 initial_ws: Option<WebsocketApi>,
1635) {
1636 let mut backoff = ExponentialBackoff::new();
1637 let mut disconnect_time: Option<DateTime<Utc>> = None;
1638 let mut unrecovered = UnrecoveredFills::default();
1640 let mut unchecked = UncheckedOrders::default();
1642 let mut current_ws = initial_ws;
1643
1644 loop {
1645 let ws = match current_ws.take() {
1647 Some(ws) => ws,
1648 None => match connect_and_subscribe(&ws_handle).await {
1649 Ok(ws) => ws,
1655 Err(e) => {
1656 error!(%e, "BinanceSpot WS connect/subscribe failed");
1657 if !backoff.wait().await {
1658 error!("BinanceSpot max reconnect attempts exhausted");
1659 emit_stream_terminated(
1661 &tx,
1662 ExchangeId::BinanceSpot,
1663 StreamTerminationReason::ReconnectBudgetExhausted {
1664 attempts: backoff.attempts(),
1665 last_error: e.to_string(),
1666 },
1667 );
1668 break;
1669 }
1670 continue;
1671 }
1672 },
1673 };
1674 info!("BinanceSpot account_stream connected and subscribed");
1675
1676 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1682 let mut signal_tx_opt = Some(signal_tx);
1683 let heartbeat_flag = Arc::new(AtomicBool::new(true));
1687 let hb_callback = heartbeat_flag.clone();
1688 let dedup_callback = dedup.clone();
1689 let known_callback = known.clone();
1690 let mut event_tx = Some(tx.clone());
1694 let mut event_buf = Vec::with_capacity(32);
1698
1699 let subscription = ws.subscribe_on_ws_events(move |event| {
1706 let Some(ref sender) = event_tx else { return };
1707 match event {
1708 WebsocketEvent::Message(json_str) => {
1709 hb_callback.store(true, Ordering::Release);
1712 let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1716 for ev in event_buf.drain(..) {
1717 if let Some(key) = dedup_key_from_event(&ev)
1719 && is_duplicate(&dedup_callback, key)
1720 {
1721 trace!("BinanceSpot dedup: skipping duplicate event");
1722 continue;
1723 }
1724 let known = KnownLiveOrders::observes(&ev.kind).then(|| {
1728 let mut known = known_callback.lock();
1729 known.observe(&ev);
1730 known
1731 });
1732 if sender.send(ev).is_err() {
1733 drop(known);
1734 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1735 event_tx.take();
1736 if let Some(s) = signal_tx_opt.take() {
1737 let _ = s.send(());
1738 }
1739 return;
1740 }
1741 }
1742 if stream_terminated {
1745 event_tx.take();
1746 if let Some(s) = signal_tx_opt.take() {
1747 let _ = s.send(());
1748 }
1749 }
1750 }
1751 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1752 hb_callback.store(true, Ordering::Release);
1755 }
1756 WebsocketEvent::Error(e) => {
1757 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1761 event_tx.take();
1762 if let Some(s) = signal_tx_opt.take() {
1763 let _ = s.send(());
1764 }
1765 }
1766 WebsocketEvent::Close(code, reason) => {
1767 warn!(code, %reason, "BinanceSpot WebSocket closed");
1768 event_tx.take();
1769 if let Some(s) = signal_tx_opt.take() {
1770 let _ = s.send(());
1771 }
1772 }
1773 _ => {}
1774 }
1775 });
1776
1777 if let Some(dt) = disconnect_time.take() {
1784 unrecovered.open(&instruments, dt, Utc::now());
1785 let held = known.lock().instruments_among(&instruments);
1787 unchecked.open(held);
1788 }
1789 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known).await;
1790 recover_spot_ended_orders(
1791 &rest,
1792 &rate_limiter,
1793 &known,
1794 &mut unchecked,
1795 &unrecovered,
1796 &tx,
1797 )
1798 .await;
1799
1800 enum DisconnectReason {
1802 Signal,
1803 HeartbeatTimeout,
1804 ConsumerDropped,
1805 }
1806 let reason = {
1807 let mut signal_rx = signal_rx;
1808 let retry_gaps = async {
1814 loop {
1815 let next_check = unchecked.next_due(&unrecovered);
1816 match unrecovered.next_due().into_iter().chain(next_check).min() {
1817 Some(due) => tokio::time::sleep_until(due).await,
1818 None => std::future::pending::<()>().await,
1819 }
1820 recover_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, &known)
1821 .await;
1822 recover_spot_ended_orders(
1823 &rest,
1824 &rate_limiter,
1825 &known,
1826 &mut unchecked,
1827 &unrecovered,
1828 &tx,
1829 )
1830 .await;
1831 }
1832 };
1833 tokio::pin!(retry_gaps);
1834 loop {
1835 tokio::select! {
1836 biased;
1838 _ = tx.closed() => {
1839 debug!("BinanceSpot account_stream consumer dropped, terminating");
1840 break DisconnectReason::ConsumerDropped;
1841 }
1842 _ = &mut signal_rx => {
1843 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1844 break DisconnectReason::Signal;
1845 }
1846 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1847 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1854 backoff.reset();
1858 continue;
1859 }
1860 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1861 break DisconnectReason::HeartbeatTimeout;
1862 }
1863 () = &mut retry_gaps => {}
1865 }
1866 }
1867 };
1868 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1869
1870 if should_reconnect {
1877 disconnect_time = Some(match reason {
1878 DisconnectReason::HeartbeatTimeout => {
1879 Utc::now()
1880 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1881 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1882 }
1883 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1884 });
1885 }
1886
1887 subscription.unsubscribe();
1895 if let Err(e) = ws.disconnect().await {
1898 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1899 }
1900
1901 if !should_reconnect || tx.is_closed() {
1902 debug!("BinanceSpot connection manager exiting");
1905 break;
1906 }
1907 if !backoff.wait().await {
1908 error!("BinanceSpot max reconnect attempts exhausted, stream terminating");
1909 let last_error = match reason {
1912 DisconnectReason::HeartbeatTimeout => {
1913 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
1914 }
1915 _ => "WebSocket disconnected (server close/error)".to_string(),
1916 };
1917 emit_stream_terminated(
1918 &tx,
1919 ExchangeId::BinanceSpot,
1920 StreamTerminationReason::ReconnectBudgetExhausted {
1921 attempts: backoff.attempts(),
1922 last_error,
1923 },
1924 );
1925 break;
1926 }
1927 }
1928}
1929
1930async fn recover_fills(
1949 rest: &Arc<RestApi>,
1950 rate_limiter: &Arc<RateLimitTracker>,
1951 unrecovered: &mut UnrecoveredFills,
1952 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1953 dedup: &SharedDedupCache,
1954 known: &SharedKnownLiveOrders,
1955) {
1956 use futures::StreamExt;
1957
1958 let due = unrecovered.due(tokio::time::Instant::now());
1959 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
1960 return;
1961 };
1962 info!(
1963 gaps = due.len(),
1964 oldest = %gap_time(oldest),
1965 "BinanceSpot recovering fills missed while disconnected"
1966 );
1967
1968 let mut settled = vec![false; due.len()];
1971 let started = AtomicUsize::new(0);
1972 let recovery = async {
1973 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
1974 let mut recovered = 0u32;
1975 let mut duplicates = 0u32;
1976
1977 let mut stream =
1983 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
1984 started.store(index + 1, Ordering::Relaxed);
1985 let rest = rest.clone();
1986 let rl = rate_limiter.clone();
1987 async move {
1988 let span = MyTradesFrom::Span {
1989 start: gap.start_ms,
1990 end: gap.end_ms,
1991 };
1992 let raw =
1993 match paginate_my_trades(&rest, &rl, &inst, span, RequestKind::Essential)
1994 .await
1995 {
1996 Ok(pages) => pages,
1997 Err(e) => return (index, Err(e)),
1998 };
1999 let totals = recovered_order_totals(
2003 ExchangeId::BinanceSpot,
2004 &inst,
2005 &raw,
2006 order_executions_deadline,
2007 |order_id| {
2008 paginate_my_trades(
2009 &rest,
2010 &rl,
2011 &inst,
2012 MyTradesFrom::Order(order_id),
2013 RequestKind::Essential,
2014 )
2015 },
2016 )
2017 .await;
2018 let cids = recovered_order_cids(
2021 ExchangeId::BinanceSpot,
2022 &inst,
2023 &raw,
2024 order_executions_deadline,
2025 |order_id| fetch_order_cid(&rest, &rl, &inst, order_id),
2026 )
2027 .await;
2028 let trades: Vec<_> = raw
2029 .iter()
2030 .filter_map(|t| {
2031 let mut trade = convert_my_trade(t, &inst)?;
2032 trade.order_filled_quantity =
2033 t.id.and_then(|id| totals.get(&id).copied());
2034 trade.client_order_id =
2035 t.order_id.and_then(|id| cids.get(&id).cloned());
2036 Some(trade)
2037 })
2038 .collect();
2039 (index, Ok(trades))
2040 }
2041 }))
2042 .buffer_unordered(8);
2043 while let Some((index, result)) = stream.next().await {
2044 let (inst, gap) = &due[index];
2045 settled[index] = true;
2046 let trades = match result {
2047 Ok(trades) => trades,
2048 Err(e) => {
2049 gap_failed(
2050 unrecovered,
2051 tx,
2052 ExchangeId::BinanceSpot,
2053 inst,
2054 gap,
2055 FillRecoveryFailure::Request(e.to_string()),
2056 );
2057 continue;
2058 }
2059 };
2060 for trade in trades {
2061 let event = UnindexedAccountEvent::new(
2064 ExchangeId::BinanceSpot,
2065 AccountEventKind::Trade(trade),
2066 );
2067 if let Some(key) = dedup_key_from_event(&event)
2070 && is_duplicate(dedup, key)
2071 {
2072 duplicates += 1;
2073 continue;
2074 }
2075 let sent = {
2078 let mut known = known.lock();
2079 known.observe(&event);
2080 tx.send(event)
2081 };
2082 if sent.is_err() {
2083 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
2086 return;
2087 }
2088 recovered += 1;
2089 }
2090 unrecovered.recovered(inst, gap);
2091 }
2092 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
2093 };
2094 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2098 .await
2099 .is_err()
2100 {
2101 let started = started.load(Ordering::Relaxed);
2102 for ((inst, gap), _) in due[..started]
2103 .iter()
2104 .zip(&settled)
2105 .filter(|(_, settled)| !**settled)
2106 {
2107 gap_failed(
2108 unrecovered,
2109 tx,
2110 ExchangeId::BinanceSpot,
2111 inst,
2112 gap,
2113 FillRecoveryFailure::TimedOut {
2114 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2115 },
2116 );
2117 }
2118 }
2119}
2120
2121fn convert_balance_entry(
2135 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
2136 now: chrono::DateTime<Utc>,
2137) -> Option<AssetBalance<AssetNameExchange>> {
2138 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2139 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2140 Some(v) => v,
2141 None => {
2142 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
2143 return None;
2144 }
2145 };
2146 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2147 Some(v) => v,
2148 None => {
2149 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
2150 return None;
2151 }
2152 };
2153 Some(AssetBalance::new(
2154 asset_name,
2155 Balance::new(free + locked, free),
2156 now,
2157 ))
2158}
2159
2160fn filter_and_convert_balances(
2161 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
2162 assets: &[AssetNameExchange],
2163) -> Vec<AssetBalance<AssetNameExchange>> {
2164 let now = Utc::now();
2165 if assets.is_empty() {
2167 return balances
2168 .into_iter()
2169 .filter_map(|b| convert_balance_entry(b, now))
2170 .collect();
2171 }
2172 if assets.len() <= 16 {
2175 return balances
2176 .into_iter()
2177 .filter_map(|b| {
2178 let asset_name_str = b.asset.as_deref()?;
2179 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
2180 return None;
2181 }
2182 convert_balance_entry(b, now)
2183 })
2184 .collect();
2185 }
2186 use std::collections::HashSet;
2187 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
2188 balances
2189 .into_iter()
2190 .filter_map(|b| {
2191 let asset_name_str = b.asset.as_deref()?;
2192 if !asset_set.contains(asset_name_str) {
2193 return None;
2194 }
2195 convert_balance_entry(b, now)
2196 })
2197 .collect()
2198}
2199
2200fn convert_my_trade(
2202 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
2203 instrument: &InstrumentNameExchange,
2204) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
2205 let trade_id_raw = match t.id {
2206 Some(id) => id,
2207 None => {
2208 warn!(%instrument, "BinanceSpot trade missing id");
2209 return None;
2210 }
2211 };
2212 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
2213 let order_id = match t.order_id {
2214 Some(id) => OrderId(format_smolstr!("{}", id)),
2215 None => {
2216 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
2217 return None;
2218 }
2219 };
2220 let side = match t.is_buyer {
2221 Some(is_buyer) => {
2222 if is_buyer {
2223 Side::Buy
2224 } else {
2225 Side::Sell
2226 }
2227 }
2228 None => {
2229 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
2230 return None;
2231 }
2232 };
2233 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2234 Some(v) => v,
2235 None => {
2236 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
2237 return None;
2238 }
2239 };
2240 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2241 Some(v) => v,
2242 None => {
2243 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2244 return None;
2245 }
2246 };
2247 let commission = t
2248 .commission
2249 .as_deref()
2250 .and_then(|s| Decimal::from_str(s).ok())
2251 .unwrap_or(Decimal::ZERO);
2252 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
2253 Some(ts) => ts,
2254 None => {
2255 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2256 Utc::now()
2257 }
2258 };
2259
2260 let fee_asset = t
2265 .commission_asset
2266 .as_deref()
2267 .map(AssetNameExchange::from)
2268 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2269
2270 Some(Trade::new(
2271 trade_id,
2272 order_id,
2273 instrument.clone(),
2274 StrategyId::unknown(), time_exchange,
2276 side,
2277 price,
2278 quantity,
2279 None,
2281 AssetFees::new(fee_asset, commission, None),
2282 ))
2283}
2284
2285fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2310 let (event_type, event) = match parse_user_data_frame(frame) {
2311 UserDataFrame::Response => return false,
2312 UserDataFrame::Event {
2313 event_type, event, ..
2314 } => (event_type, event),
2315 UserDataFrame::Unrecognised => {
2316 static SEEN: AtomicU64 = AtomicU64::new(0);
2317 log_unrecognised_frame("BinanceSpot", &SEEN, frame);
2318 return false;
2319 }
2320 };
2321 match event_type {
2323 "executionReport" => {
2324 match serde_json::from_str::<ExecutionReport>(event) {
2327 Ok(report) => {
2328 convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
2329 }
2330 Err(e) => {
2331 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable executionReport, dropping")
2332 }
2333 }
2334 false
2335 }
2336 "outboundAccountPosition" => {
2337 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2338 event,
2339 ) {
2340 Ok(position) => convert_account_position(position, buf),
2341 Err(e) => {
2342 warn!(error = %e, frame = frame_excerpt(event), "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2343 }
2344 }
2345 false
2346 }
2347 "balanceUpdate" => {
2348 false
2355 }
2356 "eventStreamTerminated" => {
2357 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2361 true
2362 }
2363 "listStatus" | "externalLockUpdate" => {
2364 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2368 false
2369 }
2370 _ => {
2371 static SEEN: UnhandledEvents = UnhandledEvents::new();
2372 log_unhandled_event("BinanceSpot", &SEEN, event_type, event);
2373 false
2374 }
2375 }
2376}
2377
2378fn convert_account_position(
2383 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2384 buf: &mut Vec<UnindexedAccountEvent>,
2385) {
2386 let time_exchange = position
2389 .u
2390 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2391 .unwrap_or_else(Utc::now);
2392
2393 for b in position.b_uppercase.unwrap_or_default() {
2394 let asset = match b.a {
2395 Some(a) => AssetNameExchange::new(a),
2396 None => {
2397 warn!("BinanceSpot account position entry missing asset name");
2398 continue;
2399 }
2400 };
2401 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2402 Some(v) => v,
2403 None => {
2404 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2405 continue;
2406 }
2407 };
2408 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2409 Some(v) => v,
2410 None => {
2411 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2412 continue;
2413 }
2414 };
2415 let update =
2420 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2421 buf.push(UnindexedAccountEvent::new(
2422 ExchangeId::BinanceSpot,
2423 AccountEventKind::BalanceStreamUpdate(
2424 rustrade_integration::collection::snapshot::Snapshot::new(update),
2425 ),
2426 ));
2427 }
2428}
2429
2430fn convert_order_kind_tif(
2461 kind: OrderKind,
2462 tif: TimeInForce,
2463) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2464 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2468 let type_enum = match binance_type {
2469 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2470 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2471 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2472 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2473 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2474 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2475 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2476 };
2477 let tif_enum = binance_tif.map(|t| match t {
2478 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2479 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2480 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2481 });
2482 Some((type_enum, tif_enum))
2483}
2484
2485#[cfg(test)]
2486#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2488 use super::*;
2489 use crate::client::binance::shared::*;
2490 use crate::client::dedup::{DedupEventKind, DedupKey};
2491 use crate::order::{TrailingOffsetType, id::ClientOrderId};
2492 use binance_sdk::common::errors::WebsocketError;
2493 use smol_str::SmolStr;
2494
2495 #[test]
2496 fn test_binance_spot_config_new_uses_testnet_by_default() {
2497 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2498 assert!(cfg.testnet);
2499 assert_eq!(cfg.api_key(), "my_key");
2500 }
2501
2502 #[test]
2503 fn test_binance_spot_config_production_is_explicit() {
2504 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2505 assert!(!cfg.testnet);
2506 }
2507
2508 #[test]
2509 fn test_binance_spot_config_debug_redacts_credentials() {
2510 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2511 let debug = format!("{cfg:?}");
2512
2513 assert!(!debug.contains("my_key"), "api_key should be redacted");
2514 assert!(
2515 !debug.contains("my_secret"),
2516 "secret_key should be redacted"
2517 );
2518 assert!(debug.contains("testnet: true"));
2519 }
2520
2521 #[test]
2522 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2523 let cfg: BinanceSpotConfig = serde_json::from_str(
2524 r#"{
2525 "api_key": "my_key",
2526 "secret_key": "my_secret"
2527 }"#,
2528 )
2529 .unwrap();
2530
2531 assert!(cfg.testnet);
2532 assert_eq!(cfg.api_key(), "my_key");
2533 }
2534
2535 #[test]
2536 fn test_binance_spot_config_deserialize_testnet_true() {
2537 let cfg: BinanceSpotConfig = serde_json::from_str(
2538 r#"{
2539 "api_key": "my_key",
2540 "secret_key": "my_secret",
2541 "testnet": true
2542 }"#,
2543 )
2544 .unwrap();
2545
2546 assert!(cfg.testnet);
2547 }
2548
2549 #[test]
2550 fn test_binance_spot_config_deserialize_testnet_false() {
2551 let cfg: BinanceSpotConfig = serde_json::from_str(
2552 r#"{
2553 "api_key": "my_key",
2554 "secret_key": "my_secret",
2555 "testnet": false
2556 }"#,
2557 )
2558 .unwrap();
2559
2560 assert!(!cfg.testnet);
2561 }
2562
2563 #[test]
2564 #[serial_test::serial]
2565 fn test_binance_spot_config_from_env_defaults_to_testnet() {
2566 temp_env::with_vars(
2567 [
2568 ("BINANCE_API_KEY", Some("my_key")),
2569 ("BINANCE_SECRET_KEY", Some("my_secret")),
2570 ("BINANCE_TESTNET", None),
2571 ],
2572 || {
2573 let cfg = BinanceSpotConfig::from_env().unwrap();
2574 assert!(cfg.testnet);
2575 assert_eq!(cfg.api_key(), "my_key");
2576 },
2577 );
2578 }
2579
2580 #[test]
2581 #[serial_test::serial]
2582 fn test_binance_spot_config_from_env_accepts_explicit_production() {
2583 temp_env::with_vars(
2584 [
2585 ("BINANCE_API_KEY", Some("my_key")),
2586 ("BINANCE_SECRET_KEY", Some("my_secret")),
2587 ("BINANCE_TESTNET", Some("false")),
2588 ],
2589 || {
2590 let cfg = BinanceSpotConfig::from_env().unwrap();
2591 assert!(!cfg.testnet);
2592 },
2593 );
2594 }
2595
2596 #[test]
2597 #[serial_test::serial]
2598 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2599 temp_env::with_vars(
2600 [
2601 ("BINANCE_API_KEY", Some("my_key")),
2602 ("BINANCE_SECRET_KEY", Some("my_secret")),
2603 ("BINANCE_TESTNET", Some("maybe")),
2604 ],
2605 || {
2606 let err = BinanceSpotConfig::from_env().unwrap_err();
2607 assert!(
2608 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2609 );
2610 },
2611 );
2612 }
2613
2614 #[test]
2615 #[serial_test::serial]
2616 fn test_binance_spot_config_from_env_requires_credentials() {
2617 temp_env::with_vars(
2618 [
2619 ("BINANCE_API_KEY", None),
2620 ("BINANCE_SECRET_KEY", Some("my_secret")),
2621 ("BINANCE_TESTNET", None),
2622 ],
2623 || {
2624 let err = BinanceSpotConfig::from_env().unwrap_err();
2625 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2626 },
2627 );
2628 }
2629
2630 #[test]
2631 fn test_parse_side() {
2632 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2633 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2634 assert_eq!(parse_side("buy"), None);
2635 assert_eq!(parse_side("UNKNOWN"), None);
2636 }
2637
2638 #[test]
2639 fn test_parse_time_in_force() {
2640 assert_eq!(
2641 parse_time_in_force("GTC"),
2642 TimeInForce::GoodUntilCancelled { post_only: false }
2643 );
2644 assert_eq!(
2645 parse_time_in_force("GTX"),
2646 TimeInForce::GoodUntilCancelled { post_only: true }
2647 );
2648 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2649 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2650 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2651 assert_eq!(
2653 parse_time_in_force("UNKNOWN"),
2654 TimeInForce::GoodUntilCancelled { post_only: false }
2655 );
2656 }
2657
2658 #[test]
2659 fn test_convert_order_kind_tif() {
2660 use rust_decimal::Decimal;
2661
2662 assert!(matches!(
2664 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2665 Some((OrderPlaceTypeEnum::Market, None))
2666 ));
2667 assert!(matches!(
2668 convert_order_kind_tif(
2669 OrderKind::Limit,
2670 TimeInForce::GoodUntilCancelled { post_only: false }
2671 ),
2672 Some((
2673 OrderPlaceTypeEnum::Limit,
2674 Some(OrderPlaceTimeInForceEnum::Gtc)
2675 ))
2676 ));
2677 assert!(matches!(
2678 convert_order_kind_tif(
2679 OrderKind::Limit,
2680 TimeInForce::GoodUntilCancelled { post_only: true }
2681 ),
2682 Some((OrderPlaceTypeEnum::LimitMaker, None))
2683 ));
2684 assert!(matches!(
2685 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2686 Some((
2687 OrderPlaceTypeEnum::Limit,
2688 Some(OrderPlaceTimeInForceEnum::Fok)
2689 ))
2690 ));
2691 assert!(matches!(
2692 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2693 Some((
2694 OrderPlaceTypeEnum::Limit,
2695 Some(OrderPlaceTimeInForceEnum::Ioc)
2696 ))
2697 ));
2698 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2701
2702 assert!(matches!(
2704 convert_order_kind_tif(
2705 OrderKind::Stop {
2706 trigger_price: Decimal::from(100)
2707 },
2708 TimeInForce::GoodUntilCancelled { post_only: false }
2709 ),
2710 Some((OrderPlaceTypeEnum::StopLoss, None))
2711 ));
2712 assert!(matches!(
2713 convert_order_kind_tif(
2714 OrderKind::StopLimit {
2715 trigger_price: Decimal::from(100)
2716 },
2717 TimeInForce::GoodUntilCancelled { post_only: false }
2718 ),
2719 Some((
2720 OrderPlaceTypeEnum::StopLossLimit,
2721 Some(OrderPlaceTimeInForceEnum::Gtc)
2722 ))
2723 ));
2724 assert!(matches!(
2725 convert_order_kind_tif(
2726 OrderKind::TakeProfit {
2727 trigger_price: Decimal::from(150)
2728 },
2729 TimeInForce::ImmediateOrCancel
2730 ),
2731 Some((OrderPlaceTypeEnum::TakeProfit, None))
2732 ));
2733 assert!(matches!(
2734 convert_order_kind_tif(
2735 OrderKind::TakeProfitLimit {
2736 trigger_price: Decimal::from(150)
2737 },
2738 TimeInForce::FillOrKill
2739 ),
2740 Some((
2741 OrderPlaceTypeEnum::TakeProfitLimit,
2742 Some(OrderPlaceTimeInForceEnum::Fok)
2743 ))
2744 ));
2745
2746 assert!(matches!(
2748 convert_order_kind_tif(
2749 OrderKind::TrailingStop {
2750 offset: Decimal::from(100),
2751 offset_type: TrailingOffsetType::BasisPoints,
2752 activation_price: None,
2753 },
2754 TimeInForce::GoodUntilCancelled { post_only: false }
2755 ),
2756 Some((OrderPlaceTypeEnum::StopLoss, None))
2757 ));
2758 assert!(matches!(
2759 convert_order_kind_tif(
2760 OrderKind::TrailingStop {
2761 offset: Decimal::from(5),
2762 offset_type: TrailingOffsetType::Percentage,
2763 activation_price: None,
2764 },
2765 TimeInForce::GoodUntilCancelled { post_only: false }
2766 ),
2767 Some((OrderPlaceTypeEnum::StopLoss, None))
2768 ));
2769
2770 assert!(
2772 convert_order_kind_tif(
2773 OrderKind::TrailingStop {
2774 offset: Decimal::from(10),
2775 offset_type: TrailingOffsetType::Absolute,
2776 activation_price: None,
2777 },
2778 TimeInForce::GoodUntilCancelled { post_only: false }
2779 )
2780 .is_none()
2781 );
2782
2783 assert!(
2785 convert_order_kind_tif(
2786 OrderKind::TrailingStopLimit {
2787 offset: Decimal::from(100),
2788 offset_type: TrailingOffsetType::BasisPoints,
2789 stop_price: Decimal::from(95),
2790 limit_offset: Decimal::from(10),
2791 },
2792 TimeInForce::GoodUntilCancelled { post_only: false }
2793 )
2794 .is_none()
2795 );
2796 }
2797
2798 #[test]
2801 fn a_fixed_limit_trailing_stop_converts_to_stop_loss_limit() {
2802 let fixed_limit = |offset_type| OrderKind::TrailingStopFixedLimit {
2803 offset: Decimal::from(100),
2804 offset_type,
2805 activation_price: Some(Decimal::from(50_000)),
2806 };
2807 assert!(matches!(
2808 convert_order_kind_tif(
2809 fixed_limit(TrailingOffsetType::BasisPoints),
2810 TimeInForce::GoodUntilCancelled { post_only: false }
2811 ),
2812 Some((
2813 OrderPlaceTypeEnum::StopLossLimit,
2814 Some(OrderPlaceTimeInForceEnum::Gtc)
2815 ))
2816 ));
2817 assert!(
2818 convert_order_kind_tif(
2819 fixed_limit(TrailingOffsetType::Absolute),
2820 TimeInForce::GoodUntilCancelled { post_only: false }
2821 )
2822 .is_none()
2823 );
2824 }
2825
2826 #[test]
2827 fn test_parse_binance_api_error() {
2828 let instrument = InstrumentNameExchange::new("BTCUSDT");
2829
2830 assert!(matches!(
2831 parse_binance_api_error("Insufficient balance".into(), &instrument),
2832 ApiError::BalanceInsufficient(None, _)
2833 ));
2834 assert!(matches!(
2835 parse_binance_api_error("Not enough funds".into(), &instrument),
2836 ApiError::BalanceInsufficient(None, _)
2837 ));
2838 assert_eq!(
2839 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2840 ApiError::rate_limited(None)
2841 );
2842 assert_eq!(
2843 parse_binance_api_error("Error code -1015".into(), &instrument),
2844 ApiError::rate_limited(None)
2845 );
2846 assert_eq!(
2848 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2849 ApiError::rate_limited(None)
2850 );
2851 assert_eq!(
2853 parse_binance_api_error("Error code -2011".into(), &instrument),
2854 ApiError::OrderAlreadyCancelled
2855 );
2856 assert!(matches!(
2858 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2859 ApiError::OrderAlreadyCancelled
2860 ));
2861 assert!(matches!(
2863 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2864 ApiError::OrderAlreadyCancelled
2865 ));
2866 assert!(matches!(
2868 parse_binance_api_error("Unknown order encountered".into(), &instrument),
2869 ApiError::OrderRejected(_)
2870 ));
2871 assert!(matches!(
2872 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2873 ApiError::InstrumentInvalid(_, _)
2874 ));
2875 assert!(matches!(
2877 parse_binance_api_error(
2878 "Server-side response error (code -2010): Account has insufficient balance".into(),
2879 &instrument
2880 ),
2881 ApiError::BalanceInsufficient(None, _)
2882 ));
2883 assert!(matches!(
2884 parse_binance_api_error(
2885 "Server-side response error (code -2010): Order would trigger immediately.".into(),
2886 &instrument
2887 ),
2888 ApiError::OrderRejected(_)
2889 ));
2890 assert!(matches!(
2891 parse_binance_api_error("Some other error".into(), &instrument),
2892 ApiError::OrderRejected(_)
2893 ));
2894 }
2895
2896 #[test]
2897 fn test_contains_error_code_suffix_guard() {
2898 assert!(
2900 !contains_error_code("-20130", "-2013"),
2901 "-20130 should not match -2013"
2902 );
2903 assert!(
2904 !contains_error_code("-20131", "-2013"),
2905 "-20131 should not match -2013"
2906 );
2907 assert!(contains_error_code("-2013", "-2013"), "exact match");
2909 assert!(
2910 contains_error_code("Error -2013: text", "-2013"),
2911 "match with trailing text"
2912 );
2913 }
2914
2915 #[test]
2916 fn test_contains_error_code_prefix_guard() {
2917 assert!(
2920 !contains_error_code("1-2013", "-2013"),
2921 "1-2013 should not match -2013"
2922 );
2923 assert!(
2924 !contains_error_code("error 1-2013 text", "-2013"),
2925 "embedded 1-2013 should not match"
2926 );
2927 assert!(
2929 contains_error_code("code=-2013,", "-2013"),
2930 "=-2013 prefix should match"
2931 );
2932 assert!(
2933 contains_error_code(" -2013 ", "-2013"),
2934 "space prefix should match"
2935 );
2936 }
2937
2938 #[test]
2939 fn test_contains_error_code_second_occurrence_valid() {
2940 assert!(
2943 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
2944 "second valid occurrence must match when first fails digit guard"
2945 );
2946 assert!(
2948 contains_error_code("-2013 or -20130", "-2013"),
2949 "first valid occurrence must match when second is a longer code"
2950 );
2951 }
2952
2953 #[test]
2954 fn test_dedup_cache() {
2955 let cache = new_dedup_cache();
2956 let key = DedupKey {
2957 instrument: SmolStr::from("BTCUSDT"),
2958 id: SmolStr::from("12345"),
2959 kind: DedupEventKind::Trade,
2960 };
2961
2962 assert!(!is_duplicate(&cache, key));
2964 let key = DedupKey {
2966 instrument: SmolStr::from("BTCUSDT"),
2967 id: SmolStr::from("12345"),
2968 kind: DedupEventKind::Trade,
2969 };
2970 assert!(is_duplicate(&cache, key));
2971
2972 let key2 = DedupKey {
2974 instrument: SmolStr::from("BTCUSDT"),
2975 id: SmolStr::from("12345"),
2976 kind: DedupEventKind::OrderState {
2977 filled_quantity: Decimal::ZERO,
2978 },
2979 };
2980 assert!(!is_duplicate(&cache, key2));
2981 }
2982
2983 #[test]
2987 fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
2988 let cache = new_dedup_cache();
2989 let state = |filled: &str| DedupKey {
2990 instrument: SmolStr::from("BTCUSDT"),
2991 id: SmolStr::from("12345"),
2992 kind: DedupEventKind::OrderState {
2993 filled_quantity: Decimal::from_str(filled).unwrap(),
2994 },
2995 };
2996
2997 assert!(!is_duplicate(&cache, state("0")), "ack is new");
2999 assert!(
3000 !is_duplicate(&cache, state("1")),
3001 "first fill is not the ack"
3002 );
3003 assert!(
3004 !is_duplicate(&cache, state("2")),
3005 "second fill is not the first"
3006 );
3007
3008 assert!(
3011 is_duplicate(&cache, state("0")),
3012 "replayed ack is a duplicate"
3013 );
3014 assert!(
3015 is_duplicate(&cache, state("2")),
3016 "replayed fill is a duplicate"
3017 );
3018
3019 assert!(
3021 is_duplicate(&cache, state("2.00")),
3022 "2.00 must be the same key as 2"
3023 );
3024 }
3025
3026 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
3033 use crate::client::dedup::dedup_key_from_event;
3034
3035 let cache = new_dedup_cache();
3036 let mut delivered = Vec::new();
3037 let mut buf = Vec::new();
3038 for frame in frames {
3039 let _ = convert_user_data_events(frame, &mut buf);
3040 for ev in buf.drain(..) {
3041 if let Some(key) = dedup_key_from_event(&ev)
3042 && is_duplicate(&cache, key)
3043 {
3044 continue;
3045 }
3046 delivered.push(ev);
3047 }
3048 }
3049 delivered
3050 }
3051
3052 const WS_NEW: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport","s":"BTCUSDT",
3054 "i":12345,"c":"client-1","x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2",
3055 "p":"100","z":"0","T":1700000000000}}"#;
3056
3057 const WS_PARTIAL_FILL: &str = r#"{"subscriptionId":0,"event":{"e":"executionReport",
3058 "s":"BTCUSDT","i":12345,"c":"client-1","x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY",
3059 "o":"LIMIT","f":"GTC","q":"2","p":"100","z":"1","l":"1","L":"100","t":555,"n":"0.1",
3060 "N":"USDT","T":1700000001000}}"#;
3061
3062 #[test]
3070 fn ws_fill_snapshot_survives_the_dedup_gate() {
3071 use crate::order::state::ActiveOrderState;
3072
3073 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
3074
3075 assert_eq!(
3076 delivered.len(),
3077 3,
3078 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
3079 );
3080
3081 let snapshots: Vec<Decimal> = delivered
3082 .iter()
3083 .filter_map(|ev| match &ev.kind {
3084 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
3085 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
3086 _ => None,
3087 },
3088 _ => None,
3089 })
3090 .collect();
3091
3092 assert_eq!(
3093 snapshots,
3094 vec![Decimal::ZERO, Decimal::ONE],
3095 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
3096 );
3097 }
3098
3099 #[test]
3102 fn ws_replayed_frame_is_still_deduplicated() {
3103 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
3104
3105 assert_eq!(
3106 delivered.len(),
3107 3,
3108 "re-delivering both frames must add nothing, got: {delivered:?}"
3109 );
3110 }
3111
3112 #[test]
3113 fn test_is_rate_limit_error() {
3114 assert!(is_rate_limit_error(&anyhow::anyhow!(
3116 "Too many requests. You are being rate-limited. Please slow down."
3117 )));
3118 assert!(is_rate_limit_error(&anyhow::anyhow!(
3120 "The IP address has been banned for exceeding rate limits. Contact support."
3121 )));
3122 assert!(is_rate_limit_error(&anyhow::anyhow!(
3124 "Error -1015: too many new orders"
3125 )));
3126 assert!(is_rate_limit_error(&anyhow::anyhow!(
3127 "Error -1003: too many requests"
3128 )));
3129 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
3131 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
3132 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
3133 assert!(
3135 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
3136 "-10150 should not match -1015"
3137 );
3138 assert!(
3139 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
3140 "-10030 should not match -1003"
3141 );
3142 }
3143
3144 #[test]
3145 fn test_classify_ws_order_error_recognises_venue_responses_only() {
3146 let instrument = InstrumentNameExchange::new("BTCUSDT");
3147
3148 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
3150 code: -2010,
3151 message: "Account has insufficient balance for requested action.".into(),
3152 });
3153 assert!(
3154 matches!(
3155 classify_ws_order_error(&rejection, &instrument),
3156 Some(OrderError::Rejected(ApiError::BalanceInsufficient(None, _)))
3157 ),
3158 "ResponseError should be classified as a venue response"
3159 );
3160
3161 let transport = anyhow::anyhow!("connection reset by peer");
3163 assert!(
3164 classify_ws_order_error(&transport, &instrument).is_none(),
3165 "plain transport error should not be classified as a venue response"
3166 );
3167
3168 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
3170 assert!(
3171 classify_ws_order_error(&rate_limit, &instrument).is_none(),
3172 "rate-limit string error should not be classified as a venue response"
3173 );
3174 }
3175
3176 #[test]
3177 fn test_dedup_key_from_event_trade_includes_instrument() {
3178 use crate::order::id::{OrderId, StrategyId};
3179 use crate::trade::{AssetFees, Trade, TradeId};
3180 use chrono::Utc;
3181 use rust_decimal::Decimal;
3182 use rustrade_instrument::Side;
3183
3184 let instrument = InstrumentNameExchange::new("BTCUSDT");
3185 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3186 TradeId::new("9001"),
3187 OrderId::new("4242"),
3188 instrument.clone(),
3189 StrategyId::unknown(),
3190 Utc::now(),
3191 Side::Buy,
3192 Decimal::ZERO,
3193 Decimal::ZERO,
3194 None,
3195 AssetFees::new(
3196 AssetNameExchange::from("USDT"),
3197 Decimal::ZERO,
3198 Some(Decimal::ZERO),
3199 ),
3200 );
3201 let event =
3202 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
3203
3204 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
3205 assert_eq!(key.kind, DedupEventKind::Trade);
3206 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
3207 assert_eq!(
3208 key.instrument.as_str(),
3209 "BTCUSDT",
3210 "key.instrument should be the symbol"
3211 );
3212
3213 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
3215 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3216 TradeId::new("9001"),
3217 OrderId::new("7777"),
3218 instrument2,
3219 StrategyId::unknown(),
3220 Utc::now(),
3221 Side::Buy,
3222 Decimal::ZERO,
3223 Decimal::ZERO,
3224 None,
3225 AssetFees::new(
3226 AssetNameExchange::from("USDT"),
3227 Decimal::ZERO,
3228 Some(Decimal::ZERO),
3229 ),
3230 );
3231 let event2 =
3232 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
3233 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
3234 assert_ne!(
3235 key, key2,
3236 "same trade ID on different symbols must produce distinct keys"
3237 );
3238 }
3239
3240 fn make_balance_inner(
3245 asset: &str,
3246 free: &str,
3247 locked: &str,
3248 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3249 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3250 a: Some(asset.to_string()),
3251 f: Some(free.to_string()),
3252 l: Some(locked.to_string()),
3253 }
3254 }
3255
3256 #[test]
3257 fn test_convert_account_position_happy_path() {
3258 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3259 u: Some(1_700_000_000_000),
3260 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
3261 ..Default::default()
3262 };
3263 let mut buf = Vec::new();
3264 convert_account_position(position, &mut buf);
3265
3266 assert_eq!(buf.len(), 1);
3267 match &buf[0].kind {
3268 AccountEventKind::BalanceStreamUpdate(snap) => {
3269 let update = &snap.0;
3270 assert_eq!(update.asset.as_ref(), "BTC");
3271 let expected_free = Decimal::from_str("1.5").unwrap();
3273 let expected_locked = Decimal::from_str("0.5").unwrap();
3274 assert_eq!(update.update.free, expected_free);
3275 assert_eq!(update.update.locked, expected_locked);
3276 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3277 }
3278 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3279 }
3280 }
3281
3282 #[test]
3283 fn test_convert_account_position_u_field_none_uses_now() {
3284 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3286 u: None,
3287 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3288 ..Default::default()
3289 };
3290 let mut buf = Vec::new();
3291 convert_account_position(position, &mut buf);
3292 assert_eq!(buf.len(), 1);
3293 }
3294
3295 #[test]
3296 fn test_convert_account_position_missing_asset_name_skipped() {
3297 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3298 u: Some(1_700_000_000_000),
3299 b_uppercase: Some(vec![
3300 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3301 a: None, f: Some("1.0".to_string()),
3303 l: Some("0.0".to_string()),
3304 },
3305 make_balance_inner("USDT", "100.0", "0.0"), ]),
3307 ..Default::default()
3308 };
3309 let mut buf = Vec::new();
3310 convert_account_position(position, &mut buf);
3311 assert_eq!(buf.len(), 1);
3313 match &buf[0].kind {
3314 AccountEventKind::BalanceStreamUpdate(snap) => {
3315 assert_eq!(snap.0.asset.as_ref(), "USDT");
3316 }
3317 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3318 }
3319 }
3320
3321 #[test]
3322 fn test_convert_account_position_unparseable_free_skipped() {
3323 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3324 u: Some(1_700_000_000_000),
3325 b_uppercase: Some(vec![
3326 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3327 a: Some("BTC".to_string()),
3328 f: Some("not-a-number".to_string()),
3329 l: Some("0.0".to_string()),
3330 },
3331 make_balance_inner("ETH", "1.0", "0.0"), ]),
3333 ..Default::default()
3334 };
3335 let mut buf = Vec::new();
3336 convert_account_position(position, &mut buf);
3337 assert_eq!(buf.len(), 1);
3339 match &buf[0].kind {
3340 AccountEventKind::BalanceStreamUpdate(snap) => {
3341 assert_eq!(snap.0.asset.as_ref(), "ETH");
3342 }
3343 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3344 }
3345 }
3346
3347 #[test]
3348 fn test_convert_account_position_empty_balances() {
3349 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3350 u: Some(1_700_000_000_000),
3351 b_uppercase: Some(vec![]),
3352 ..Default::default()
3353 };
3354 let mut buf = Vec::new();
3355 convert_account_position(position, &mut buf);
3356 assert!(
3357 buf.is_empty(),
3358 "empty balance list should produce no events"
3359 );
3360 }
3361
3362 #[test]
3363 fn test_convert_account_position_b_field_none() {
3364 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3365 u: Some(1_700_000_000_000),
3366 b_uppercase: None, ..Default::default()
3368 };
3369 let mut buf = Vec::new();
3370 convert_account_position(position, &mut buf);
3371 assert!(buf.is_empty(), "None B field should produce no events");
3372 }
3373
3374 #[tokio::test]
3376 async fn test_exponential_backoff_exhaustion() {
3377 tokio::time::pause(); let mut backoff = ExponentialBackoff::new();
3379
3380 for i in 0..MAX_RECONNECT_ATTEMPTS {
3382 assert!(
3383 backoff.wait().await,
3384 "expected true on attempt {i} (before exhaustion)"
3385 );
3386 }
3387
3388 assert!(!backoff.wait().await, "expected false after max attempts");
3390 }
3391
3392 #[tokio::test]
3393 async fn test_exponential_backoff_reset() {
3394 tokio::time::pause();
3395 let mut backoff = ExponentialBackoff::new();
3396
3397 for _ in 0..MAX_RECONNECT_ATTEMPTS {
3398 backoff.wait().await;
3399 }
3400 assert!(!backoff.wait().await, "should be exhausted");
3401
3402 backoff.reset();
3403 assert!(backoff.wait().await, "should succeed again after reset");
3404 }
3405
3406 fn make_base_report() -> ExecutionReport {
3410 ExecutionReport {
3411 symbol: Some("BTCUSDT".to_string()),
3412 order_id: Some(12345),
3413 client_order_id: Some("client-1".to_string()),
3414 transaction_time: Some(1_700_000_000_000),
3415 ..Default::default()
3416 }
3417 }
3418
3419 fn convert(report: ExecutionReport) -> Vec<UnindexedAccountEvent> {
3421 let mut buf = Vec::new();
3422 convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
3423 buf
3424 }
3425
3426 fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
3431 assert!(
3432 events.len() <= 1,
3433 "expected at most a single event, got: {events:?}"
3434 );
3435 events.pop()
3436 }
3437
3438 #[test]
3439 fn test_convert_execution_report_new() {
3440 let report = ExecutionReport {
3441 execution_type: Some("NEW".to_string()),
3442 side: Some("BUY".to_string()),
3443 order_type: Some("LIMIT".to_string()),
3444 price: Some("50000.00".to_string()),
3445 order_quantity: Some("0.01".to_string()),
3446 time_in_force: Some("GTC".to_string()),
3447 cumulative_filled_quantity: Some("0".to_string()),
3448 ..make_base_report()
3449 };
3450
3451 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3452 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3453 match &event.kind {
3454 AccountEventKind::OrderSnapshot(snap) => {
3455 let order = &snap.0;
3456 assert_eq!(order.side, Side::Buy);
3457 assert_eq!(order.kind, OrderKind::Limit);
3458 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3459 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3460 assert_eq!(order.key.cid.0.as_str(), "client-1");
3461 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3462 }
3463 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3464 }
3465 }
3466
3467 #[test]
3470 fn a_trailing_stop_loss_limit_report_keeps_its_limit_and_activation_price() {
3471 let report = ExecutionReport {
3472 execution_type: Some("NEW".to_string()),
3473 side: Some("SELL".to_string()),
3474 order_type: Some("STOP_LOSS_LIMIT".to_string()),
3475 price: Some("49000.00".to_string()),
3476 stop_price: Some("50000.00".to_string()),
3477 trailing_delta: Some(WireInt::Int(25)),
3478 order_quantity: Some("0.01".to_string()),
3479 time_in_force: Some("GTC".to_string()),
3480 cumulative_filled_quantity: Some("0".to_string()),
3481 ..make_base_report()
3482 };
3483
3484 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3485 let AccountEventKind::OrderSnapshot(snap) = &event.kind else {
3486 panic!("NEW should yield OrderSnapshot, got {:?}", event.kind);
3487 };
3488 assert_eq!(
3489 snap.0.kind,
3490 OrderKind::TrailingStopFixedLimit {
3491 offset: Decimal::from(25),
3492 offset_type: TrailingOffsetType::BasisPoints,
3493 activation_price: Some(Decimal::from(50_000)),
3494 }
3495 );
3496 assert_eq!(snap.0.price, Some(Decimal::from(49_000)));
3497 }
3498
3499 #[test]
3503 fn a_report_without_an_order_type_has_no_order_snapshot() {
3504 let new = ExecutionReport {
3505 execution_type: Some("NEW".to_string()),
3506 side: Some("BUY".to_string()),
3507 price: Some("50000.00".to_string()),
3508 order_quantity: Some("0.01".to_string()),
3509 ..make_base_report()
3510 };
3511 assert!(convert(new).is_empty());
3512
3513 let fill = ExecutionReport {
3514 execution_type: Some("TRADE".to_string()),
3515 order_status: Some("PARTIALLY_FILLED".to_string()),
3516 side: Some("BUY".to_string()),
3517 trade_id: Some(9999),
3518 last_executed_price: Some("50000.00".to_string()),
3519 last_executed_quantity: Some("0.005".to_string()),
3520 commission_amount: Some("0".to_string()),
3521 cumulative_filled_quantity: Some("0.005".to_string()),
3522 order_type: Some("LIMIT".to_string()),
3523 price: Some("50000.00".to_string()),
3524 order_quantity: Some("0.01".to_string()),
3525 ..make_base_report()
3526 };
3527 let kinds = |report| {
3528 convert(report)
3529 .into_iter()
3530 .map(|event| match event.kind {
3531 AccountEventKind::Trade(_) => "trade",
3532 AccountEventKind::OrderSnapshot(_) => "snapshot",
3533 _ => "other",
3534 })
3535 .collect::<Vec<_>>()
3536 };
3537 assert_eq!(kinds(fill.clone()), ["trade", "snapshot"]);
3538 let untyped = ExecutionReport {
3539 order_type: None,
3540 ..fill
3541 };
3542 assert_eq!(kinds(untyped), ["trade"]);
3543 }
3544
3545 #[test]
3546 fn test_convert_execution_report_trade() {
3547 let report = ExecutionReport {
3550 execution_type: Some("TRADE".to_string()),
3551 side: Some("BUY".to_string()),
3552 trade_id: Some(9999),
3553 last_executed_price: Some("50000.00".to_string()),
3554 last_executed_quantity: Some("0.01".to_string()),
3555 commission_amount: Some("0.000001".to_string()),
3556 ..make_base_report()
3557 };
3558
3559 let event = sole_event(convert(report)).expect("TRADE event should produce Some");
3560 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3561 match &event.kind {
3562 AccountEventKind::Trade(trade) => {
3563 assert_eq!(trade.side, Side::Buy);
3564 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3565 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3566 assert_eq!(trade.id.0.as_str(), "9999");
3567 assert_eq!(trade.order_id.0.as_str(), "12345");
3568 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3569 }
3570 other => panic!("TRADE should yield Trade, got {other:?}"),
3571 }
3572 }
3573
3574 fn trade_events(
3576 events: Vec<UnindexedAccountEvent>,
3577 ) -> (
3578 Trade<AssetNameExchange, InstrumentNameExchange>,
3579 crate::order::Order<
3580 ExchangeId,
3581 InstrumentNameExchange,
3582 OrderState<AssetNameExchange, InstrumentNameExchange>,
3583 >,
3584 ) {
3585 let [first, second]: [UnindexedAccountEvent; 2] = events
3586 .try_into()
3587 .expect("a fill report must produce an execution and an order snapshot");
3588 let AccountEventKind::Trade(trade) = first.kind else {
3589 panic!("first event must be the Trade, got {:?}", first.kind);
3590 };
3591 let AccountEventKind::OrderSnapshot(snap) = second.kind else {
3592 panic!(
3593 "second event must be the OrderSnapshot, got {:?}",
3594 second.kind
3595 );
3596 };
3597 (trade, snap.0)
3598 }
3599
3600 #[test]
3604 fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
3605 let report = ExecutionReport {
3606 execution_type: Some("TRADE".to_string()),
3607 order_status: Some("PARTIALLY_FILLED".to_string()),
3608 side: Some("BUY".to_string()),
3609 order_type: Some("LIMIT".to_string()),
3610 price: Some("50000.00".to_string()),
3611 order_quantity: Some("0.01".to_string()),
3612 time_in_force: Some("GTC".to_string()),
3613 trade_id: Some(9999),
3614 last_executed_price: Some("50000.00".to_string()),
3615 last_executed_quantity: Some("0.004".to_string()),
3616 cumulative_filled_quantity: Some("0.004".to_string()),
3617 ..make_base_report()
3618 };
3619
3620 let (trade, order) = trade_events(convert(report));
3621 assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
3622
3623 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3624 else {
3625 panic!("expected an Open snapshot, got {:?}", order.state);
3626 };
3627 assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
3628 assert_eq!(
3629 open.quantity_remaining(order.quantity),
3630 Decimal::from_str("0.006").unwrap()
3631 );
3632 }
3633
3634 #[test]
3637 fn a_filled_report_reports_nothing_left_to_fill() {
3638 let report = ExecutionReport {
3639 execution_type: Some("TRADE".to_string()),
3640 order_status: Some("FILLED".to_string()),
3641 side: Some("BUY".to_string()),
3642 order_type: Some("LIMIT".to_string()),
3643 price: Some("50000.00".to_string()),
3644 order_quantity: Some("0.01".to_string()),
3645 time_in_force: Some("GTC".to_string()),
3646 trade_id: Some(10_000),
3647 last_executed_price: Some("50000.00".to_string()),
3648 last_executed_quantity: Some("0.006".to_string()),
3649 cumulative_filled_quantity: Some("0.01".to_string()),
3650 ..make_base_report()
3651 };
3652
3653 let (_trade, order) = trade_events(convert(report));
3654 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3655 else {
3656 panic!("expected an Open snapshot, got {:?}", order.state);
3657 };
3658 assert!(
3659 open.quantity_remaining(order.quantity).is_zero(),
3660 "a completed order must report nothing left to fill so the engine retires it"
3661 );
3662 }
3663
3664 #[test]
3668 fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
3669 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
3670 let report = ExecutionReport {
3671 execution_type: Some("TRADE".to_string()),
3672 order_status: Some(status.to_string()),
3673 side: Some("BUY".to_string()),
3674 order_type: Some("LIMIT".to_string()),
3675 price: Some("50000.00".to_string()),
3676 order_quantity: Some("0.01".to_string()),
3677 time_in_force: Some("GTC".to_string()),
3678 trade_id: Some(9999),
3679 last_executed_price: Some("50000.00".to_string()),
3680 last_executed_quantity: Some("0.004".to_string()),
3681 cumulative_filled_quantity: Some("0.004".to_string()),
3682 ..make_base_report()
3683 };
3684 let events = convert(report);
3685 assert_eq!(
3686 events.len(),
3687 1,
3688 "status {status} must produce the execution alone, got: {events:?}"
3689 );
3690 assert!(
3691 matches!(events[0].kind, AccountEventKind::Trade(_)),
3692 "the execution must still be reported for status {status}"
3693 );
3694 }
3695 }
3696
3697 #[test]
3698 fn test_convert_execution_report_trade_missing_last_price() {
3699 let report = ExecutionReport {
3701 execution_type: Some("TRADE".to_string()),
3702 side: Some("BUY".to_string()),
3703 trade_id: Some(9999),
3704 last_executed_price: None, last_executed_quantity: Some("0.01".to_string()),
3706 ..make_base_report()
3707 };
3708 assert!(
3709 sole_event(convert(report)).is_none(),
3710 "missing last price (L) should return None"
3711 );
3712 }
3713
3714 #[test]
3715 fn test_convert_execution_report_trade_missing_last_qty() {
3716 let report = ExecutionReport {
3718 execution_type: Some("TRADE".to_string()),
3719 side: Some("BUY".to_string()),
3720 trade_id: Some(9999),
3721 last_executed_price: Some("50000.00".to_string()),
3722 last_executed_quantity: None, ..make_base_report()
3724 };
3725 assert!(
3726 sole_event(convert(report)).is_none(),
3727 "missing last qty (l) should return None"
3728 );
3729 }
3730
3731 #[test]
3732 fn test_convert_execution_report_canceled() {
3733 let report = ExecutionReport {
3734 execution_type: Some("CANCELED".to_string()),
3735 ..make_base_report()
3736 };
3737
3738 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3739 assert!(
3740 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3741 "CANCELED should yield OrderCancelled with Ok state"
3742 );
3743 }
3744
3745 #[test]
3747 fn a_cancel_report_carries_its_fill_or_none() {
3748 let cancelled = |z: Option<&str>| {
3749 let report = ExecutionReport {
3750 execution_type: Some("CANCELED".to_string()),
3751 cumulative_filled_quantity: z.map(str::to_string),
3752 ..make_base_report()
3753 };
3754 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3755 let AccountEventKind::OrderCancelled(response) = event.kind else {
3756 panic!("expected OrderCancelled, got {:?}", event.kind);
3757 };
3758 let Ok(cancelled) = response.state else {
3759 panic!("expected Ok, got {:?}", response.state);
3760 };
3761 cancelled.filled_quantity
3762 };
3763
3764 assert_eq!(cancelled(Some("0.5")), Some(Decimal::new(5, 1)));
3765 assert_eq!(cancelled(Some("0")), Some(Decimal::ZERO));
3766 assert_eq!(cancelled(None), None);
3767 assert_eq!(cancelled(Some("not a number")), None);
3768 }
3769
3770 #[test]
3771 fn test_convert_execution_report_expired() {
3772 let report = ExecutionReport {
3773 execution_type: Some("EXPIRED".to_string()),
3774 ..make_base_report()
3775 };
3776 let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
3777 assert!(
3778 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3779 "EXPIRED should yield OrderCancelled with Ok state"
3780 );
3781 }
3782
3783 #[test]
3784 fn test_convert_execution_report_expired_in_match() {
3785 let report = ExecutionReport {
3786 execution_type: Some("EXPIRED_IN_MATCH".to_string()),
3787 ..make_base_report()
3788 };
3789 let event = sole_event(convert(report)).expect("EXPIRED_IN_MATCH should produce Some");
3790 assert!(
3791 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3792 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3793 );
3794 }
3795
3796 #[test]
3797 fn test_convert_execution_report_rejected() {
3798 let report = ExecutionReport {
3799 execution_type: Some("REJECTED".to_string()),
3800 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
3801 ..make_base_report()
3802 };
3803
3804 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
3805 assert!(
3806 matches!(
3807 event.kind,
3808 AccountEventKind::OrderCancelled(ref r) if matches!(
3809 &r.state,
3810 Err(OrderError::Rejected(ApiError::BalanceInsufficient(None, reason)))
3811 if reason == "INSUFFICIENT_FUNDS"
3812 )
3813 ),
3814 "a REJECTED shortfall should go through the classifier, got {:?}",
3815 event.kind
3816 );
3817 }
3818
3819 #[test]
3820 fn test_convert_execution_report_rejected_for_another_reason() {
3821 let report = ExecutionReport {
3822 execution_type: Some("REJECTED".to_string()),
3823 reject_reason: Some("WOULD_MATCH_IMMEDIATELY".to_string()),
3824 ..make_base_report()
3825 };
3826
3827 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
3828 assert!(
3829 matches!(
3830 event.kind,
3831 AccountEventKind::OrderCancelled(ref r) if matches!(
3832 &r.state,
3833 Err(OrderError::Rejected(ApiError::OrderRejected(reason)))
3834 if reason == "WOULD_MATCH_IMMEDIATELY"
3835 )
3836 ),
3837 "an unrecognised REJECTED reason should stay OrderRejected, got {:?}",
3838 event.kind
3839 );
3840 }
3841
3842 #[test]
3843 fn test_convert_execution_report_missing_exec_type() {
3844 let report = ExecutionReport {
3846 symbol: Some("BTCUSDT".to_string()),
3847 ..Default::default()
3848 };
3849 assert!(
3850 sole_event(convert(report)).is_none(),
3851 "missing execution type should return None"
3852 );
3853 }
3854
3855 #[test]
3856 fn test_convert_execution_report_missing_symbol() {
3857 let report = ExecutionReport {
3859 execution_type: Some("NEW".to_string()),
3860 ..Default::default()
3861 };
3862 assert!(
3863 sole_event(convert(report)).is_none(),
3864 "missing symbol should return None"
3865 );
3866 }
3867
3868 #[test]
3869 fn test_convert_execution_report_missing_order_id() {
3870 let report = ExecutionReport {
3872 execution_type: Some("NEW".to_string()),
3873 symbol: Some("BTCUSDT".to_string()),
3874 order_id: None,
3875 ..Default::default()
3876 };
3877 assert!(
3878 sole_event(convert(report)).is_none(),
3879 "missing orderId should return None"
3880 );
3881 }
3882
3883 #[test]
3884 fn test_convert_execution_report_trade_missing_trade_id() {
3885 let report = ExecutionReport {
3887 execution_type: Some("TRADE".to_string()),
3888 trade_id: None,
3889 ..make_base_report()
3890 };
3891 assert!(
3892 sole_event(convert(report)).is_none(),
3893 "TRADE event missing tradeId should return None"
3894 );
3895 }
3896
3897 #[test]
3898 fn test_convert_execution_report_replace_yields_cancelled() {
3899 let report = ExecutionReport {
3900 execution_type: Some("REPLACE".to_string()),
3901 ..make_base_report()
3902 };
3903 let event = sole_event(convert(report)).expect("REPLACE should produce Some");
3906 assert!(
3907 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3908 "REPLACE should yield OrderCancelled with Ok state"
3909 );
3910 }
3911
3912 fn make_balance(
3917 asset: &str,
3918 free: &str,
3919 locked: &str,
3920 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3921 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3922 asset: Some(asset.to_string()),
3923 free: Some(free.to_string()),
3924 locked: Some(locked.to_string()),
3925 }
3926 }
3927
3928 #[test]
3929 fn test_filter_balances_empty_assets_returns_all() {
3930 let balances = vec![
3931 make_balance("BTC", "1.0", "0.0"),
3932 make_balance("USDT", "500.0", "50.0"),
3933 ];
3934 let result = filter_and_convert_balances(balances, &[]);
3935 assert_eq!(result.len(), 2, "empty filter should return all balances");
3936 }
3937
3938 #[test]
3939 fn test_filter_balances_matching_asset_returned() {
3940 let balances = vec![
3941 make_balance("BTC", "1.5", "0.5"),
3942 make_balance("ETH", "10.0", "0.0"),
3943 ];
3944 let assets = vec![AssetNameExchange::new("BTC")];
3945 let result = filter_and_convert_balances(balances, &assets);
3946 assert_eq!(result.len(), 1);
3947 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
3948 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
3950 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
3951 }
3952
3953 #[test]
3954 fn test_filter_balances_non_matching_asset_filtered_out() {
3955 let balances = vec![
3956 make_balance("BTC", "1.0", "0.0"),
3957 make_balance("ETH", "2.0", "0.0"),
3958 ];
3959 let assets = vec![AssetNameExchange::new("USDT")];
3960 let result = filter_and_convert_balances(balances, &assets);
3961 assert!(
3962 result.is_empty(),
3963 "non-matching asset should be filtered out"
3964 );
3965 }
3966
3967 #[test]
3968 fn test_filter_balances_missing_asset_field_skipped() {
3969 let balances = vec![
3970 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3971 asset: None,
3972 free: Some("1.0".to_string()),
3973 locked: Some("0.0".to_string()),
3974 },
3975 make_balance("USDT", "100.0", "0.0"),
3976 ];
3977 let result = filter_and_convert_balances(balances, &[]);
3978 assert_eq!(
3979 result.len(),
3980 1,
3981 "entry with missing asset should be skipped"
3982 );
3983 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
3984 }
3985
3986 #[test]
3987 fn test_filter_balances_unparseable_free_skipped() {
3988 let balances = vec![
3989 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3990 asset: Some("BTC".to_string()),
3991 free: Some("not-a-number".to_string()),
3992 locked: Some("0.0".to_string()),
3993 },
3994 ];
3995 let result = filter_and_convert_balances(balances, &[]);
3996 assert!(
3997 result.is_empty(),
3998 "unparseable free field should be skipped"
3999 );
4000 }
4001
4002 #[test]
4003 fn test_filter_balances_zero_balance_included() {
4004 let balances = vec![
4007 make_balance("BTC", "0.00000000", "0.00000000"),
4008 make_balance("USDT", "100.0", "0.0"),
4009 ];
4010 let result = filter_and_convert_balances(balances, &[]);
4011 assert_eq!(result.len(), 2, "zero-balance entries must be included");
4012 let btc = result
4013 .iter()
4014 .find(|b| b.asset == AssetNameExchange::new("BTC"))
4015 .unwrap();
4016 assert_eq!(btc.balance.total, Decimal::ZERO);
4017 assert_eq!(btc.balance.free, Decimal::ZERO);
4018 }
4019
4020 #[test]
4021 fn test_filter_balances_duplicate_assets_in_response() {
4022 let balances = vec![
4026 make_balance("BTC", "1.0", "0.0"),
4027 make_balance("BTC", "2.0", "0.0"),
4028 ];
4029 let result = filter_and_convert_balances(balances, &[]);
4030 assert_eq!(
4031 result.len(),
4032 2,
4033 "duplicate asset entries produce two AssetBalance entries"
4034 );
4035 }
4036
4037 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
4042 binance_sdk::spot::rest_api::MyTradesResponseInner {
4043 id: Some(9001),
4044 order_id: Some(4242),
4045 price: Some("50000.00".to_string()),
4046 qty: Some("0.01".to_string()),
4047 commission: Some("0.05".to_string()),
4048 time: Some(1_700_000_000_000),
4049 is_buyer: Some(true),
4050 ..Default::default()
4051 }
4052 }
4053
4054 #[test]
4055 fn test_convert_my_trade_happy_path() {
4056 let instrument = InstrumentNameExchange::new("BTCUSDT");
4057 let trade =
4058 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
4059 assert_eq!(trade.instrument, instrument);
4060 assert_eq!(trade.side, Side::Buy);
4061 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
4062 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
4063 }
4064
4065 #[test]
4066 fn test_convert_my_trade_sell_side() {
4067 let instrument = InstrumentNameExchange::new("BTCUSDT");
4068 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4069 is_buyer: Some(false),
4070 ..make_base_trade()
4071 };
4072 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
4073 assert_eq!(trade.side, Side::Sell);
4074 }
4075
4076 #[test]
4077 fn test_convert_my_trade_missing_id_returns_none() {
4078 let instrument = InstrumentNameExchange::new("BTCUSDT");
4079 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4080 id: None,
4081 ..make_base_trade()
4082 };
4083 assert!(
4084 convert_my_trade(&t, &instrument).is_none(),
4085 "missing id should return None"
4086 );
4087 }
4088
4089 #[test]
4090 fn test_convert_my_trade_missing_order_id_returns_none() {
4091 let instrument = InstrumentNameExchange::new("BTCUSDT");
4092 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4093 order_id: None,
4094 ..make_base_trade()
4095 };
4096 assert!(
4097 convert_my_trade(&t, &instrument).is_none(),
4098 "missing orderId should return None"
4099 );
4100 }
4101
4102 #[test]
4103 fn test_convert_my_trade_missing_is_buyer_returns_none() {
4104 let instrument = InstrumentNameExchange::new("BTCUSDT");
4105 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4106 is_buyer: None,
4107 ..make_base_trade()
4108 };
4109 assert!(
4110 convert_my_trade(&t, &instrument).is_none(),
4111 "missing isBuyer should return None"
4112 );
4113 }
4114
4115 #[test]
4116 fn test_convert_my_trade_commission_none_defaults_to_zero() {
4117 let instrument = InstrumentNameExchange::new("BTCUSDT");
4118 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4119 commission: None,
4120 ..make_base_trade()
4121 };
4122 let trade =
4123 convert_my_trade(&t, &instrument).expect("None commission should still convert");
4124 assert_eq!(trade.fees.fees, Decimal::ZERO);
4125 }
4126
4127 fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4132 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4133 order_id: Some(12345),
4134 client_order_id: Some("cid-abc".to_string()),
4135 side: Some("BUY".to_string()),
4136 r#type: Some("LIMIT".to_string()),
4137 price: Some("50000.00".to_string()),
4138 orig_qty: Some("0.01".to_string()),
4139 executed_qty: Some("0.0".to_string()),
4140 time_in_force: Some("GTC".to_string()),
4141 time: Some(1_700_000_000_000),
4142 status: Some("NEW".to_string()),
4143 ..Default::default()
4144 }
4145 }
4146
4147 #[test]
4148 fn test_convert_open_order_all_orders_response_converts_identically() {
4149 let instrument = InstrumentNameExchange::new("BTCUSDT");
4156 let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4157 order_id: Some(12345),
4158 client_order_id: Some("cid-abc".to_string()),
4159 side: Some("BUY".to_string()),
4160 r#type: Some("LIMIT".to_string()),
4161 price: Some("50000.00".to_string()),
4162 orig_qty: Some("0.01".to_string()),
4163 executed_qty: Some("0.0".to_string()),
4164 time_in_force: Some("GTC".to_string()),
4165 time: Some(1_700_000_000_000),
4166 status: Some("NEW".to_string()),
4167 ..Default::default()
4168 };
4169 let from_all_orders = convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument)
4170 .expect("valid order should convert");
4171 let from_open_orders = convert_open_order(
4172 &make_base_open_order(),
4173 ExchangeId::BinanceSpot,
4174 &instrument,
4175 )
4176 .expect("valid order should convert");
4177 assert_eq!(from_all_orders, from_open_orders);
4178 }
4179
4180 #[test]
4186 fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
4187 let instrument = InstrumentNameExchange::new("BTCUSDT");
4188 for status in [
4189 "CANCELED",
4190 "FILLED",
4191 "EXPIRED",
4192 "EXPIRED_IN_MATCH",
4193 "REJECTED",
4194 "PENDING_CANCEL",
4195 "SOME_FUTURE_STATUS",
4196 ] {
4197 let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4198 order_id: Some(12345),
4199 client_order_id: Some("cid-abc".to_string()),
4200 side: Some("BUY".to_string()),
4201 r#type: Some("LIMIT".to_string()),
4202 price: Some("50000.00".to_string()),
4203 orig_qty: Some("0.01".to_string()),
4204 executed_qty: Some("0.004".to_string()),
4205 time_in_force: Some("GTC".to_string()),
4206 time: Some(1_700_000_000_000),
4207 status: Some(status.to_string()),
4208 ..Default::default()
4209 };
4210 assert_eq!(
4211 convert_open_order(&row, ExchangeId::BinanceSpot, &instrument),
4212 None,
4213 "{status}"
4214 );
4215 }
4216 }
4217
4218 #[test]
4219 fn test_convert_open_order_admits_every_live_status() {
4220 let instrument = InstrumentNameExchange::new("BTCUSDT");
4221 for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
4222 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4223 status: Some(status.to_string()),
4224 ..make_base_open_order()
4225 };
4226 assert!(
4227 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_some(),
4228 "{status}"
4229 );
4230 }
4231 }
4232
4233 #[test]
4238 fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
4239 let instrument = InstrumentNameExchange::new("BTCUSDT");
4240 let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4241 order_id: Some(67890),
4242 client_order_id: Some("cid-def".to_string()),
4243 ..make_base_open_order()
4244 };
4245
4246 let listing = convert_open_order_listing(
4247 &[make_base_open_order(), second()],
4248 ExchangeId::BinanceSpot,
4249 &instrument,
4250 );
4251 assert_eq!(listing.orders.len(), 2);
4252 assert!(listing.complete);
4253
4254 let empty = convert_open_order_listing::<
4255 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
4256 >(&[], ExchangeId::BinanceSpot, &instrument);
4257 assert!(empty.orders.is_empty());
4258 assert!(empty.complete, "no open orders is a complete answer");
4259
4260 let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4261 side: None,
4262 ..second()
4263 };
4264 let listing = convert_open_order_listing(
4265 &[make_base_open_order(), dropped],
4266 ExchangeId::BinanceSpot,
4267 &instrument,
4268 );
4269 assert_eq!(listing.orders.len(), 1);
4270 assert!(!listing.complete, "a dropped row may be a live order");
4271
4272 let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4273 client_order_id: None,
4274 ..second()
4275 };
4276 let listing = convert_open_order_listing(
4277 &[make_base_open_order(), without_cid],
4278 ExchangeId::BinanceSpot,
4279 &instrument,
4280 );
4281 assert_eq!(listing.orders.len(), 2, "the row is still converted");
4282 assert!(
4283 !listing.complete,
4284 "an order kept under its orderId cannot be found under the cid it was placed with"
4285 );
4286 }
4287
4288 #[test]
4289 fn test_convert_open_order_missing_status_returns_none() {
4290 let instrument = InstrumentNameExchange::new("BTCUSDT");
4291 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4292 status: None,
4293 ..make_base_open_order()
4294 };
4295 assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none());
4296 }
4297
4298 #[test]
4304 fn test_convert_open_order_prefers_update_time_over_creation_time() {
4305 let instrument = InstrumentNameExchange::new("BTCUSDT");
4306
4307 let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4308 update_time: Some(1_700_000_005_000),
4309 ..make_base_open_order()
4310 };
4311 let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument)
4312 .expect("valid order should convert");
4313 assert_eq!(
4314 order.state.time_exchange,
4315 Utc.timestamp_millis_opt(1_700_000_005_000)
4316 .single()
4317 .unwrap(),
4318 "update_time must win over time"
4319 );
4320
4321 let order = convert_open_order(
4324 &make_base_open_order(),
4325 ExchangeId::BinanceSpot,
4326 &instrument,
4327 )
4328 .expect("valid order should convert");
4329 assert_eq!(
4330 order.state.time_exchange,
4331 Utc.timestamp_millis_opt(1_700_000_000_000)
4332 .single()
4333 .unwrap(),
4334 "absent update_time falls back to time"
4335 );
4336 }
4337
4338 #[test]
4339 fn test_convert_open_order_happy_path() {
4340 let instrument = InstrumentNameExchange::new("BTCUSDT");
4341 let order = convert_open_order(
4342 &make_base_open_order(),
4343 ExchangeId::BinanceSpot,
4344 &instrument,
4345 )
4346 .expect("valid order should convert");
4347 assert_eq!(order.key.instrument, instrument);
4348 assert_eq!(order.side, Side::Buy);
4349 assert_eq!(order.kind, OrderKind::Limit);
4350 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
4351 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
4352 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
4353 }
4354
4355 #[test]
4356 fn test_convert_open_order_owned_symbol_recovers_instrument() {
4357 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4359 symbol: Some("ETHUSDT".to_string()),
4360 ..make_base_open_order()
4361 };
4362 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot)
4363 .expect("valid order should convert");
4364 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4365 assert_eq!(order.side, Side::Buy);
4366 }
4367
4368 #[test]
4369 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
4370 let o = make_base_open_order();
4372 assert!(o.symbol.is_none());
4373 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot).is_none());
4374 }
4375
4376 #[test]
4377 fn test_convert_open_order_missing_order_id_returns_none() {
4378 let instrument = InstrumentNameExchange::new("BTCUSDT");
4379 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4380 order_id: None,
4381 ..make_base_open_order()
4382 };
4383 assert!(
4384 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4385 "missing orderId should return None"
4386 );
4387 }
4388
4389 #[test]
4390 fn test_convert_open_order_missing_side_returns_none() {
4391 let instrument = InstrumentNameExchange::new("BTCUSDT");
4392 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4393 side: None,
4394 ..make_base_open_order()
4395 };
4396 assert!(
4397 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4398 "missing side should return None"
4399 );
4400 }
4401
4402 #[test]
4403 fn test_convert_open_order_missing_type_returns_none() {
4404 let instrument = InstrumentNameExchange::new("BTCUSDT");
4405 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4406 r#type: None,
4407 ..make_base_open_order()
4408 };
4409 assert!(
4410 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
4411 "missing type should return None"
4412 );
4413 }
4414
4415 #[test]
4416 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
4417 let instrument = InstrumentNameExchange::new("BTCUSDT");
4418 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4419 executed_qty: None,
4420 ..make_base_open_order()
4421 };
4422 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4423 .expect("None executedQty should still convert");
4424 assert_eq!(
4425 order.state.filled_quantity,
4426 Decimal::ZERO,
4427 "None executedQty should default to zero"
4428 );
4429 }
4430
4431 #[test]
4432 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
4433 let instrument = InstrumentNameExchange::new("BTCUSDT");
4434 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4435 executed_qty: Some("bad-value".to_string()),
4436 ..make_base_open_order()
4437 };
4438 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
4439 .expect("unparseable executedQty should still convert");
4440 assert_eq!(
4441 order.state.filled_quantity,
4442 Decimal::ZERO,
4443 "unparseable executedQty should default to zero"
4444 );
4445 }
4446
4447 #[test]
4452 fn test_dedup_key_from_event_non_open_states_return_none() {
4453 use crate::order::state::{
4454 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
4455 };
4456 use rustrade_integration::collection::snapshot::Snapshot;
4457
4458 let key = OrderKey::new(
4459 ExchangeId::BinanceSpot,
4460 InstrumentNameExchange::new("BTCUSDT"),
4461 StrategyId::unknown(),
4462 ClientOrderId::new("cid1"),
4463 );
4464
4465 let event = UnindexedAccountEvent::new(
4467 ExchangeId::BinanceSpot,
4468 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4469 key.clone(),
4470 Side::Buy,
4471 None, Decimal::ZERO,
4473 OrderKind::Market,
4474 TimeInForce::ImmediateOrCancel,
4475 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4476 ActiveOrderState::OpenInFlight(OpenInFlight::new(Utc::now())),
4477 ),
4478 ))),
4479 );
4480 assert!(
4481 dedup_key_from_event(&event).is_none(),
4482 "OpenInFlight should return None — dedup not meaningful before exchange ack"
4483 );
4484
4485 let event = UnindexedAccountEvent::new(
4487 ExchangeId::BinanceSpot,
4488 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4489 key.clone(),
4490 Side::Buy,
4491 None, Decimal::ZERO,
4493 OrderKind::Market,
4494 TimeInForce::ImmediateOrCancel,
4495 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4496 ActiveOrderState::CancelInFlight(CancelInFlight::new(None, Utc::now())),
4497 ),
4498 ))),
4499 );
4500 assert!(
4501 dedup_key_from_event(&event).is_none(),
4502 "CancelInFlight should return None"
4503 );
4504
4505 let event = UnindexedAccountEvent::new(
4507 ExchangeId::BinanceSpot,
4508 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4509 key,
4510 Side::Buy,
4511 None, Decimal::ONE,
4513 OrderKind::Market,
4514 TimeInForce::ImmediateOrCancel,
4515 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
4516 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
4517 OrderId::new("123"),
4518 Utc::now(),
4519 Decimal::ONE, None,
4521 )),
4522 ),
4523 ))),
4524 );
4525 assert!(
4526 dedup_key_from_event(&event).is_none(),
4527 "Inactive state should return None"
4528 );
4529 }
4530
4531 #[test]
4532 fn test_dedup_key_from_event_cancelled_error_returns_none() {
4533 let report = ExecutionReport {
4536 execution_type: Some("REJECTED".to_string()),
4537 reject_reason: Some("INSUFFICIENT_FUNDS".to_string()),
4538 ..make_base_report()
4539 };
4540 let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
4541 assert!(
4542 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
4543 "prerequisite: event is OrderCancelled with Err"
4544 );
4545 assert!(
4546 dedup_key_from_event(&event).is_none(),
4547 "OrderCancelled with Err state should return None"
4548 );
4549 }
4550
4551 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
4561 let mut value = serde_json::to_value(event).expect("event serializes to Value");
4562 value
4563 .as_object_mut()
4564 .expect("event serializes to a JSON object")
4565 .insert(
4566 "e".to_string(),
4567 serde_json::Value::String(event_type.to_string()),
4568 );
4569 serde_json::json!({ "subscriptionId": 0, "event": value }).to_string()
4570 }
4571
4572 #[test]
4573 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
4574 let report = ExecutionReport {
4575 execution_type: Some("NEW".to_string()),
4576 side: Some("BUY".to_string()),
4577 order_type: Some("LIMIT".to_string()),
4578 price: Some("50000.00".to_string()),
4579 order_quantity: Some("0.01".to_string()),
4580 time_in_force: Some("GTC".to_string()),
4581 cumulative_filled_quantity: Some("0".to_string()),
4582 ..make_base_report()
4583 };
4584 let frame = user_data_frame("executionReport", &report);
4585 let mut buf = Vec::new();
4586 let terminated = convert_user_data_events(&frame, &mut buf);
4587 assert!(
4588 !terminated,
4589 "ExecutionReport should not signal stream termination"
4590 );
4591 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
4592 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
4593 }
4594
4595 #[test]
4596 fn test_convert_user_data_events_account_position_pushes_to_buf() {
4597 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4598 u: Some(1_700_000_000_000),
4599 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
4600 ..Default::default()
4601 };
4602 let frame = user_data_frame("outboundAccountPosition", &position);
4603 let mut buf = Vec::new();
4604 let terminated = convert_user_data_events(&frame, &mut buf);
4605 assert!(
4606 !terminated,
4607 "OutboundAccountPosition should not signal stream termination"
4608 );
4609 assert_eq!(
4610 buf.len(),
4611 1,
4612 "OutboundAccountPosition should push one balance event"
4613 );
4614 }
4615
4616 #[test]
4617 fn test_convert_user_data_events_balance_update_ignored() {
4618 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4619 ..Default::default()
4620 };
4621 let frame = user_data_frame("balanceUpdate", &update);
4622 let mut buf = Vec::new();
4623 let terminated = convert_user_data_events(&frame, &mut buf);
4624 assert!(
4625 !terminated,
4626 "BalanceUpdate should not signal stream termination"
4627 );
4628 assert!(buf.is_empty(), "BalanceUpdate should push no events");
4629 }
4630
4631 #[test]
4632 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4633 let frame =
4635 r#"{"subscriptionId":0,"event":{"e":"eventStreamTerminated","E":1700000000000}}"#;
4636 let mut buf = Vec::new();
4637 let terminated = convert_user_data_events(frame, &mut buf);
4638 assert!(
4639 terminated,
4640 "eventStreamTerminated must signal stream termination"
4641 );
4642 assert!(
4643 buf.is_empty(),
4644 "eventStreamTerminated should push no events"
4645 );
4646 }
4647
4648 #[test]
4649 fn test_convert_user_data_events_unknown_event_ignored() {
4650 for e in ["listStatus", "externalLockUpdate", "someFutureEvent"] {
4653 let frame = format!(
4654 r#"{{"subscriptionId":0,"event":{{"e":"{e}","E":1700000000000,"s":"BTCUSDT"}}}}"#
4655 );
4656 let mut buf = Vec::new();
4657 let terminated = convert_user_data_events(&frame, &mut buf);
4658 assert!(!terminated, "{e} must not signal termination");
4659 assert!(buf.is_empty(), "{e} should push no events");
4660 }
4661 }
4662
4663 #[test]
4668 fn test_convert_user_data_events_reads_the_ws_api_envelope() {
4669 let fill = r#"{"subscriptionId":3,"event":{"e":"executionReport","E":1700000001000,
4670 "s":"BTCUSDT","c":"client-1","S":"SELL","o":"MARKET","f":"GTC","q":"0.5","p":"0",
4671 "x":"TRADE","X":"FILLED","i":777,"l":"0.5","z":"0.5","L":"40000","n":"0.02",
4672 "N":"USDT","T":1700000001000,"t":888}}"#;
4673 let mut buf = Vec::new();
4674 assert!(!convert_user_data_events(fill, &mut buf));
4675 assert!(
4676 buf.iter()
4677 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
4678 "the enveloped fill must produce its trade, got: {buf:?}"
4679 );
4680
4681 let bare = r#"{"e":"executionReport","s":"BTCUSDT","i":777,"x":"NEW","X":"NEW"}"#;
4683 buf.clear();
4684 assert!(!convert_user_data_events(bare, &mut buf));
4685 assert!(buf.is_empty());
4686 }
4687
4688 #[test]
4689 fn test_convert_user_data_events_non_user_data_frame_ignored() {
4690 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4692 let mut buf = Vec::new();
4693 let terminated = convert_user_data_events(frame, &mut buf);
4694 assert!(
4695 !terminated,
4696 "non-user-data frame must not signal termination"
4697 );
4698 assert!(buf.is_empty(), "non-user-data frame should push no events");
4699 }
4700
4701 #[tokio::test]
4706 async fn test_rate_limit_tracker_not_blocked_initially() {
4707 tokio::time::pause();
4708 let tracker = RateLimitTracker::new(WeightPool::Spot);
4709 tokio::time::timeout(
4711 std::time::Duration::from_millis(1),
4712 tracker.wait_if_blocked(RequestKind::Order),
4713 )
4714 .await
4715 .expect("wait_if_blocked should return immediately when not blocked");
4716 }
4717
4718 #[tokio::test]
4719 async fn test_rate_limit_tracker_blocks_until_deadline() {
4720 tokio::time::pause();
4721 let tracker = RateLimitTracker::new(WeightPool::Spot);
4722 let delay = Duration::from_secs(5);
4723 tracker.on_rate_limited(Some(delay));
4724
4725 assert!(
4727 tokio::time::timeout(
4728 Duration::from_millis(1),
4729 tracker.wait_if_blocked(RequestKind::Order)
4730 )
4731 .await
4732 .is_err(),
4733 "wait_if_blocked should block while cooldown is active"
4734 );
4735
4736 tokio::time::advance(delay + Duration::from_millis(1)).await;
4738 tokio::time::timeout(
4739 Duration::from_millis(1),
4740 tracker.wait_if_blocked(RequestKind::Order),
4741 )
4742 .await
4743 .expect("wait_if_blocked should return after cooldown expires");
4744 }
4745
4746 #[tokio::test]
4747 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
4748 tokio::time::pause();
4749 let tracker = RateLimitTracker::new(WeightPool::Spot);
4750
4751 tracker.on_rate_limited(Some(Duration::from_secs(5)));
4753 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4755
4756 tokio::time::advance(Duration::from_secs(6)).await;
4758 assert!(
4759 tokio::time::timeout(
4760 Duration::from_millis(1),
4761 tracker.wait_if_blocked(RequestKind::Order)
4762 )
4763 .await
4764 .is_err(),
4765 "cooldown should have been extended to 10s"
4766 );
4767
4768 tokio::time::advance(Duration::from_secs(5)).await;
4770 tokio::time::timeout(
4771 Duration::from_millis(1),
4772 tracker.wait_if_blocked(RequestKind::Order),
4773 )
4774 .await
4775 .expect("wait_if_blocked should return after extended cooldown expires");
4776 }
4777
4778 #[tokio::test]
4779 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
4780 tokio::time::pause();
4781 let tracker = RateLimitTracker::new(WeightPool::Spot);
4782
4783 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4785 tracker.on_rate_limited(Some(Duration::from_secs(2)));
4786
4787 tokio::time::advance(Duration::from_secs(3)).await;
4789 assert!(
4790 tokio::time::timeout(
4791 Duration::from_millis(1),
4792 tracker.wait_if_blocked(RequestKind::Order)
4793 )
4794 .await
4795 .is_err(),
4796 "shorter on_rate_limited must not shorten existing cooldown"
4797 );
4798 }
4799
4800 #[test]
4805 fn test_classify_ws_order_error_with_wrapped_error_chain() {
4806 let instrument = InstrumentNameExchange::new("BTCUSDT");
4811 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
4812 code: -2010,
4813 message: "insufficient balance".into(),
4814 });
4815 assert!(
4817 classify_ws_order_error(&raw, &instrument).is_some(),
4818 "unwrapped ResponseError at root must be detected"
4819 );
4820 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
4822 assert!(
4823 classify_ws_order_error(&wrapped, &instrument).is_some(),
4824 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
4825 );
4826 }
4827
4828 #[tokio::test]
4831 async fn rest_used_weight_near_the_limit_pauses_queries() {
4832 for (used, pauses) in [(5_399, false), (5_400, true)] {
4833 let server = wiremock::MockServer::start().await;
4834 wiremock::Mock::given(wiremock::matchers::method("GET"))
4835 .and(wiremock::matchers::path("/api/v3/openOrders"))
4836 .respond_with(
4837 wiremock::ResponseTemplate::new(200)
4838 .insert_header("x-mbx-used-weight-1m", used.to_string())
4839 .set_body_json(serde_json::json!([])),
4840 )
4841 .mount(&server)
4842 .await;
4843 let rest = Arc::new(SpotRestApi::from_config(
4844 ConfigurationRestApi::builder()
4845 .api_key("key")
4846 .api_secret("secret")
4847 .base_path(server.uri())
4848 .build()
4849 .unwrap(),
4850 ));
4851 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
4852
4853 fetch_all_open_orders(rest, Arc::clone(&tracker))
4854 .await
4855 .unwrap();
4856
4857 let query_waits = tokio::time::timeout(
4858 Duration::from_millis(50),
4859 tracker.wait_if_blocked(RequestKind::Query),
4860 )
4861 .await
4862 .is_err();
4863 assert_eq!(query_waits, pauses, "used weight {used}");
4864 tokio::time::timeout(
4865 Duration::from_millis(50),
4866 tracker.wait_if_blocked(RequestKind::Order),
4867 )
4868 .await
4869 .expect("orders never wait on the pause");
4870 }
4871 }
4872
4873 fn client_with_ws_api(ws_url: String) -> BinanceSpot {
4875 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4876 "key".into(),
4877 "secret".into(),
4878 ));
4879 client.ws_handle = SpotWsApi::from_config(
4880 ConfigurationWebsocketApi::builder()
4881 .api_key("key")
4882 .api_secret("secret")
4883 .ws_url(ws_url)
4884 .build()
4885 .unwrap(),
4886 );
4887 client
4888 }
4889
4890 fn cancel_request(
4892 instrument: &InstrumentNameExchange,
4893 ) -> OrderRequestCancel<ExchangeId, &InstrumentNameExchange> {
4894 crate::order::OrderEvent {
4895 key: OrderKey {
4896 exchange: ExchangeId::BinanceSpot,
4897 instrument,
4898 strategy: StrategyId::new("strategy"),
4899 cid: ClientOrderId::new("cid"),
4900 },
4901 state: crate::order::request::RequestCancel { id: None },
4902 }
4903 }
4904
4905 async fn ws_server<F, Fut>(serve: F) -> String
4907 where
4908 F: Fn(tokio::net::TcpStream) -> Fut + Send + 'static,
4909 Fut: std::future::Future<Output = ()> + Send + 'static,
4910 {
4911 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4912 let url = format!("ws://{}", listener.local_addr().unwrap());
4913 tokio::spawn(async move {
4914 while let Ok((stream, _)) = listener.accept().await {
4915 tokio::spawn(serve(stream));
4916 }
4917 });
4918 url
4919 }
4920
4921 #[tokio::test]
4924 async fn cancel_without_a_session_during_a_cooldown_is_rejected_unsent() {
4925 let client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
4926 "key".into(),
4927 "secret".into(),
4928 ));
4929 client
4930 .rate_limiter
4931 .on_rate_limited(Some(Duration::from_secs(60)));
4932 let instrument = InstrumentNameExchange::new("BTCUSDT");
4933 let response = client.cancel_order(cancel_request(&instrument)).await;
4934
4935 assert!(
4936 matches!(
4937 response.state,
4938 Err(UnindexedOrderError::Rejected(ApiError::RateLimit { .. }))
4939 ),
4940 "{:?}",
4941 response.state
4942 );
4943 assert!(
4944 client.ws_api.read().await.is_none(),
4945 "no session was opened"
4946 );
4947 }
4948
4949 #[tokio::test]
4952 async fn a_handshake_refused_with_429_starts_a_cooldown() {
4953 use tokio_tungstenite::tungstenite::http;
4954
4955 let url = ws_server(|stream| async move {
4956 #[allow(clippy::result_large_err)]
4958 let refuse = |_: &http::Request<()>, _| {
4959 Err(http::Response::builder()
4960 .status(http::StatusCode::TOO_MANY_REQUESTS)
4961 .body(None)
4962 .unwrap())
4963 };
4964 let _ = tokio_tungstenite::accept_hdr_async(stream, refuse).await;
4965 })
4966 .await;
4967 let client = client_with_ws_api(url);
4968 let instrument = InstrumentNameExchange::new("BTCUSDT");
4969
4970 let response = client.cancel_order(cancel_request(&instrument)).await;
4971
4972 assert!(
4973 matches!(
4974 response.state,
4975 Err(UnindexedOrderError::Rejected(ApiError::RateLimit { .. }))
4976 ),
4977 "{:?}",
4978 response.state
4979 );
4980 assert!(client.rate_limiter.is_blocked());
4981 }
4982
4983 #[tokio::test]
4986 async fn a_ws_api_response_near_the_weight_limit_pauses_queries() {
4987 use futures::{SinkExt as _, StreamExt as _};
4988 use tokio_tungstenite::tungstenite::Message;
4989
4990 let url = ws_server(|stream| async move {
4991 let Ok(mut ws) = tokio_tungstenite::accept_async(stream).await else {
4992 return;
4993 };
4994 while let Some(Ok(message)) = ws.next().await {
4995 let Message::Text(text) = message else {
4996 continue;
4997 };
4998 let request: serde_json::Value = serde_json::from_str(&text).unwrap();
4999 let response = serde_json::json!({
5000 "id": request["id"],
5001 "status": 200,
5002 "result": {
5003 "symbol": "BTCUSDT",
5004 "origClientOrderId": "cid",
5005 "orderId": 7,
5006 "transactTime": 1_700_000_000_000_i64,
5007 "executedQty": "0",
5008 },
5009 "rateLimits": [{
5010 "rateLimitType": "REQUEST_WEIGHT",
5011 "interval": "MINUTE",
5012 "intervalNum": 1,
5013 "limit": 1_000,
5014 "count": 950,
5015 }],
5016 });
5017 if ws.send(Message::text(response.to_string())).await.is_err() {
5018 return;
5019 }
5020 }
5021 })
5022 .await;
5023 let client = client_with_ws_api(url);
5024 let instrument = InstrumentNameExchange::new("BTCUSDT");
5025
5026 let response = client.cancel_order(cancel_request(&instrument)).await;
5027
5028 assert!(response.state.is_ok(), "{:?}", response.state);
5029 assert_eq!(client.rate_limiter.weight_limit(), 1_000);
5030 assert!(
5031 tokio::time::timeout(
5032 Duration::from_millis(50),
5033 client.rate_limiter.wait_if_blocked(RequestKind::Query),
5034 )
5035 .await
5036 .is_err(),
5037 "queries pause"
5038 );
5039 tokio::time::timeout(
5040 Duration::from_millis(50),
5041 client.rate_limiter.wait_if_blocked(RequestKind::Order),
5042 )
5043 .await
5044 .expect("orders never wait on the pause");
5045 }
5046
5047 #[tokio::test]
5050 async fn fill_recovery_does_not_wait_for_the_weight_pause() {
5051 let server = wiremock::MockServer::start().await;
5052 wiremock::Mock::given(wiremock::matchers::method("GET"))
5053 .and(wiremock::matchers::path("/api/v3/myTrades"))
5054 .respond_with(
5056 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
5057 "symbol": "BTCUSDT",
5058 "id": 1,
5059 "orderId": 7,
5060 "price": "100",
5061 "qty": "1",
5062 "commission": "0",
5063 "commissionAsset": "USDT",
5064 "time": Utc::now().timestamp_millis(),
5065 "isBuyer": true,
5066 "isMaker": false,
5067 }])),
5068 )
5069 .expect(2)
5070 .mount(&server)
5071 .await;
5072 let rest = Arc::new(SpotRestApi::from_config(
5073 ConfigurationRestApi::builder()
5074 .api_key("key")
5075 .api_secret("secret")
5076 .base_path(server.uri())
5077 .build()
5078 .unwrap(),
5079 ));
5080 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5081 tracker.throttle(Duration::from_secs(60));
5082 let (tx, _rx) = mpsc::unbounded_channel();
5083 let mut unrecovered = UnrecoveredFills::default();
5084 unrecovered.open(
5085 &[InstrumentNameExchange::new("BTCUSDT")],
5086 Utc::now() - chrono::Duration::minutes(1),
5087 Utc::now(),
5088 );
5089
5090 tokio::time::timeout(
5092 Duration::from_millis(800),
5093 recover_fills(
5094 &rest,
5095 &tracker,
5096 &mut unrecovered,
5097 &tx,
5098 &new_dedup_cache(),
5099 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5100 ),
5101 )
5102 .await
5103 .expect("recovery does not wait for the pause");
5104 }
5105
5106 #[tokio::test]
5108 async fn a_gap_whose_every_read_fails_is_reported_when_given_up() {
5109 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5110 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5111 let server = wiremock::MockServer::start().await;
5112 wiremock::Mock::given(wiremock::matchers::method("GET"))
5113 .and(wiremock::matchers::path("/api/v3/myTrades"))
5114 .respond_with(
5115 wiremock::ResponseTemplate::new(400)
5116 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5117 )
5118 .mount(&server)
5119 .await;
5120 let rest = Arc::new(SpotRestApi::from_config(
5121 ConfigurationRestApi::builder()
5122 .api_key("key")
5123 .api_secret("secret")
5124 .base_path(server.uri())
5125 .build()
5126 .expect("valid config"),
5127 ));
5128 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5129 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5130 let dedup = new_dedup_cache();
5131 let btc = InstrumentNameExchange::new("BTCUSDT");
5132 let mut unrecovered = UnrecoveredFills::default();
5133 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
5134
5135 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
5136 recover_fills(
5137 &rest,
5138 &tracker,
5139 &mut unrecovered,
5140 &tx,
5141 &dedup,
5142 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5143 )
5144 .await;
5145 unrecovered.make_due();
5146 }
5147
5148 assert!(unrecovered.is_empty(), "the gap is given up");
5149 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5150 let [event] = forwarded.as_slice() else {
5151 panic!("only the give-up is sent: {forwarded:?}");
5152 };
5153 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
5154 panic!("expected FillRecoveryGaveUp, got {event:?}");
5155 };
5156 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
5157 assert_eq!(
5158 gave_up.scope,
5159 crate::FillRecoveryScope::Instruments(vec![btc])
5160 );
5161 assert_eq!(
5162 gave_up.start.timestamp_millis(),
5163 disconnect.timestamp_millis()
5164 );
5165 assert_eq!(
5166 gave_up.attempts,
5167 crate::client::order_recovery::MAX_GAP_RETRIES + 1
5168 );
5169 assert!(
5170 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
5171 "the last read's error: {:?}",
5172 gave_up.reason
5173 );
5174 }
5175
5176 #[tokio::test]
5179 async fn a_failed_recovery_keeps_its_closed_gap_for_the_retry() {
5180 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5181 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5182 let trade = |id: i64, time: DateTime<Utc>| {
5183 serde_json::json!({
5184 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5185 "commission": "0", "commissionAsset": "USDT", "time": time.timestamp_millis(),
5186 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5187 })
5188 };
5189 let server = wiremock::MockServer::start().await;
5190 wiremock::Mock::given(wiremock::matchers::method("GET"))
5191 .and(wiremock::matchers::path("/api/v3/myTrades"))
5192 .respond_with(
5193 wiremock::ResponseTemplate::new(400)
5194 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5195 )
5196 .up_to_n_times(1)
5197 .with_priority(1)
5198 .mount(&server)
5199 .await;
5200 wiremock::Mock::given(wiremock::matchers::method("GET"))
5201 .and(wiremock::matchers::path("/api/v3/myTrades"))
5202 .respond_with(
5203 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([
5204 trade(1, disconnect + chrono::Duration::minutes(1)),
5205 trade(2, Utc::now() - chrono::Duration::minutes(1)),
5206 ])),
5207 )
5208 .mount(&server)
5209 .await;
5210 let rest = Arc::new(SpotRestApi::from_config(
5211 ConfigurationRestApi::builder()
5212 .api_key("key")
5213 .api_secret("secret")
5214 .base_path(server.uri())
5215 .build()
5216 .unwrap(),
5217 ));
5218 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5219 let (tx, mut rx) = mpsc::unbounded_channel();
5220 let dedup = new_dedup_cache();
5221 let mut unrecovered = UnrecoveredFills::default();
5222 unrecovered.open(
5223 &[InstrumentNameExchange::new("BTCUSDT")],
5224 disconnect,
5225 reconnect,
5226 );
5227
5228 recover_fills(
5229 &rest,
5230 &tracker,
5231 &mut unrecovered,
5232 &tx,
5233 &dedup,
5234 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5235 )
5236 .await;
5237 assert!(!unrecovered.is_empty(), "the failed gap is kept");
5238 assert!(rx.try_recv().is_err(), "nothing forwarded");
5239 assert!(
5240 unrecovered.due(tokio::time::Instant::now()).is_empty(),
5241 "and waits for its retry"
5242 );
5243
5244 unrecovered.make_due();
5245 recover_fills(
5246 &rest,
5247 &tracker,
5248 &mut unrecovered,
5249 &tx,
5250 &dedup,
5251 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5252 )
5253 .await;
5254 assert!(unrecovered.is_empty(), "the retry recovered it");
5255 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5256 assert_eq!(
5257 forwarded.len(),
5258 1,
5259 "only the fill inside the gap: {forwarded:?}"
5260 );
5261
5262 let start_times: Vec<String> = server
5263 .received_requests()
5264 .await
5265 .unwrap()
5266 .iter()
5267 .filter_map(|request| {
5268 request
5269 .url
5270 .query_pairs()
5271 .find(|(key, _)| key == "startTime")
5272 .map(|(_, value)| value.into_owned())
5273 })
5274 .collect();
5275 let start = disconnect.timestamp_millis().to_string();
5276 assert_eq!(start_times, [start.clone(), start]);
5277 }
5278
5279 #[tokio::test]
5282 async fn recovered_fills_carry_their_orders_client_order_ids() {
5283 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5284 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5285 let trade = |id: i64| {
5286 serde_json::json!({
5287 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5288 "commission": "0", "commissionAsset": "USDT",
5289 "time": (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
5290 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5291 })
5292 };
5293 let server = wiremock::MockServer::start().await;
5294 for id in [1, 2] {
5296 wiremock::Mock::given(wiremock::matchers::method("GET"))
5297 .and(wiremock::matchers::path("/api/v3/myTrades"))
5298 .and(wiremock::matchers::query_param("orderId", id.to_string()))
5299 .respond_with(
5300 wiremock::ResponseTemplate::new(200)
5301 .set_body_json(serde_json::json!([trade(id)])),
5302 )
5303 .with_priority(1)
5304 .mount(&server)
5305 .await;
5306 }
5307 wiremock::Mock::given(wiremock::matchers::method("GET"))
5308 .and(wiremock::matchers::path("/api/v3/myTrades"))
5309 .respond_with(
5310 wiremock::ResponseTemplate::new(200)
5311 .set_body_json(serde_json::json!([trade(1), trade(2)])),
5312 )
5313 .mount(&server)
5314 .await;
5315 wiremock::Mock::given(wiremock::matchers::method("GET"))
5316 .and(wiremock::matchers::path("/api/v3/order"))
5317 .and(wiremock::matchers::query_param("orderId", "1"))
5318 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
5319 serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "sweep-1"}),
5320 ))
5321 .mount(&server)
5322 .await;
5323 wiremock::Mock::given(wiremock::matchers::method("GET"))
5324 .and(wiremock::matchers::path("/api/v3/order"))
5325 .and(wiremock::matchers::query_param("orderId", "2"))
5326 .respond_with(
5327 wiremock::ResponseTemplate::new(400).set_body_json(
5328 serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
5329 ),
5330 )
5331 .mount(&server)
5332 .await;
5333 let rest = Arc::new(SpotRestApi::from_config(
5334 ConfigurationRestApi::builder()
5335 .api_key("key")
5336 .api_secret("secret")
5337 .base_path(server.uri())
5338 .build()
5339 .unwrap(),
5340 ));
5341 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Spot));
5342 let (tx, mut rx) = mpsc::unbounded_channel();
5343 let mut unrecovered = UnrecoveredFills::default();
5344 unrecovered.open(
5345 &[InstrumentNameExchange::new("BTCUSDT")],
5346 disconnect,
5347 reconnect,
5348 );
5349
5350 recover_fills(
5351 &rest,
5352 &tracker,
5353 &mut unrecovered,
5354 &tx,
5355 &new_dedup_cache(),
5356 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5357 )
5358 .await;
5359
5360 let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
5361 .filter_map(|event| match event.kind {
5362 AccountEventKind::Trade(trade) => Some((
5363 trade.id.0.to_string(),
5364 trade.client_order_id,
5365 trade.order_filled_quantity,
5366 )),
5367 _ => None,
5368 })
5369 .collect();
5370 cids.sort();
5371 assert_eq!(
5373 cids,
5374 [
5375 (
5376 "1".to_string(),
5377 Some(ClientOrderId::new("sweep-1")),
5378 Some(Decimal::ONE)
5379 ),
5380 ("2".to_string(), None, Some(Decimal::ONE)),
5381 ]
5382 );
5383 let order_reads = server
5384 .received_requests()
5385 .await
5386 .unwrap()
5387 .iter()
5388 .filter(|request| request.url.path() == "/api/v3/order")
5389 .count();
5390 assert_eq!(order_reads, 2, "one read per order");
5391 }
5392
5393 #[tokio::test]
5396 async fn a_span_walk_pages_by_id_and_stops_past_the_end() {
5397 let start = Utc::now().timestamp_millis() - 600_000;
5398 let end = start + 10_000;
5399 let trade = |id: i64, time: i64| {
5400 serde_json::json!({
5401 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5402 "commission": "0", "commissionAsset": "USDT", "time": time,
5403 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5404 })
5405 };
5406 let server = wiremock::MockServer::start().await;
5407 wiremock::Mock::given(wiremock::matchers::method("GET"))
5408 .and(wiremock::matchers::path("/api/v3/myTrades"))
5409 .and(wiremock::matchers::query_param("fromId", "1001"))
5410 .respond_with(
5412 wiremock::ResponseTemplate::new(200).set_body_json(
5413 (1_001..=2_000)
5414 .map(|id| trade(id, if id == 1_001 { end } else { end + 1 }))
5415 .collect::<Vec<_>>(),
5416 ),
5417 )
5418 .with_priority(1)
5419 .mount(&server)
5420 .await;
5421 let full_page: Vec<_> = (1..=1_000).map(|id| trade(id, start + id)).collect();
5422 wiremock::Mock::given(wiremock::matchers::method("GET"))
5423 .and(wiremock::matchers::path("/api/v3/myTrades"))
5424 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(full_page))
5425 .mount(&server)
5426 .await;
5427 let rest = Arc::new(SpotRestApi::from_config(
5428 ConfigurationRestApi::builder()
5429 .api_key("key")
5430 .api_secret("secret")
5431 .base_path(server.uri())
5432 .build()
5433 .unwrap(),
5434 ));
5435
5436 let read = paginate_my_trades(
5437 &rest,
5438 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5439 &InstrumentNameExchange::new("BTCUSDT"),
5440 MyTradesFrom::Span { start, end },
5441 RequestKind::Query,
5442 )
5443 .await
5444 .unwrap();
5445
5446 let ids: Vec<_> = read.iter().filter_map(|t| t.id).collect();
5447 assert_eq!(ids, (1..=1_001).collect::<Vec<_>>());
5448 assert_eq!(server.received_requests().await.unwrap().len(), 2);
5449 }
5450
5451 #[tokio::test]
5454 async fn fetch_trades_returns_only_the_span() {
5455 let start = DateTime::from_timestamp_millis(
5457 (Utc::now() - chrono::Duration::minutes(10)).timestamp_millis(),
5458 )
5459 .unwrap()
5460 + chrono::Duration::microseconds(500);
5461 let end = start + chrono::Duration::seconds(10);
5462 let (start_ms, end_ms) = (start.timestamp_millis(), end.timestamp_millis());
5463 let trade = |id: i64, time: i64| {
5464 serde_json::json!({
5465 "symbol": "BTCUSDT", "id": id, "orderId": id, "price": "100", "qty": "1",
5466 "commission": "0", "commissionAsset": "USDT", "time": time,
5467 "isBuyer": true, "isMaker": false, "isBestMatch": true,
5468 })
5469 };
5470 let server = wiremock::MockServer::start().await;
5471 wiremock::Mock::given(wiremock::matchers::method("GET"))
5472 .and(wiremock::matchers::path("/api/v3/myTrades"))
5473 .and(wiremock::matchers::query_param(
5474 "startTime",
5475 start_ms.to_string(),
5476 ))
5477 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(vec![
5478 trade(1, start_ms),
5479 trade(2, start_ms + 1),
5480 trade(3, end_ms),
5481 trade(4, end_ms + 1),
5482 ]))
5483 .mount(&server)
5484 .await;
5485 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5486 "key".into(),
5487 "secret".into(),
5488 ));
5489 client.rest = Arc::new(SpotRestApi::from_config(
5490 ConfigurationRestApi::builder()
5491 .api_key("key")
5492 .api_secret("secret")
5493 .base_path(server.uri())
5494 .build()
5495 .unwrap(),
5496 ));
5497 let btc = [InstrumentNameExchange::new("BTCUSDT")];
5498
5499 let read = client.fetch_trades(start, end, &btc).await.unwrap();
5500 let ids: Vec<_> = read.trades.iter().map(|t| t.id.0.as_str()).collect();
5501 assert_eq!(ids, ["2", "3"]);
5502 assert_eq!(read.resume, None, "spot reads a span whole");
5503
5504 let empty = client.fetch_trades(end, start, &btc).await.unwrap();
5505 assert!(empty.trades.is_empty() && empty.resume.is_none());
5506 assert_eq!(server.received_requests().await.unwrap().len(), 1);
5507 }
5508
5509 #[tokio::test]
5511 async fn a_gap_whose_every_read_times_out_is_reported_when_given_up() {
5512 let server = wiremock::MockServer::start().await;
5513 wiremock::Mock::given(wiremock::matchers::method("GET"))
5514 .and(wiremock::matchers::path("/api/v3/myTrades"))
5515 .respond_with(
5516 wiremock::ResponseTemplate::new(200)
5517 .set_body_json(serde_json::json!([]))
5518 .set_delay(Duration::from_secs(3_600)),
5519 )
5520 .mount(&server)
5521 .await;
5522 let rest = Arc::new(SpotRestApi::from_config(
5523 ConfigurationRestApi::builder()
5524 .api_key("key")
5525 .api_secret("secret")
5526 .base_path(server.uri())
5527 .timeout(3_600_000_u64)
5528 .retries(0_u32)
5529 .build()
5530 .expect("valid config"),
5531 ));
5532 let btc = InstrumentNameExchange::new("BTCUSDT");
5533 let mut unrecovered = UnrecoveredFills::default();
5534 unrecovered.open(
5535 std::slice::from_ref(&btc),
5536 Utc::now() - chrono::Duration::minutes(10),
5537 Utc::now(),
5538 );
5539 let (tx, mut rx) = mpsc::unbounded_channel();
5540 tokio::time::pause();
5542
5543 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
5544 recover_fills(
5545 &rest,
5546 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5547 &mut unrecovered,
5548 &tx,
5549 &new_dedup_cache(),
5550 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5551 )
5552 .await;
5553 unrecovered.make_due();
5554 }
5555
5556 assert!(unrecovered.is_empty(), "the gap is given up");
5557 let Ok(event) = rx.try_recv() else {
5558 panic!("the given-up gap is reported");
5559 };
5560 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
5561 panic!("expected FillRecoveryGaveUp, got {event:?}");
5562 };
5563 assert_eq!(
5564 gave_up.scope,
5565 crate::FillRecoveryScope::Instruments(vec![btc])
5566 );
5567 assert_eq!(
5568 gave_up.reason,
5569 FillRecoveryFailure::TimedOut {
5570 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS
5571 }
5572 );
5573 assert!(rx.try_recv().is_err(), "reported once");
5574 }
5575
5576 #[tokio::test]
5579 async fn a_timed_out_recovery_charges_only_the_gaps_it_started() {
5580 let server = wiremock::MockServer::start().await;
5581 wiremock::Mock::given(wiremock::matchers::method("GET"))
5582 .and(wiremock::matchers::path("/api/v3/myTrades"))
5583 .respond_with(
5584 wiremock::ResponseTemplate::new(200)
5585 .set_body_json(serde_json::json!([]))
5586 .set_delay(Duration::from_secs(3_600)),
5587 )
5588 .mount(&server)
5589 .await;
5590 let rest = Arc::new(SpotRestApi::from_config(
5591 ConfigurationRestApi::builder()
5592 .api_key("key")
5593 .api_secret("secret")
5594 .base_path(server.uri())
5595 .timeout(3_600_000_u64)
5596 .retries(0_u32)
5597 .build()
5598 .unwrap(),
5599 ));
5600 let instruments: Vec<_> = (0..9)
5601 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
5602 .collect();
5603 let mut unrecovered = UnrecoveredFills::default();
5604 unrecovered.open(
5605 &instruments,
5606 Utc::now() - chrono::Duration::minutes(10),
5607 Utc::now(),
5608 );
5609 let (tx, _rx) = mpsc::unbounded_channel();
5610 tokio::time::pause();
5612
5613 recover_fills(
5614 &rest,
5615 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5616 &mut unrecovered,
5617 &tx,
5618 &new_dedup_cache(),
5619 &KnownLiveOrders::shared(ExchangeId::BinanceSpot),
5620 )
5621 .await;
5622
5623 let now = tokio::time::Instant::now();
5624 assert_eq!(
5625 unrecovered.due(now).len(),
5626 1,
5627 "the gap never started stays due"
5628 );
5629 assert_eq!(
5630 unrecovered
5631 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
5632 .len(),
5633 9,
5634 "the eight started wait for their first retry"
5635 );
5636 }
5637
5638 fn order_row(cid: &str, status: &str) -> serde_json::Value {
5644 serde_json::json!({
5645 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
5646 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
5647 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "time": 1_700_000_000_000_i64,
5648 "updateTime": 1_700_000_060_000_i64,
5649 })
5650 }
5651
5652 fn get_order_row(row: serde_json::Value) -> binance_sdk::spot::rest_api::GetOrderResponse {
5653 serde_json::from_value(row).unwrap()
5654 }
5655
5656 fn spot_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
5657 OrderKey::new(
5658 ExchangeId::BinanceSpot,
5659 InstrumentNameExchange::new(instrument),
5660 StrategyId::new("strategy"),
5661 ClientOrderId::new(cid),
5662 )
5663 }
5664
5665 fn ended_state(
5666 key: &UnindexedOrderKey,
5667 row: serde_json::Value,
5668 ) -> Option<crate::order::state::UnindexedInactiveOrderState> {
5669 let order = convert_ended_order(&get_order_row(row), ExchangeId::BinanceSpot, key, None)?;
5670 assert_eq!(&order.key, key, "reported under the key asked");
5671 assert_eq!(order.quantity, Decimal::TWO);
5672 Some(order.state)
5673 }
5674
5675 #[test]
5678 fn an_ended_order_row_without_a_fill_reports_it_unknown() {
5679 use crate::order::state::{Expired, Filled, InactiveOrderState};
5680 let key = spot_key("BTCUSDT", "a");
5681 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
5682 let id = OrderId::new("7");
5683 let row = |status: &str, executed_qty: Option<&str>| {
5684 let mut row = order_row("a", status);
5685 row["executedQty"] = executed_qty.map_or(serde_json::Value::Null, Into::into);
5686 row
5687 };
5688
5689 for executed_qty in [None, Some("not a number")] {
5690 assert_eq!(
5691 ended_state(&key, row("CANCELED", executed_qty)),
5692 Some(InactiveOrderState::Cancelled(Cancelled::new(
5693 id.clone(),
5694 ended,
5695 None
5696 ))),
5697 "{executed_qty:?}"
5698 );
5699 assert_eq!(
5700 ended_state(&key, row("EXPIRED", executed_qty)),
5701 Some(InactiveOrderState::Expired(Expired::new(
5702 id.clone(),
5703 ended,
5704 None
5705 ))),
5706 "{executed_qty:?}"
5707 );
5708 assert_eq!(
5709 ended_state(&key, row("FILLED", executed_qty)),
5710 Some(InactiveOrderState::FullyFilled(Filled::new(
5711 id.clone(),
5712 ended,
5713 Decimal::TWO,
5714 Some(Decimal::new(525, 1)),
5715 ))),
5716 "the whole quantity, at the quote over it: {executed_qty:?}"
5717 );
5718 }
5719 }
5720
5721 #[test]
5722 fn an_ended_order_row_says_how_the_order_ended() {
5723 use crate::order::state::{Expired, Filled, InactiveOrderState};
5724 let key = spot_key("BTCUSDT", "a");
5725 let ended = Utc.timestamp_millis_opt(1_700_000_060_000).unwrap();
5726 let id = OrderId::new("7");
5727
5728 let mut filled = order_row("a", "FILLED");
5729 filled["executedQty"] = "2".into();
5730 filled["cummulativeQuoteQty"] = "210".into();
5731 assert_eq!(
5732 ended_state(&key, filled),
5733 Some(InactiveOrderState::FullyFilled(Filled::new(
5734 id.clone(),
5735 ended,
5736 Decimal::TWO,
5737 Some(Decimal::from(105)),
5738 )))
5739 );
5740 assert_eq!(
5741 ended_state(&key, order_row("a", "CANCELED")),
5742 Some(InactiveOrderState::Cancelled(Cancelled::new(
5743 id.clone(),
5744 ended,
5745 Some(Decimal::ONE)
5746 )))
5747 );
5748 for status in ["EXPIRED", "EXPIRED_IN_MATCH"] {
5749 assert_eq!(
5750 ended_state(&key, order_row("a", status)),
5751 Some(InactiveOrderState::Expired(Expired::new(
5752 id.clone(),
5753 ended,
5754 Some(Decimal::ONE)
5755 ))),
5756 "{status}"
5757 );
5758 }
5759 assert!(matches!(
5760 ended_state(&key, order_row("a", "REJECTED")),
5761 Some(InactiveOrderState::OpenFailed(OrderError::Rejected(
5762 ApiError::OrderRejected(_)
5763 )))
5764 ));
5765 }
5766
5767 #[test]
5768 fn a_live_or_unreadable_order_row_is_not_ended() {
5769 let key = spot_key("BTCUSDT", "a");
5770 for status in [
5771 "NEW",
5772 "PARTIALLY_FILLED",
5773 "PENDING_NEW",
5774 "PENDING_CANCEL",
5775 "SOMETHING_NEW",
5776 ] {
5777 assert_eq!(ended_state(&key, order_row("a", status)), None, "{status}");
5778 }
5779 let mut no_status = order_row("a", "FILLED");
5780 no_status["status"] = serde_json::Value::Null;
5781 assert_eq!(ended_state(&key, no_status), None);
5782 let mut no_side = order_row("a", "FILLED");
5783 no_side["side"] = serde_json::Value::Null;
5784 assert_eq!(ended_state(&key, no_side), None);
5785 }
5786
5787 #[test]
5788 fn an_unknown_order_or_symbol_is_recognised_by_its_code() {
5789 use binance_sdk::common::errors::ConnectorError;
5790 let bad = |code| {
5791 anyhow::Error::new(ConnectorError::BadRequestError {
5792 msg: "no".into(),
5793 code: Some(code),
5794 })
5795 };
5796 assert!(is_unknown_order(&bad(-2013)));
5797 assert!(is_unknown_order(&bad(-1121)));
5798 assert!(!is_unknown_order(&bad(-1100)));
5799 assert!(is_unknown_order(&anyhow::Error::new(
5800 ConnectorError::NotFoundError {
5801 msg: "no".into(),
5802 code: Some(-2013),
5803 }
5804 )));
5805 assert!(!is_unknown_order(&anyhow::anyhow!("-2013 in text only")));
5806 }
5807
5808 fn rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
5809 Arc::new(SpotRestApi::from_config(
5810 ConfigurationRestApi::builder()
5811 .api_key("key")
5812 .api_secret("secret")
5813 .base_path(server.uri())
5814 .build()
5815 .unwrap(),
5816 ))
5817 }
5818
5819 async fn mount_order(
5821 server: &wiremock::MockServer,
5822 cid: &str,
5823 response: wiremock::ResponseTemplate,
5824 ) {
5825 wiremock::Mock::given(wiremock::matchers::method("GET"))
5826 .and(wiremock::matchers::path("/api/v3/order"))
5827 .and(wiremock::matchers::query_param("origClientOrderId", cid))
5828 .respond_with(response)
5829 .mount(server)
5830 .await;
5831 }
5832
5833 fn venue_error(code: i64) -> wiremock::ResponseTemplate {
5834 wiremock::ResponseTemplate::new(400)
5835 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
5836 }
5837
5838 fn row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
5839 wiremock::ResponseTemplate::new(200).set_body_json(order_row(cid, status))
5840 }
5841
5842 async fn looked_up(server: &wiremock::MockServer) -> Vec<String> {
5844 server
5845 .received_requests()
5846 .await
5847 .unwrap()
5848 .iter()
5849 .filter(|request| request.url.path() == "/api/v3/order")
5850 .filter_map(|request| {
5851 request
5852 .url
5853 .query_pairs()
5854 .find(|(key, _)| key == "origClientOrderId")
5855 .map(|(_, value)| value.into_owned())
5856 })
5857 .collect()
5858 }
5859
5860 #[tokio::test]
5861 async fn fetch_ended_orders_reports_each_ended_order_under_its_key_and_omits_the_rest() {
5862 let server = wiremock::MockServer::start().await;
5863 mount_order(&server, "filled", row_response("filled", "FILLED")).await;
5864 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
5865 mount_order(&server, "live", row_response("live", "PARTIALLY_FILLED")).await;
5866 mount_order(&server, "unknown", venue_error(-2013)).await;
5867 mount_order(&server, "no-symbol", venue_error(-1121)).await;
5868 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5869 "key".into(),
5870 "secret".into(),
5871 ));
5872 client.rest = rest_at(&server);
5873 let keys = ["filled", "cancelled", "live", "unknown"].map(|cid| spot_key("BTCUSDT", cid));
5874
5875 let mut ended = client
5876 .fetch_ended_orders(&[keys.to_vec(), vec![spot_key("NOSUCH", "no-symbol")]].concat())
5877 .await
5878 .unwrap();
5879
5880 ended.sort_by(|a, b| a.key.cid.cmp(&b.key.cid));
5881 let reported: Vec<_> = ended.iter().map(|order| &order.key).collect();
5882 assert_eq!(
5883 reported,
5884 [&keys[1], &keys[0]],
5885 "the strategy asked survives"
5886 );
5887 assert!(matches!(
5888 ended[0].state,
5889 crate::order::state::InactiveOrderState::Cancelled(_)
5890 ));
5891 assert!(matches!(
5892 ended[1].state,
5893 crate::order::state::InactiveOrderState::FullyFilled(_)
5894 ));
5895 assert!(client.fetch_ended_orders(&[]).await.unwrap().is_empty());
5896 }
5897
5898 #[tokio::test]
5899 async fn fetch_ended_orders_fails_when_any_lookup_fails() {
5900 let server = wiremock::MockServer::start().await;
5901 mount_order(&server, "cancelled", row_response("cancelled", "CANCELED")).await;
5902 mount_order(&server, "refused", venue_error(-1100)).await;
5903 let mut client = <BinanceSpot as ExecutionClient>::new(BinanceSpotConfig::new(
5904 "key".into(),
5905 "secret".into(),
5906 ));
5907 client.rest = rest_at(&server);
5908
5909 let result = client
5910 .fetch_ended_orders(&[
5911 spot_key("BTCUSDT", "cancelled"),
5912 spot_key("BTCUSDT", "refused"),
5913 ])
5914 .await;
5915
5916 assert!(
5917 matches!(
5918 result,
5919 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
5920 ),
5921 "{result:?}"
5922 );
5923 }
5924
5925 async fn venue_after_a_disconnect(delay: Duration) -> wiremock::MockServer {
5928 let server = wiremock::MockServer::start().await;
5929 wiremock::Mock::given(wiremock::matchers::method("GET"))
5930 .and(wiremock::matchers::path("/api/v3/openOrders"))
5931 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
5932 .respond_with(
5933 wiremock::ResponseTemplate::new(200)
5934 .set_body_json(serde_json::json!([order_row("live", "NEW")])),
5935 )
5936 .mount(&server)
5937 .await;
5938 mount_order(
5939 &server,
5940 "gone",
5941 row_response("gone", "CANCELED").set_delay(delay),
5942 )
5943 .await;
5944 mount_order(&server, "unknown", venue_error(-2013)).await;
5945 server
5946 }
5947
5948 fn held_orders() -> SharedKnownLiveOrders {
5950 let known = KnownLiveOrders::shared(ExchangeId::BinanceSpot);
5951 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
5952 known.lock().live(
5953 &spot_key("BTCUSDT", cid),
5954 Decimal::TWO,
5955 OrderKind::Limit,
5956 &Open::new(
5957 VenueOrderId::Assigned(OrderId::new(n.to_string())),
5958 Utc::now(),
5959 Decimal::ZERO,
5960 ),
5961 );
5962 }
5963 known
5964 }
5965
5966 fn btcusdt_unchecked() -> UncheckedOrders {
5967 let mut unchecked = UncheckedOrders::default();
5968 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
5969 unchecked
5970 }
5971
5972 #[tokio::test]
5973 async fn a_reconnect_reports_how_an_unlisted_order_ended_and_forgets_an_unknown_one() {
5974 let server = venue_after_a_disconnect(Duration::ZERO).await;
5975 let known = held_orders();
5976 let mut unchecked = btcusdt_unchecked();
5977 let (tx, mut rx) = mpsc::unbounded_channel();
5978
5979 recover_spot_ended_orders(
5980 &rest_at(&server),
5981 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
5982 &known,
5983 &mut unchecked,
5984 &UnrecoveredFills::default(),
5985 &tx,
5986 )
5987 .await;
5988
5989 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5990 let [event] = sent.as_slice() else {
5991 panic!("one order ended: {sent:?}");
5992 };
5993 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
5994 panic!("an order snapshot: {event:?}");
5995 };
5996 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
5997 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
5998 assert!(matches!(
5999 snapshot.0.state,
6000 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
6001 ));
6002 let mut asked = looked_up(&server).await;
6003 asked.sort();
6004 assert_eq!(
6005 asked,
6006 ["gone", "unknown"],
6007 "a listed order is not looked up"
6008 );
6009 let known = known.lock();
6010 assert!(known.contains(&ClientOrderId::new("live")));
6011 assert!(!known.contains(&ClientOrderId::new("gone")));
6012 assert!(
6013 !known.contains(&ClientOrderId::new("unknown")),
6014 "asking again cannot help"
6015 );
6016 assert!(unchecked.is_empty());
6017 }
6018
6019 #[tokio::test]
6020 async fn an_order_the_stream_reports_ending_during_the_check_is_not_reported_again() {
6021 let server = venue_after_a_disconnect(Duration::from_millis(300)).await;
6022 let known = held_orders();
6023 let mut unchecked = btcusdt_unchecked();
6024 let (tx, mut rx) = mpsc::unbounded_channel();
6025 let (rest, tracker) = (
6026 rest_at(&server),
6027 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6028 );
6029 let unrecovered = UnrecoveredFills::default();
6030
6031 tokio::join!(
6032 recover_spot_ended_orders(&rest, &tracker, &known, &mut unchecked, &unrecovered, &tx,),
6033 async {
6034 tokio::time::sleep(Duration::from_millis(100)).await;
6035 known.lock().ended(&ClientOrderId::new("gone"));
6036 }
6037 );
6038
6039 assert!(rx.try_recv().is_err(), "the stream already reported it");
6040 assert!(unchecked.is_empty());
6041 }
6042
6043 #[tokio::test]
6044 async fn an_instrument_with_unrecovered_fills_is_not_checked_yet() {
6045 let server = venue_after_a_disconnect(Duration::ZERO).await;
6046 let known = held_orders();
6047 let mut unchecked = btcusdt_unchecked();
6048 let mut unrecovered = UnrecoveredFills::default();
6049 unrecovered.open(
6050 &[InstrumentNameExchange::new("BTCUSDT")],
6051 Utc::now() - chrono::Duration::minutes(1),
6052 Utc::now(),
6053 );
6054 let (tx, mut rx) = mpsc::unbounded_channel();
6055
6056 recover_spot_ended_orders(
6057 &rest_at(&server),
6058 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6059 &known,
6060 &mut unchecked,
6061 &unrecovered,
6062 &tx,
6063 )
6064 .await;
6065
6066 assert!(rx.try_recv().is_err());
6067 assert!(server.received_requests().await.unwrap().is_empty());
6068 assert!(!unchecked.is_empty(), "it waits for its fills");
6069 assert_eq!(unchecked.next_due(&unrecovered), None, "woken by the gap");
6070 }
6071
6072 #[tokio::test]
6073 async fn a_failed_check_sends_nothing_and_waits_for_its_retry() {
6074 let server = wiremock::MockServer::start().await;
6075 wiremock::Mock::given(wiremock::matchers::method("GET"))
6076 .and(wiremock::matchers::path("/api/v3/openOrders"))
6077 .respond_with(venue_error(-1100))
6078 .mount(&server)
6079 .await;
6080 let server_rest = rest_at(&server);
6081 let known = held_orders();
6082 let mut unchecked = btcusdt_unchecked();
6083 let (tx, mut rx) = mpsc::unbounded_channel();
6084
6085 recover_spot_ended_orders(
6086 &server_rest,
6087 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6088 &known,
6089 &mut unchecked,
6090 &UnrecoveredFills::default(),
6091 &tx,
6092 )
6093 .await;
6094
6095 assert!(rx.try_recv().is_err());
6096 assert!(!unchecked.is_empty());
6097 assert!(
6098 unchecked
6099 .next_due(&UnrecoveredFills::default())
6100 .is_some_and(|due| due > tokio::time::Instant::now()),
6101 "retried after a backoff"
6102 );
6103 assert!(
6104 known.lock().contains(&ClientOrderId::new("gone")),
6105 "still held"
6106 );
6107 }
6108
6109 #[tokio::test]
6110 async fn an_unlisted_order_the_lookup_finds_live_stays_held() {
6111 let server = wiremock::MockServer::start().await;
6112 wiremock::Mock::given(wiremock::matchers::method("GET"))
6113 .and(wiremock::matchers::path("/api/v3/openOrders"))
6114 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
6115 .mount(&server)
6116 .await;
6117 for (cid, status) in [
6118 ("live", "NEW"),
6119 ("gone", "PENDING_CANCEL"),
6120 ("unknown", "WHAT"),
6121 ] {
6122 mount_order(&server, cid, row_response(cid, status)).await;
6123 }
6124 let known = held_orders();
6125 let mut unchecked = btcusdt_unchecked();
6126 let (tx, mut rx) = mpsc::unbounded_channel();
6127
6128 recover_spot_ended_orders(
6129 &rest_at(&server),
6130 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6131 &known,
6132 &mut unchecked,
6133 &UnrecoveredFills::default(),
6134 &tx,
6135 )
6136 .await;
6137
6138 assert!(rx.try_recv().is_err(), "none has ended");
6139 let known = known.lock();
6140 for cid in ["live", "gone", "unknown"] {
6141 assert!(
6142 known.contains(&ClientOrderId::new(cid)),
6143 "{cid}: only an order Binance does not know is dropped"
6144 );
6145 }
6146 assert!(unchecked.is_empty());
6147 }
6148
6149 #[tokio::test]
6150 async fn one_instrument_failing_does_not_hold_up_another() {
6151 let server = venue_after_a_disconnect(Duration::ZERO).await;
6152 wiremock::Mock::given(wiremock::matchers::method("GET"))
6153 .and(wiremock::matchers::path("/api/v3/openOrders"))
6154 .and(wiremock::matchers::query_param("symbol", "ETHUSDT"))
6155 .respond_with(venue_error(-1100))
6156 .mount(&server)
6157 .await;
6158 let known = held_orders();
6159 let eth = spot_key("ETHUSDT", "eth");
6160 known.lock().live(
6161 ð,
6162 Decimal::TWO,
6163 OrderKind::Limit,
6164 &Open::new(
6165 VenueOrderId::Assigned(OrderId::new("9")),
6166 Utc::now(),
6167 Decimal::ZERO,
6168 ),
6169 );
6170 let mut unchecked = btcusdt_unchecked();
6171 unchecked.open([eth.instrument.clone()]);
6172 let (tx, mut rx) = mpsc::unbounded_channel();
6173
6174 recover_spot_ended_orders(
6175 &rest_at(&server),
6176 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6177 &known,
6178 &mut unchecked,
6179 &UnrecoveredFills::default(),
6180 &tx,
6181 )
6182 .await;
6183
6184 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
6185 assert_eq!(sent.len(), 1, "BTCUSDT's ended order: {sent:?}");
6186 assert!(!unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
6187 assert!(
6188 unchecked.contains(ð.instrument),
6189 "ETHUSDT waits for its retry"
6190 );
6191 assert!(known.lock().contains(ð.cid));
6192 }
6193
6194 #[tokio::test]
6195 async fn a_check_dropped_part_way_changes_nothing() {
6196 let server = venue_after_a_disconnect(Duration::from_millis(500)).await;
6197 let known = held_orders();
6198 let mut unchecked = btcusdt_unchecked();
6199 let (tx, mut rx) = mpsc::unbounded_channel();
6200
6201 let dropped = tokio::time::timeout(
6202 Duration::from_millis(100),
6203 recover_spot_ended_orders(
6204 &rest_at(&server),
6205 &Arc::new(RateLimitTracker::new(WeightPool::Spot)),
6206 &known,
6207 &mut unchecked,
6208 &UnrecoveredFills::default(),
6209 &tx,
6210 ),
6211 )
6212 .await;
6213
6214 assert!(dropped.is_err(), "the lookup of `gone` was still waiting");
6215 assert!(rx.try_recv().is_err());
6216 assert!(known.lock().contains(&ClientOrderId::new("gone")));
6217 assert!(unchecked.contains(&InstrumentNameExchange::new("BTCUSDT")));
6218 assert!(
6219 unchecked
6220 .next_due(&UnrecoveredFills::default())
6221 .is_some_and(|due| due <= tokio::time::Instant::now()),
6222 "still due, not charged a failure"
6223 );
6224 }
6225}