1use super::shared::{
31 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
32 CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
33 RateLimitTracker, SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, classify_order_kind_tif,
34 connectivity_error, dedup_key_from_event, is_api_rejection_error, is_duplicate,
35 is_rate_limit_error, new_dedup_cache, parse_binance_api_error, parse_order_kind, parse_side,
36 parse_time_in_force, rest_call_with_retry,
37};
38use crate::{
39 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
40 UnindexedAccountSnapshot,
41 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
42 client::ExecutionClient,
43 error::{ApiError, ConnectivityError, OrderError, UnindexedClientError, UnindexedOrderError},
44 order::{
45 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
46 id::{ClientOrderId, OrderId, StrategyId},
47 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
48 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
49 },
50 trade::{AssetFees, Trade, TradeId},
51};
52use binance_sdk::{
53 common::{
54 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
55 models::WebsocketEvent,
56 },
57 spot::{
58 SpotRestApi, SpotWsApi,
59 rest_api::{GetAccountParams, GetOpenOrdersParams, MyTradesParams, RestApi},
60 websocket_api::{
61 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
62 OrderPlaceTypeEnum, UserDataStreamEventsResponse,
63 UserDataStreamSubscribeSignatureParams, WebsocketApi, WebsocketApiHandle,
64 },
65 },
66};
67use chrono::{DateTime, TimeZone, Utc};
68use futures::stream::BoxStream;
69use rust_decimal::Decimal;
70use rustrade_instrument::{
71 Side, asset::name::AssetNameExchange, exchange::ExchangeId,
72 instrument::name::InstrumentNameExchange,
73};
74use serde::Deserialize;
75use smol_str::format_smolstr;
76use std::{
77 str::FromStr,
78 sync::{
79 Arc,
80 atomic::{AtomicBool, Ordering},
81 },
82 time::Duration,
83};
84use tokio::sync::{RwLock, mpsc, oneshot};
85use tracing::{debug, error, info, trace, warn};
86
87#[derive(Clone, Deserialize)]
94pub struct BinanceSpotConfig {
95 api_key: String,
98 secret_key: String,
99 pub testnet: bool,
101}
102
103impl std::fmt::Debug for BinanceSpotConfig {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.debug_struct("BinanceSpotConfig")
107 .field("api_key", &"***")
108 .field("secret_key", &"***")
109 .field("testnet", &self.testnet)
110 .finish()
111 }
112}
113
114impl BinanceSpotConfig {
115 pub fn new(api_key: String, secret_key: String, testnet: bool) -> Self {
116 Self {
117 api_key,
118 secret_key,
119 testnet,
120 }
121 }
122
123 pub fn api_key(&self) -> &str {
125 &self.api_key
126 }
127}
128
129#[derive(Clone)]
138pub struct BinanceSpot {
139 config: Arc<BinanceSpotConfig>,
140 rest: Arc<RestApi>,
141 ws_handle: WebsocketApiHandle,
143 ws_api: Arc<RwLock<Option<WebsocketApi>>>,
149 rate_limiter: Arc<RateLimitTracker>,
151}
152
153impl std::fmt::Debug for BinanceSpot {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 f.debug_struct("BinanceSpot")
156 .field("testnet", &self.config.testnet)
157 .finish_non_exhaustive()
158 }
159}
160
161impl BinanceSpot {
162 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
166 let rest_config = ConfigurationRestApi::builder()
167 .api_key(config.api_key.clone())
168 .api_secret(config.secret_key.clone())
169 .build()
170 .expect("failed to build Binance REST configuration");
171
172 Arc::new(if config.testnet {
173 SpotRestApi::testnet(rest_config)
174 } else {
175 SpotRestApi::production(rest_config)
176 })
177 }
178
179 #[allow(clippy::expect_used)] fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
183 let ws_config = ConfigurationWebsocketApi::builder()
184 .api_key(config.api_key.clone())
185 .api_secret(config.secret_key.clone())
186 .build()
187 .expect("failed to build Binance WebSocket configuration");
188
189 if config.testnet {
190 SpotWsApi::testnet(ws_config)
191 } else {
192 SpotWsApi::production(ws_config)
193 }
194 }
195
196 async fn get_ws_api(&self) -> anyhow::Result<WebsocketApi> {
200 {
202 let guard = self.ws_api.read().await;
203 if let Some(ref ws) = *guard {
204 return Ok(ws.clone());
205 }
206 }
207 let mut guard = self.ws_api.write().await;
214 if let Some(ref ws) = *guard {
216 return Ok(ws.clone());
217 }
218 let ws = tokio::time::timeout(
219 Duration::from_secs(CONNECT_TIMEOUT_SECS),
220 self.ws_handle.connect(),
221 )
222 .await
223 .map_err(|_| {
224 anyhow::anyhow!("BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
225 })??;
226 *guard = Some(ws.clone());
227 Ok(ws)
228 }
229
230 async fn clear_ws_api(&self) {
233 let ws = {
244 let mut guard = self.ws_api.write().await;
245 guard.take()
246 };
247 if let Some(ws) = ws {
248 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
249 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
250 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
251 Ok(Ok(())) => {}
252 }
253 }
254 }
255}
256
257async fn fetch_open_orders_for_instrument(
264 rest: Arc<RestApi>,
265 rate_limiter: Arc<RateLimitTracker>,
266 instrument: InstrumentNameExchange,
267) -> Result<
268 (
269 InstrumentNameExchange,
270 Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
271 ),
272 UnindexedClientError,
273> {
274 let symbol_str = instrument.name().to_string();
276 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
277 let sym = symbol_str.clone();
278 Box::pin(async move {
279 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
280 rest.get_open_orders(params).await
281 })
282 })
283 .await
284 .map_err(connectivity_error)?;
285
286 let orders_data = response
287 .data()
288 .await
289 .map_err(|e| connectivity_error(e.into()))?;
290
291 let orders = orders_data
292 .into_iter()
293 .filter_map(|o| convert_open_order(&o, &instrument))
294 .collect();
295
296 Ok((instrument, orders))
297}
298
299async fn fetch_all_open_orders(
304 rest: Arc<RestApi>,
305 rate_limiter: Arc<RateLimitTracker>,
306) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
307 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
308 Box::pin(async move {
309 let params = GetOpenOrdersParams::builder().build()?;
310 rest.get_open_orders(params).await
311 })
312 })
313 .await
314 .map_err(connectivity_error)?;
315
316 let orders_data = response
317 .data()
318 .await
319 .map_err(|e| connectivity_error(e.into()))?;
320
321 let orders = orders_data
322 .into_iter()
323 .filter_map(|o| convert_open_order_owned_symbol(&o))
324 .collect();
325
326 Ok(orders)
327}
328
329async fn paginate_my_trades(
337 rest: &Arc<RestApi>,
338 rate_limiter: &Arc<RateLimitTracker>,
339 instrument: &InstrumentNameExchange,
340 start_time_ms: i64,
341) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
342 let symbol_str = instrument.name().to_string();
344 let mut all_pages = Vec::new();
349 let mut cursor: Option<i64> = None;
350 loop {
351 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, |rest| {
353 let sym = symbol_str.clone();
354 let stm = start_time_ms;
355 Box::pin(async move {
356 #[allow(clippy::cast_possible_truncation)]
358 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
359 let params = if let Some(id) = fid {
360 builder.from_id(id).build()?
361 } else {
362 builder.start_time(stm).build()?
363 };
364 rest.my_trades(params).await
365 })
366 })
367 .await
368 .map_err(connectivity_error)?;
369
370 let page = response
371 .data()
372 .await
373 .map_err(|e| connectivity_error(e.into()))?;
374
375 let page_len = page.len();
376 let last_id = page.last().and_then(|t| t.id);
377 all_pages.extend(page);
378
379 if page_len < BINANCE_MAX_TRADES {
380 break;
381 }
382 match last_id {
383 Some(id) => {
384 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
385 match id.checked_add(1) {
386 Some(next) => cursor = Some(next),
387 None => break, }
389 }
390 None => {
391 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
392 break;
393 }
394 }
395 }
396 Ok(all_pages)
397}
398
399impl ExecutionClient for BinanceSpot {
404 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
405 type Config = BinanceSpotConfig;
406 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
407
408 fn new(config: Self::Config) -> Self {
413 let rest = Self::build_rest(&config);
414 let ws_handle = Self::build_ws_handle(&config);
415 Self {
416 config: Arc::new(config),
417 rest,
418 ws_handle,
419 ws_api: Arc::new(RwLock::new(None)),
420 rate_limiter: Arc::new(RateLimitTracker::new()),
421 }
422 }
423
424 async fn account_snapshot(
425 &self,
426 assets: &[AssetNameExchange],
427 instruments: &[InstrumentNameExchange],
428 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
429 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
431 Box::pin(async move {
432 let params = GetAccountParams::builder().build()?;
433 rest.get_account(params).await
434 })
435 })
436 .await
437 .map_err(connectivity_error)?;
438
439 let account = response
440 .data()
441 .await
442 .map_err(|e| connectivity_error(e.into()))?;
443
444 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
446
447 use futures::{StreamExt as _, TryStreamExt};
453 let instrument_snapshots: Vec<_> =
454 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
455 fetch_open_orders_for_instrument(
456 self.rest.clone(),
457 self.rate_limiter.clone(),
458 instrument,
459 )
460 }))
461 .buffer_unordered(8)
462 .map(|result| {
463 let (inst, orders) = result?;
464 let wrapped = orders
465 .into_iter()
466 .map(|o| Order {
467 key: o.key,
468 side: o.side,
469 price: o.price,
470 quantity: o.quantity,
471 kind: o.kind,
472 time_in_force: o.time_in_force,
473 state: OrderState::active(o.state),
474 })
475 .collect();
476 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
477 inst, wrapped, None, None,
478 ))
479 })
480 .try_collect()
481 .await?;
482
483 Ok(AccountSnapshot::new(
484 ExchangeId::BinanceSpot,
485 balances,
486 instrument_snapshots,
487 ))
488 }
489
490 async fn account_stream(
506 &self,
507 _assets: &[AssetNameExchange],
511 instruments: &[InstrumentNameExchange],
512 ) -> Result<Self::AccountStream, UnindexedClientError> {
513 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
528 let dedup = new_dedup_cache();
529 let ws_handle = self.ws_handle.clone();
530 let rest = self.rest.clone();
531 let rate_limiter = self.rate_limiter.clone();
532 let instruments = instruments.to_vec();
533 debug_assert!(
537 instruments.iter().all(|i| i.name().len() <= 23),
538 "instrument name exceeds SmolStr inline capacity: {:?}",
539 instruments.iter().find(|i| i.name().len() > 23)
540 );
541
542 let initial_ws = ws_handle.connect().await.map_err(|e| {
546 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
547 })?;
548
549 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
551 .build()
552 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
553
554 match initial_ws
555 .user_data_stream_subscribe_signature(params)
556 .await
557 {
558 Ok(_) => {}
559 Err(e) => {
560 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
565 Ok(Err(de)) => {
566 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
567 }
568 Err(_) => {
569 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
570 }
571 Ok(Ok(())) => {}
572 }
573 return Err(UnindexedClientError::Internal(e.to_string()));
574 }
575 }
576
577 let cm_handle = tokio::spawn(connection_manager(
589 tx,
590 dedup,
591 ws_handle,
592 rest,
593 rate_limiter,
594 instruments,
595 Some(initial_ws),
596 ));
597
598 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
602 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
603 Ok(futures::StreamExt::boxed(guarded_stream))
604 }
605
606 async fn cancel_order(
607 &self,
608 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
609 ) -> Option<UnindexedOrderResponseCancel> {
610 let instrument = request.key.instrument.clone();
611 let key = OrderKey {
612 exchange: request.key.exchange,
613 instrument: instrument.clone(),
614 strategy: request.key.strategy.clone(),
615 cid: request.key.cid.clone(),
616 };
617
618 let ws = match self.get_ws_api().await {
619 Ok(ws) => ws,
620 Err(e) => {
621 return Some(UnindexedOrderResponseCancel {
622 key,
623 state: Err(UnindexedOrderError::Connectivity(
624 ConnectivityError::Socket(format!("{e:#}")),
625 )),
626 });
627 }
628 };
629
630 let mut params_builder =
633 OrderCancelParams::builder(request.key.instrument.name().to_string());
634
635 if let Some(ref order_id) = request.state.id {
637 if let Ok(id) = order_id.0.parse::<i64>() {
638 params_builder = params_builder.order_id(id);
639 } else {
640 error!(
645 order_id = %order_id.0,
646 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
647 );
648 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
649 }
650 } else {
651 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
652 }
653
654 let params = match params_builder.build() {
655 Ok(p) => p,
656 Err(e) => {
657 error!(%e, "BinanceSpot failed to build cancel order params");
658 return Some(UnindexedOrderResponseCancel {
659 key,
660 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
661 e.to_string(),
662 ))),
663 });
664 }
665 };
666
667 match ws.order_cancel(params).await {
668 Ok(response) => match response.data() {
669 Ok(data) => {
670 let time_exchange = data
671 .transact_time
672 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
673 .unwrap_or_else(Utc::now);
674
675 let exchange_order_id = match data.order_id {
676 Some(id) => OrderId(format_smolstr!("{id}")),
677 None => {
678 error!("BinanceSpot cancel response missing orderId");
679 return Some(UnindexedOrderResponseCancel {
680 key,
681 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
682 "cancel response missing orderId".into(),
683 ))),
684 });
685 }
686 };
687
688 let filled_qty = data
689 .executed_qty
690 .as_deref()
691 .and_then(|q| Decimal::from_str(q).ok())
692 .unwrap_or(Decimal::ZERO);
693
694 Some(UnindexedOrderResponseCancel {
695 key,
696 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
697 })
698 }
699 Err(e) => {
700 Some(UnindexedOrderResponseCancel {
702 key,
703 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
704 e.to_string(),
705 ))),
706 })
707 }
708 },
709 Err(e) => {
710 if is_api_rejection_error(&e) {
717 let api_err = parse_binance_api_error(e.to_string(), &instrument);
719 Some(UnindexedOrderResponseCancel {
724 key,
725 state: Err(UnindexedOrderError::from(api_err)),
726 })
727 } else if is_rate_limit_error(&e) {
728 self.rate_limiter.on_rate_limited(None);
730 Some(UnindexedOrderResponseCancel {
731 key,
732 state: Err(UnindexedOrderError::from(ApiError::RateLimit)),
733 })
734 } else {
735 self.clear_ws_api().await;
738 Some(UnindexedOrderResponseCancel {
739 key,
740 state: Err(UnindexedOrderError::Connectivity(
741 ConnectivityError::Socket(format!("{e:#}")),
742 )),
743 })
744 }
745 }
746 }
747 }
748
749 async fn open_order(
750 &self,
751 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
752 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
753 let instrument = request.key.instrument.clone();
754 let side = request.state.side;
755 let price = request.state.price;
756 let quantity = request.state.quantity;
757 let kind = request.state.kind;
758 let time_in_force = request.state.time_in_force;
759 let cid = request.key.cid.clone();
760
761 let order_key = OrderKey::new(
762 ExchangeId::BinanceSpot,
763 instrument.clone(),
764 request.key.strategy.clone(),
765 cid.clone(),
766 );
767
768 let ws = match self.get_ws_api().await {
769 Ok(ws) => ws,
770 Err(e) => {
771 return Some(Order {
772 key: order_key,
773 side,
774 price,
775 quantity,
776 kind,
777 time_in_force,
778 state: OrderState::inactive(OrderError::Connectivity(
779 ConnectivityError::Socket(format!("{e:#}")),
780 )),
781 });
782 }
783 };
784
785 let binance_side = match side {
786 Side::Buy => OrderPlaceSideEnum::Buy,
787 Side::Sell => OrderPlaceSideEnum::Sell,
788 };
789
790 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
791 Some(converted) => converted,
792 None => {
793 return Some(Order {
794 key: order_key,
795 side,
796 price,
797 quantity,
798 kind,
799 time_in_force,
800 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
801 "Binance Spot does not yet support OrderKind::{kind:?}"
802 ))),
803 });
804 }
805 };
806
807 let mut params_builder =
814 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
815 .quantity(quantity)
816 .new_client_order_id(cid.0.to_string());
817
818 match kind {
821 OrderKind::Limit => {
822 params_builder = params_builder.price(price);
823 }
824 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
825 params_builder = params_builder.stop_price(trigger_price);
826 }
827 OrderKind::StopLimit { trigger_price }
828 | OrderKind::TakeProfitLimit { trigger_price } => {
829 params_builder = params_builder.price(price).stop_price(trigger_price);
830 }
831 OrderKind::TrailingStop {
832 offset,
833 offset_type,
834 } => {
835 let basis_points: i32 = match offset_type {
837 TrailingOffsetType::BasisPoints => {
838 let Ok(bp) = i32::try_from(offset) else {
839 return Some(Order {
840 key: order_key,
841 side,
842 price,
843 quantity,
844 kind,
845 time_in_force,
846 state: OrderState::inactive(OrderError::UnsupportedOrderType(
847 format!(
848 "TrailingStop basis-point offset {offset} overflows i32 \
849 (Binance trailingDelta filter typically caps at 2000)"
850 ),
851 )),
852 });
853 };
854 bp
855 }
856 TrailingOffsetType::Percentage => {
857 let Ok(bp) = i32::try_from(offset * Decimal::from(100)) else {
858 return Some(Order {
859 key: order_key,
860 side,
861 price,
862 quantity,
863 kind,
864 time_in_force,
865 state: OrderState::inactive(OrderError::UnsupportedOrderType(
866 format!(
867 "TrailingStop percentage offset {offset} overflows i32 \
868 after scaling to basis points"
869 ),
870 )),
871 });
872 };
873 bp
874 }
875 TrailingOffsetType::Absolute => {
876 return Some(Order {
880 key: order_key,
881 side,
882 price,
883 quantity,
884 kind,
885 time_in_force,
886 state: OrderState::inactive(OrderError::UnsupportedOrderType(
887 "TrailingStop with Absolute offset is not supported by Binance; \
888 convert to basis points: (absolute / price) * 10000"
889 .into(),
890 )),
891 });
892 }
893 };
894 params_builder = params_builder.trailing_delta(basis_points);
895 }
896 _ => {}
900 }
901
902 if let Some(tif) = binance_tif {
903 params_builder = params_builder.time_in_force(tif);
904 }
905
906 let params = match params_builder.build() {
907 Ok(p) => p,
908 Err(e) => {
909 error!(%e, "BinanceSpot failed to build new order params");
910 return Some(Order {
911 key: order_key,
912 side,
913 price,
914 quantity,
915 kind,
916 time_in_force,
917 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
918 e.to_string(),
919 ))),
920 });
921 }
922 };
923
924 match ws.order_place(params).await {
925 Ok(response) => match response.data() {
926 Ok(data) => {
927 let time_exchange = data
928 .transact_time
929 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
930 .unwrap_or_else(Utc::now);
931
932 let exchange_order_id = match data.order_id {
933 Some(id) => OrderId(format_smolstr!("{id}")),
934 None => {
935 error!("BinanceSpot open_order response missing orderId");
936 return Some(Order {
937 key: order_key,
938 side,
939 price,
940 quantity,
941 kind,
942 time_in_force,
943 state: OrderState::inactive(OrderError::Rejected(
944 ApiError::OrderRejected(
945 "open_order response missing orderId".into(),
946 ),
947 )),
948 });
949 }
950 };
951
952 let filled_qty = data
953 .executed_qty
954 .as_deref()
955 .and_then(|q| Decimal::from_str(q).ok())
956 .unwrap_or(Decimal::ZERO);
957
958 let state = if filled_qty >= quantity {
959 OrderState::fully_filled(Filled::new(
961 exchange_order_id,
962 time_exchange,
963 filled_qty,
964 None, ))
966 } else {
967 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
969 };
970
971 Some(Order {
972 key: order_key,
973 side,
974 price,
975 quantity,
976 kind,
977 time_in_force,
978 state,
979 })
980 }
981 Err(e) => {
982 Some(Order {
984 key: order_key,
985 side,
986 price,
987 quantity,
988 kind,
989 time_in_force,
990 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
991 e.to_string(),
992 ))),
993 })
994 }
995 },
996 Err(e) => {
997 if is_api_rejection_error(&e) {
1004 let api_err = parse_binance_api_error(e.to_string(), &instrument);
1006 Some(Order {
1011 key: order_key,
1012 side,
1013 price,
1014 quantity,
1015 kind,
1016 time_in_force,
1017 state: OrderState::inactive(OrderError::from(api_err)),
1018 })
1019 } else if is_rate_limit_error(&e) {
1020 self.rate_limiter.on_rate_limited(None);
1022 Some(Order {
1023 key: order_key,
1024 side,
1025 price,
1026 quantity,
1027 kind,
1028 time_in_force,
1029 state: OrderState::inactive(OrderError::from(ApiError::RateLimit)),
1030 })
1031 } else {
1032 self.clear_ws_api().await;
1035 Some(Order {
1036 key: order_key,
1037 side,
1038 price,
1039 quantity,
1040 kind,
1041 time_in_force,
1042 state: OrderState::inactive(OrderError::Connectivity(
1043 ConnectivityError::Socket(format!("{e:#}")),
1044 )),
1045 })
1046 }
1047 }
1048 }
1049 }
1050
1051 async fn fetch_balances(
1052 &self,
1053 assets: &[AssetNameExchange],
1054 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1055 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
1056 Box::pin(async move {
1057 let params = GetAccountParams::builder().build()?;
1058 rest.get_account(params).await
1059 })
1060 })
1061 .await
1062 .map_err(connectivity_error)?;
1063
1064 let account = response
1065 .data()
1066 .await
1067 .map_err(|e| connectivity_error(e.into()))?;
1068
1069 Ok(filter_and_convert_balances(
1070 account.balances.unwrap_or_default(),
1071 assets,
1072 ))
1073 }
1074
1075 async fn fetch_open_orders(
1080 &self,
1081 instruments: &[InstrumentNameExchange],
1082 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1083 if instruments.is_empty() {
1086 return fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await;
1087 }
1088 use futures::{StreamExt as _, TryStreamExt as _};
1093 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1094 fetch_open_orders_for_instrument(
1095 self.rest.clone(),
1096 self.rate_limiter.clone(),
1097 instrument,
1098 )
1099 }))
1100 .buffer_unordered(8)
1101 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, orders)| async move {
1102 acc.extend(orders);
1103 Ok(acc)
1104 })
1105 .await
1106 }
1107
1108 #[allow(clippy::redundant_iter_cloned)]
1116 async fn fetch_trades(
1117 &self,
1118 time_since: DateTime<Utc>,
1119 instruments: &[InstrumentNameExchange],
1120 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1121 use futures::StreamExt;
1122
1123 if instruments.is_empty() {
1124 debug!(
1125 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1126 );
1127 return Ok(Vec::new());
1128 }
1129 let start_time_ms = time_since.timestamp_millis();
1130 let mut all_trades = Vec::new();
1133
1134 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1138 let rest = self.rest.clone();
1139 let rate_limiter = self.rate_limiter.clone();
1140 async move {
1141 let pages = paginate_my_trades(&rest, &rate_limiter, &inst, start_time_ms).await?;
1142 Ok::<_, UnindexedClientError>((inst, pages))
1143 }
1144 }))
1145 .buffer_unordered(8);
1146 while let Some(result) = stream.next().await {
1147 let (instrument, trades_data) = result?;
1148 for t in trades_data {
1149 if let Some(trade) = convert_my_trade(&t, &instrument) {
1150 all_trades.push(trade);
1151 }
1152 }
1153 }
1154
1155 Ok(all_trades)
1156 }
1157}
1158
1159async fn connect_and_subscribe(ws_handle: &WebsocketApiHandle) -> anyhow::Result<WebsocketApi> {
1166 let ws = ws_handle.connect().await?;
1167 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1169 .build()
1170 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1171 match ws.user_data_stream_subscribe_signature(params).await {
1172 Ok(_) => Ok(ws),
1173 Err(e) => {
1174 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1175 let ws_cleanup = ws;
1176 tokio::spawn(async move {
1186 match tokio::time::timeout(Duration::from_secs(5), ws_cleanup.disconnect()).await {
1187 Ok(Err(dc_err)) => warn!(%dc_err, "BinanceSpot WS cleanup disconnect failed"),
1188 Err(_) => warn!("BinanceSpot WS cleanup disconnect timed out (5s)"),
1189 Ok(Ok(())) => {}
1190 }
1191 });
1192 Err(e)
1193 }
1194 }
1195}
1196
1197#[allow(clippy::cognitive_complexity)]
1219async fn connection_manager(
1220 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1221 dedup: SharedDedupCache,
1222 ws_handle: WebsocketApiHandle,
1223 rest: Arc<RestApi>,
1224 rate_limiter: Arc<RateLimitTracker>,
1225 instruments: Vec<InstrumentNameExchange>,
1226 initial_ws: Option<WebsocketApi>,
1227) {
1228 let mut backoff = ExponentialBackoff::new();
1229 let mut disconnect_time: Option<DateTime<Utc>> = None;
1230 let mut current_ws = initial_ws;
1231
1232 loop {
1233 let ws = match current_ws.take() {
1235 Some(ws) => ws,
1236 None => match connect_and_subscribe(&ws_handle).await {
1237 Ok(ws) => ws,
1243 Err(e) => {
1244 error!(%e, "BinanceSpot WS connect/subscribe failed");
1245 if !backoff.wait().await {
1246 error!("BinanceSpot max reconnect attempts exhausted");
1247 break;
1248 }
1249 continue;
1250 }
1251 },
1252 };
1253 info!("BinanceSpot account_stream connected and subscribed");
1254
1255 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1261 let mut signal_tx_opt = Some(signal_tx);
1262 let heartbeat_flag = Arc::new(AtomicBool::new(true));
1266 let hb_callback = heartbeat_flag.clone();
1267 let dedup_callback = dedup.clone();
1268 let mut event_tx = Some(tx.clone());
1272 let mut event_buf = Vec::with_capacity(32);
1276
1277 let subscription = ws.subscribe_on_ws_events(move |event| {
1284 let Some(ref sender) = event_tx else { return };
1285 match event {
1286 WebsocketEvent::Message(json_str) => {
1287 hb_callback.store(true, Ordering::Release);
1290 match serde_json::from_str::<UserDataStreamEventsResponse>(&json_str) {
1291 Ok(user_event) => {
1292 let stream_terminated = convert_user_data_events(user_event, &mut event_buf);
1293 for ev in event_buf.drain(..) {
1294 if let Some(key) = dedup_key_from_event(&ev)
1296 && is_duplicate(&dedup_callback, key)
1297 {
1298 trace!("BinanceSpot dedup: skipping duplicate event");
1299 continue;
1300 }
1301 if sender.send(ev).is_err() {
1302 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1303 event_tx.take();
1304 if let Some(s) = signal_tx_opt.take() {
1305 let _ = s.send(());
1306 }
1307 return;
1308 }
1309 }
1310 if stream_terminated {
1313 event_tx.take();
1314 if let Some(s) = signal_tx_opt.take() {
1315 let _ = s.send(());
1316 }
1317 }
1318 }
1319 Err(e) => {
1320 trace!(
1323 error = %e,
1324 raw = %json_str.get(..200).unwrap_or(json_str.as_str()),
1325 "BinanceSpot WS: skipped non-UserDataStream message"
1326 );
1327 }
1328 }
1329 }
1330 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1331 hb_callback.store(true, Ordering::Release);
1334 }
1335 WebsocketEvent::Error(e) => {
1336 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1340 event_tx.take();
1341 if let Some(s) = signal_tx_opt.take() {
1342 let _ = s.send(());
1343 }
1344 }
1345 WebsocketEvent::Close(code, reason) => {
1346 warn!(code, %reason, "BinanceSpot WebSocket closed");
1347 event_tx.take();
1348 if let Some(s) = signal_tx_opt.take() {
1349 let _ = s.send(());
1350 }
1351 }
1352 _ => {}
1353 }
1354 });
1355
1356 if let Some(dt) = disconnect_time.take() {
1365 match tokio::time::timeout(
1366 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
1367 recover_fills(&rest, &rate_limiter, &instruments, dt, &tx, &dedup),
1368 )
1369 .await
1370 {
1371 Ok(()) => {}
1372 Err(_) => {
1373 warn!(
1378 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
1379 "BinanceSpot fill recovery timed out — remaining instruments not queried, some fills may be missing"
1380 );
1381 }
1382 }
1383 }
1384
1385 enum DisconnectReason {
1387 Signal,
1388 HeartbeatTimeout,
1389 ConsumerDropped,
1390 }
1391 let reason = {
1392 let mut signal_rx = signal_rx;
1393 loop {
1394 tokio::select! {
1395 _ = tx.closed() => {
1396 debug!("BinanceSpot account_stream consumer dropped, terminating");
1397 break DisconnectReason::ConsumerDropped;
1398 }
1399 _ = &mut signal_rx => {
1400 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1401 break DisconnectReason::Signal;
1402 }
1403 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1404 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1411 backoff.reset();
1415 continue;
1416 }
1417 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1418 break DisconnectReason::HeartbeatTimeout;
1419 }
1420 }
1421 }
1422 };
1423 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1424
1425 if should_reconnect {
1432 disconnect_time = Some(match reason {
1433 DisconnectReason::HeartbeatTimeout => {
1434 Utc::now()
1435 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1436 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1437 }
1438 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1439 });
1440 }
1441
1442 subscription.unsubscribe();
1450 if let Err(e) = ws.disconnect().await {
1453 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1454 }
1455
1456 if !should_reconnect || tx.is_closed() {
1457 debug!("BinanceSpot connection manager exiting");
1458 break;
1459 }
1460 if !backoff.wait().await {
1461 error!("BinanceSpot max reconnect attempts exhausted, stream terminating");
1462 break;
1463 }
1464 }
1465}
1466
1467#[allow(clippy::redundant_iter_cloned)]
1476async fn recover_fills(
1477 rest: &Arc<RestApi>,
1478 rate_limiter: &Arc<RateLimitTracker>,
1479 instruments: &[InstrumentNameExchange],
1480 disconnect_time: DateTime<Utc>,
1481 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1482 dedup: &SharedDedupCache,
1483) {
1484 use futures::StreamExt;
1485
1486 if instruments.is_empty() {
1487 debug!(
1488 "BinanceSpot recover_fills called with empty instruments slice — no fills will be recovered"
1489 );
1490 return;
1491 }
1492 info!(
1493 since = %disconnect_time,
1494 instruments = instruments.len(),
1495 "BinanceSpot recovering fills after reconnect"
1496 );
1497
1498 let start_time_ms = disconnect_time.timestamp_millis();
1499 let mut recovered = 0u32;
1500 let mut duplicates = 0u32;
1501 let mut failed_instruments = 0u32;
1502
1503 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1510 let rest = rest.clone();
1511 let rl = rate_limiter.clone();
1512 async move {
1513 let raw = match paginate_my_trades(&rest, &rl, &inst, start_time_ms).await {
1514 Ok(pages) => pages,
1515 Err(e) => {
1516 warn!(%e, %inst, "BinanceSpot fill recovery: REST request failed");
1517 return None;
1518 }
1519 };
1520 let trades: Vec<_> = raw
1521 .into_iter()
1522 .filter_map(|t| convert_my_trade(&t, &inst))
1523 .collect();
1524 Some(trades)
1525 }
1526 }))
1527 .buffer_unordered(8);
1528 while let Some(result) = stream.next().await {
1529 let trades = match result {
1530 Some(t) => t,
1531 None => {
1532 failed_instruments += 1;
1533 continue;
1534 }
1535 };
1536 for trade in trades {
1537 let event =
1540 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
1541 if let Some(key) = dedup_key_from_event(&event)
1544 && is_duplicate(dedup, key)
1545 {
1546 duplicates += 1;
1547 continue;
1548 }
1549 if tx.send(event).is_err() {
1550 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
1554 return;
1555 }
1556 recovered += 1;
1557 }
1558 }
1559
1560 if failed_instruments > 0 {
1561 error!(
1562 recovered,
1563 duplicates,
1564 failed_instruments,
1565 "BinanceSpot fill recovery complete with failures — some fills may be permanently missed"
1566 );
1567 } else {
1568 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
1569 }
1570}
1571
1572fn convert_balance_entry(
1586 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
1587 now: chrono::DateTime<Utc>,
1588) -> Option<AssetBalance<AssetNameExchange>> {
1589 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
1590 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1591 Some(v) => v,
1592 None => {
1593 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
1594 return None;
1595 }
1596 };
1597 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1598 Some(v) => v,
1599 None => {
1600 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
1601 return None;
1602 }
1603 };
1604 Some(AssetBalance::new(
1605 asset_name,
1606 Balance::new(free + locked, free),
1607 now,
1608 ))
1609}
1610
1611fn filter_and_convert_balances(
1612 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
1613 assets: &[AssetNameExchange],
1614) -> Vec<AssetBalance<AssetNameExchange>> {
1615 let now = Utc::now();
1616 if assets.is_empty() {
1618 return balances
1619 .into_iter()
1620 .filter_map(|b| convert_balance_entry(b, now))
1621 .collect();
1622 }
1623 if assets.len() <= 16 {
1626 return balances
1627 .into_iter()
1628 .filter_map(|b| {
1629 let asset_name_str = b.asset.as_deref()?;
1630 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
1631 return None;
1632 }
1633 convert_balance_entry(b, now)
1634 })
1635 .collect();
1636 }
1637 use std::collections::HashSet;
1638 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
1639 balances
1640 .into_iter()
1641 .filter_map(|b| {
1642 let asset_name_str = b.asset.as_deref()?;
1643 if !asset_set.contains(asset_name_str) {
1644 return None;
1645 }
1646 convert_balance_entry(b, now)
1647 })
1648 .collect()
1649}
1650
1651fn convert_open_order(
1656 o: &binance_sdk::spot::rest_api::AllOrdersResponseInner,
1657 instrument: &InstrumentNameExchange,
1658) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
1659 let order_id_raw = match o.order_id {
1660 Some(id) => id,
1661 None => {
1662 warn!(%instrument, "BinanceSpot open order missing orderId");
1663 return None;
1664 }
1665 };
1666 let order_id = OrderId(format_smolstr!("{}", order_id_raw));
1667 if o.client_order_id.is_none() {
1668 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing clientOrderId, using orderId as fallback — order may not reconcile with engine state");
1669 }
1670 let cid = ClientOrderId::new(
1671 o.client_order_id
1672 .as_deref()
1673 .unwrap_or(&format_smolstr!("{}", order_id_raw)),
1674 );
1675 let side = match o.side.as_deref() {
1676 Some(s) => parse_side(s)?,
1678 None => {
1679 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing side");
1680 return None;
1681 }
1682 };
1683 let price = o.price.as_deref().and_then(|s| Decimal::from_str(s).ok());
1684 let quantity = match o
1685 .orig_qty
1686 .as_deref()
1687 .and_then(|s| Decimal::from_str(s).ok())
1688 {
1689 Some(v) => v,
1690 None => {
1691 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing/unparseable origQty");
1692 return None;
1693 }
1694 };
1695 let filled_qty = match o.executed_qty.as_deref() {
1696 Some(s) => match Decimal::from_str(s) {
1697 Ok(v) => v,
1698 Err(_) => {
1699 warn!(%instrument, order_id = %order_id_raw, executed_qty = s, "BinanceSpot open order unparseable executedQty, defaulting to 0");
1700 Decimal::ZERO
1701 }
1702 },
1703 None => Decimal::ZERO,
1704 };
1705 let kind = match o.r#type.as_deref() {
1706 Some(t) => parse_order_kind(t)?,
1708 None => {
1709 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing type");
1710 return None;
1711 }
1712 };
1713 let time_in_force = parse_time_in_force(o.time_in_force.as_deref().unwrap_or("GTC"));
1714 let time_exchange = match o.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
1715 Some(ts) => ts,
1716 None => {
1717 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing/unparseable time, using now");
1718 Utc::now()
1719 }
1720 };
1721
1722 Some(Order {
1723 key: OrderKey::new(
1724 ExchangeId::BinanceSpot,
1725 instrument.clone(),
1726 StrategyId::unknown(),
1729 cid,
1730 ),
1731 side,
1732 price,
1733 quantity,
1734 kind,
1735 time_in_force,
1736 state: Open::new(order_id, time_exchange, filled_qty),
1737 })
1738}
1739
1740fn convert_open_order_owned_symbol(
1745 o: &binance_sdk::spot::rest_api::AllOrdersResponseInner,
1746) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
1747 let instrument = match o.symbol.as_deref() {
1748 Some(s) => InstrumentNameExchange::new(s),
1749 None => {
1750 warn!("BinanceSpot open order missing symbol in return-all query, dropping order");
1751 return None;
1752 }
1753 };
1754 convert_open_order(o, &instrument)
1755}
1756
1757fn convert_my_trade(
1759 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
1760 instrument: &InstrumentNameExchange,
1761) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
1762 let trade_id_raw = match t.id {
1763 Some(id) => id,
1764 None => {
1765 warn!(%instrument, "BinanceSpot trade missing id");
1766 return None;
1767 }
1768 };
1769 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
1770 let order_id = match t.order_id {
1771 Some(id) => OrderId(format_smolstr!("{}", id)),
1772 None => {
1773 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
1774 return None;
1775 }
1776 };
1777 let side = match t.is_buyer {
1778 Some(is_buyer) => {
1779 if is_buyer {
1780 Side::Buy
1781 } else {
1782 Side::Sell
1783 }
1784 }
1785 None => {
1786 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
1787 return None;
1788 }
1789 };
1790 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1791 Some(v) => v,
1792 None => {
1793 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
1794 return None;
1795 }
1796 };
1797 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1798 Some(v) => v,
1799 None => {
1800 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
1801 return None;
1802 }
1803 };
1804 let commission = t
1805 .commission
1806 .as_deref()
1807 .and_then(|s| Decimal::from_str(s).ok())
1808 .unwrap_or(Decimal::ZERO);
1809 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
1810 Some(ts) => ts,
1811 None => {
1812 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
1813 Utc::now()
1814 }
1815 };
1816
1817 let fee_asset = t
1822 .commission_asset
1823 .as_deref()
1824 .map(AssetNameExchange::from)
1825 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
1826
1827 Some(Trade::new(
1828 trade_id,
1829 order_id,
1830 instrument.clone(),
1831 StrategyId::unknown(), time_exchange,
1833 side,
1834 price,
1835 quantity,
1836 AssetFees::new(fee_asset, commission, None),
1837 ))
1838}
1839
1840fn convert_user_data_events(
1848 event: UserDataStreamEventsResponse,
1849 buf: &mut Vec<UnindexedAccountEvent>,
1850) -> bool {
1851 match event {
1852 UserDataStreamEventsResponse::ExecutionReport(report) => {
1853 if let Some(ev) = convert_execution_report(*report) {
1854 buf.push(ev);
1855 }
1856 false
1857 }
1858 UserDataStreamEventsResponse::OutboundAccountPosition(position) => {
1859 convert_account_position(*position, buf);
1860 false
1861 }
1862 UserDataStreamEventsResponse::BalanceUpdate(_update) => {
1863 debug!("BinanceSpot ignoring BalanceUpdate event");
1869 false
1870 }
1871 UserDataStreamEventsResponse::EventStreamTerminated(_) => {
1872 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
1876 true
1877 }
1878 _ => {
1879 trace!("BinanceSpot ignoring unhandled user data event");
1880 false
1881 }
1882 }
1883}
1884
1885#[allow(clippy::cognitive_complexity)]
1896fn convert_execution_report(
1897 report: binance_sdk::spot::websocket_api::ExecutionReport,
1898) -> Option<UnindexedAccountEvent> {
1899 let exec_type = match report.x.as_deref() {
1900 Some(t) => t,
1901 None => {
1902 warn!("BinanceSpot executionReport missing execution type (x), dropping");
1903 return None;
1904 }
1905 };
1906 let symbol = match report.s.as_deref() {
1907 Some(s) => InstrumentNameExchange::new(s),
1908 None => {
1909 warn!("BinanceSpot executionReport missing symbol (s), dropping");
1910 return None;
1911 }
1912 };
1913 let order_id = match report.i {
1916 Some(id) => OrderId(format_smolstr!("{id}")),
1917 None => {
1918 warn!(%symbol, "BinanceSpot executionReport missing orderId (i), dropping");
1919 return None;
1920 }
1921 };
1922 let cid = match report.c.as_deref() {
1923 Some(c) => ClientOrderId::new(c),
1924 None => ClientOrderId::new(order_id.0.as_str()),
1925 };
1926
1927 let time_exchange = match report
1929 .t_uppercase
1930 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1931 {
1932 Some(t) => t,
1933 None => {
1934 warn!(%symbol, "BinanceSpot executionReport missing/unparseable transaction time (T), using now");
1935 Utc::now()
1936 }
1937 };
1938
1939 match exec_type {
1940 "NEW" => convert_new_order(&report, symbol, cid, order_id, time_exchange),
1941 "TRADE" => {
1942 let trade_id = match report.t {
1944 Some(id) => TradeId(format_smolstr!("{id}")),
1945 None => {
1946 warn!(%symbol, "BinanceSpot TRADE event missing trade ID (t), dropping");
1947 return None;
1948 }
1949 };
1950 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
1951 Some(s) => s,
1952 None => {
1953 warn!(%symbol, "BinanceSpot TRADE event missing/unknown side (S), dropping");
1954 return None;
1955 }
1956 };
1957 let last_price = match report.l_uppercase.as_deref() {
1958 Some(s) => match Decimal::from_str(s) {
1959 Ok(v) => v,
1960 Err(e) => {
1961 warn!(%symbol, error = %e, raw = s, "BinanceSpot TRADE event unparseable last price (L), dropping fill");
1962 return None;
1963 }
1964 },
1965 None => {
1966 warn!(%symbol, "BinanceSpot TRADE event missing last price (L), dropping fill");
1967 return None;
1968 }
1969 };
1970 let last_qty = match report.l.as_deref() {
1971 Some(s) => match Decimal::from_str(s) {
1972 Ok(v) => v,
1973 Err(e) => {
1974 warn!(%symbol, error = %e, raw = s, "BinanceSpot TRADE event unparseable last qty (l), dropping fill");
1975 return None;
1976 }
1977 },
1978 None => {
1979 warn!(%symbol, "BinanceSpot TRADE event missing last qty (l), dropping fill");
1980 return None;
1981 }
1982 };
1983 let commission = match Decimal::from_str(report.n.as_deref().unwrap_or("0")) {
1985 Ok(v) => v,
1986 Err(e) => {
1987 warn!(%symbol, error = %e, "BinanceSpot TRADE event unparseable commission (n), defaulting to 0");
1988 Decimal::ZERO
1989 }
1990 };
1991
1992 let fee_asset = report
1996 .n_uppercase
1997 .as_deref()
1998 .map(AssetNameExchange::from)
1999 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2000
2001 let trade = Trade::new(
2002 trade_id,
2003 order_id,
2004 symbol,
2005 StrategyId::unknown(), time_exchange,
2007 side,
2008 last_price,
2009 last_qty,
2010 AssetFees::new(fee_asset, commission, None),
2011 );
2012
2013 Some(UnindexedAccountEvent::new(
2014 ExchangeId::BinanceSpot,
2015 AccountEventKind::Trade(trade),
2016 ))
2017 }
2018 "CANCELED" | "EXPIRED" | "EXPIRED_IN_MATCH" => {
2019 let filled_qty = report
2020 .z
2021 .as_deref()
2022 .and_then(|s| Decimal::from_str(s).ok())
2023 .unwrap_or(Decimal::ZERO);
2024 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
2025 let response = UnindexedOrderResponseCancel {
2026 key: OrderKey::new(
2027 ExchangeId::BinanceSpot,
2028 symbol,
2029 StrategyId::unknown(), cid,
2031 ),
2032 state: Ok(cancelled),
2033 };
2034
2035 Some(UnindexedAccountEvent::new(
2036 ExchangeId::BinanceSpot,
2037 AccountEventKind::OrderCancelled(response),
2038 ))
2039 }
2040 "REJECTED" => {
2041 let reject_reason = report.r.unwrap_or_else(|| "unknown".to_string());
2045 warn!(
2046 %symbol, %order_id, reason = %reject_reason,
2047 "BinanceSpot order REJECTED by matching engine"
2048 );
2049 let response = UnindexedOrderResponseCancel {
2050 key: OrderKey::new(ExchangeId::BinanceSpot, symbol, StrategyId::unknown(), cid),
2051 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
2052 reject_reason,
2053 ))),
2054 };
2055
2056 Some(UnindexedAccountEvent::new(
2057 ExchangeId::BinanceSpot,
2058 AccountEventKind::OrderCancelled(response),
2059 ))
2060 }
2061 "REPLACE" => {
2062 let filled_qty = report
2069 .z
2070 .as_deref()
2071 .and_then(|s| Decimal::from_str(s).ok())
2072 .unwrap_or(Decimal::ZERO);
2073 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
2074 let response = UnindexedOrderResponseCancel {
2075 key: OrderKey::new(ExchangeId::BinanceSpot, symbol, StrategyId::unknown(), cid),
2076 state: Ok(cancelled),
2077 };
2078 Some(UnindexedAccountEvent::new(
2079 ExchangeId::BinanceSpot,
2080 AccountEventKind::OrderCancelled(response),
2081 ))
2082 }
2083 _ => {
2084 trace!(exec_type, "BinanceSpot ignoring execution type");
2087 None
2088 }
2089 }
2090}
2091
2092fn convert_new_order(
2094 report: &binance_sdk::spot::websocket_api::ExecutionReport,
2095 symbol: InstrumentNameExchange,
2096 cid: ClientOrderId,
2097 order_id: OrderId,
2098 time_exchange: DateTime<Utc>,
2099) -> Option<UnindexedAccountEvent> {
2100 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
2101 Some(s) => s,
2102 None => {
2103 warn!(%symbol, "BinanceSpot NEW event missing/unknown side (S), dropping");
2104 return None;
2105 }
2106 };
2107 let kind = parse_order_kind(report.o.as_deref().unwrap_or("LIMIT"))?;
2108 let price: Option<Decimal> = match (report.p.as_deref(), &kind) {
2109 (Some(p), _) => match Decimal::from_str(p) {
2110 Ok(v) if !v.is_zero() => Some(v),
2111 Ok(_) => {
2112 if matches!(
2116 kind,
2117 OrderKind::Limit
2118 | OrderKind::StopLimit { .. }
2119 | OrderKind::TakeProfitLimit { .. }
2120 | OrderKind::TrailingStopLimit { .. }
2121 ) {
2122 trace!(%symbol, %kind, "BinanceSpot NEW event has zero price (p) on limit-type order, treating as no limit price");
2123 }
2124 None
2125 }
2126 Err(e) => {
2127 warn!(%symbol, price = p, error = %e, "BinanceSpot NEW event unparseable price (p), dropping");
2128 return None;
2129 }
2130 },
2131 (
2132 None,
2133 OrderKind::Market
2134 | OrderKind::Stop { .. }
2135 | OrderKind::TakeProfit { .. }
2136 | OrderKind::TrailingStop { .. },
2137 ) => {
2138 None
2140 }
2141 (
2142 None,
2143 OrderKind::Limit
2144 | OrderKind::StopLimit { .. }
2145 | OrderKind::TakeProfitLimit { .. }
2146 | OrderKind::TrailingStopLimit { .. },
2147 ) => {
2148 warn!(%symbol, "BinanceSpot NEW limit-type order missing price (p), dropping");
2149 return None;
2150 }
2151 };
2152 let quantity = match report.q.as_deref() {
2153 Some(q) => match Decimal::from_str(q) {
2154 Ok(v) => v,
2155 Err(e) => {
2156 warn!(%symbol, qty = q, error = %e, "BinanceSpot NEW event unparseable quantity (q), dropping");
2157 return None;
2158 }
2159 },
2160 None => {
2161 warn!(%symbol, "BinanceSpot NEW order missing quantity (q), dropping");
2162 return None;
2163 }
2164 };
2165 let time_in_force = parse_time_in_force(report.f.as_deref().unwrap_or("GTC"));
2166 let filled_qty = report
2169 .z
2170 .as_deref()
2171 .and_then(|s| Decimal::from_str(s).ok())
2172 .unwrap_or(Decimal::ZERO);
2173
2174 let order = Order {
2175 key: OrderKey::new(
2176 ExchangeId::BinanceSpot,
2177 symbol,
2178 StrategyId::unknown(), cid,
2180 ),
2181 side,
2182 price,
2183 quantity,
2184 kind,
2185 time_in_force,
2186 state: OrderState::active(Open::new(order_id, time_exchange, filled_qty)),
2187 };
2188
2189 Some(UnindexedAccountEvent::new(
2190 ExchangeId::BinanceSpot,
2191 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot::new(
2192 order,
2193 )),
2194 ))
2195}
2196
2197fn convert_account_position(
2202 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2203 buf: &mut Vec<UnindexedAccountEvent>,
2204) {
2205 let time_exchange = position
2208 .u
2209 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2210 .unwrap_or_else(Utc::now);
2211
2212 for b in position.b_uppercase.unwrap_or_default() {
2213 let asset = match b.a {
2214 Some(a) => AssetNameExchange::new(a),
2215 None => {
2216 warn!("BinanceSpot account position entry missing asset name");
2217 continue;
2218 }
2219 };
2220 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2221 Some(v) => v,
2222 None => {
2223 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2224 continue;
2225 }
2226 };
2227 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2228 Some(v) => v,
2229 None => {
2230 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2231 continue;
2232 }
2233 };
2234 let update =
2239 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2240 buf.push(UnindexedAccountEvent::new(
2241 ExchangeId::BinanceSpot,
2242 AccountEventKind::BalanceStreamUpdate(
2243 rustrade_integration::collection::snapshot::Snapshot::new(update),
2244 ),
2245 ));
2246 }
2247}
2248
2249fn convert_order_kind_tif(
2276 kind: OrderKind,
2277 tif: TimeInForce,
2278) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2279 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2283 let type_enum = match binance_type {
2284 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2285 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2286 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2287 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2288 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2289 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2290 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2291 };
2292 let tif_enum = binance_tif.map(|t| match t {
2293 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2294 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2295 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2296 });
2297 Some((type_enum, tif_enum))
2298}
2299
2300#[cfg(test)]
2301#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2303 use super::*;
2304 use crate::client::binance::shared::*;
2305 use crate::order::TrailingOffsetType;
2306 use binance_sdk::common::errors::WebsocketError;
2307 use smol_str::SmolStr;
2308
2309 #[test]
2310 fn test_parse_side() {
2311 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2312 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2313 assert_eq!(parse_side("buy"), None);
2314 assert_eq!(parse_side("UNKNOWN"), None);
2315 }
2316
2317 #[test]
2318 fn test_parse_order_kind() {
2319 assert_eq!(parse_order_kind("MARKET"), Some(OrderKind::Market));
2320 assert_eq!(parse_order_kind("LIMIT"), Some(OrderKind::Limit));
2321 assert_eq!(parse_order_kind("LIMIT_MAKER"), Some(OrderKind::Limit));
2322 assert_eq!(parse_order_kind("STOP_LOSS"), None);
2323 assert_eq!(parse_order_kind("TAKE_PROFIT"), None);
2324 assert_eq!(parse_order_kind("STOP_LOSS_LIMIT"), Some(OrderKind::Limit));
2325 assert_eq!(
2326 parse_order_kind("TAKE_PROFIT_LIMIT"),
2327 Some(OrderKind::Limit)
2328 );
2329 assert_eq!(parse_order_kind("UNKNOWN_TYPE"), None);
2330 }
2331
2332 #[test]
2333 fn test_parse_time_in_force() {
2334 assert_eq!(
2335 parse_time_in_force("GTC"),
2336 TimeInForce::GoodUntilCancelled { post_only: false }
2337 );
2338 assert_eq!(
2339 parse_time_in_force("GTX"),
2340 TimeInForce::GoodUntilCancelled { post_only: true }
2341 );
2342 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2343 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2344 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2345 assert_eq!(
2347 parse_time_in_force("UNKNOWN"),
2348 TimeInForce::GoodUntilCancelled { post_only: false }
2349 );
2350 }
2351
2352 #[test]
2353 fn test_convert_order_kind_tif() {
2354 use rust_decimal::Decimal;
2355
2356 assert!(matches!(
2358 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2359 Some((OrderPlaceTypeEnum::Market, None))
2360 ));
2361 assert!(matches!(
2362 convert_order_kind_tif(
2363 OrderKind::Limit,
2364 TimeInForce::GoodUntilCancelled { post_only: false }
2365 ),
2366 Some((
2367 OrderPlaceTypeEnum::Limit,
2368 Some(OrderPlaceTimeInForceEnum::Gtc)
2369 ))
2370 ));
2371 assert!(matches!(
2372 convert_order_kind_tif(
2373 OrderKind::Limit,
2374 TimeInForce::GoodUntilCancelled { post_only: true }
2375 ),
2376 Some((OrderPlaceTypeEnum::LimitMaker, None))
2377 ));
2378 assert!(matches!(
2379 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2380 Some((
2381 OrderPlaceTypeEnum::Limit,
2382 Some(OrderPlaceTimeInForceEnum::Fok)
2383 ))
2384 ));
2385 assert!(matches!(
2386 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2387 Some((
2388 OrderPlaceTypeEnum::Limit,
2389 Some(OrderPlaceTimeInForceEnum::Ioc)
2390 ))
2391 ));
2392 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2395
2396 assert!(matches!(
2398 convert_order_kind_tif(
2399 OrderKind::Stop {
2400 trigger_price: Decimal::from(100)
2401 },
2402 TimeInForce::GoodUntilCancelled { post_only: false }
2403 ),
2404 Some((OrderPlaceTypeEnum::StopLoss, None))
2405 ));
2406 assert!(matches!(
2407 convert_order_kind_tif(
2408 OrderKind::StopLimit {
2409 trigger_price: Decimal::from(100)
2410 },
2411 TimeInForce::GoodUntilCancelled { post_only: false }
2412 ),
2413 Some((
2414 OrderPlaceTypeEnum::StopLossLimit,
2415 Some(OrderPlaceTimeInForceEnum::Gtc)
2416 ))
2417 ));
2418 assert!(matches!(
2419 convert_order_kind_tif(
2420 OrderKind::TakeProfit {
2421 trigger_price: Decimal::from(150)
2422 },
2423 TimeInForce::ImmediateOrCancel
2424 ),
2425 Some((OrderPlaceTypeEnum::TakeProfit, None))
2426 ));
2427 assert!(matches!(
2428 convert_order_kind_tif(
2429 OrderKind::TakeProfitLimit {
2430 trigger_price: Decimal::from(150)
2431 },
2432 TimeInForce::FillOrKill
2433 ),
2434 Some((
2435 OrderPlaceTypeEnum::TakeProfitLimit,
2436 Some(OrderPlaceTimeInForceEnum::Fok)
2437 ))
2438 ));
2439
2440 assert!(matches!(
2442 convert_order_kind_tif(
2443 OrderKind::TrailingStop {
2444 offset: Decimal::from(100),
2445 offset_type: TrailingOffsetType::BasisPoints,
2446 },
2447 TimeInForce::GoodUntilCancelled { post_only: false }
2448 ),
2449 Some((OrderPlaceTypeEnum::StopLoss, None))
2450 ));
2451 assert!(matches!(
2452 convert_order_kind_tif(
2453 OrderKind::TrailingStop {
2454 offset: Decimal::from(5),
2455 offset_type: TrailingOffsetType::Percentage,
2456 },
2457 TimeInForce::GoodUntilCancelled { post_only: false }
2458 ),
2459 Some((OrderPlaceTypeEnum::StopLoss, None))
2460 ));
2461
2462 assert!(
2464 convert_order_kind_tif(
2465 OrderKind::TrailingStop {
2466 offset: Decimal::from(10),
2467 offset_type: TrailingOffsetType::Absolute,
2468 },
2469 TimeInForce::GoodUntilCancelled { post_only: false }
2470 )
2471 .is_none()
2472 );
2473
2474 assert!(
2476 convert_order_kind_tif(
2477 OrderKind::TrailingStopLimit {
2478 offset: Decimal::from(100),
2479 offset_type: TrailingOffsetType::BasisPoints,
2480 limit_offset: Decimal::from(10),
2481 },
2482 TimeInForce::GoodUntilCancelled { post_only: false }
2483 )
2484 .is_none()
2485 );
2486 }
2487
2488 #[test]
2489 fn test_parse_binance_api_error() {
2490 let instrument = InstrumentNameExchange::new("BTCUSDT");
2491
2492 assert!(matches!(
2493 parse_binance_api_error("Insufficient balance".into(), &instrument),
2494 ApiError::BalanceInsufficient(_, _)
2495 ));
2496 assert!(matches!(
2497 parse_binance_api_error("Not enough funds".into(), &instrument),
2498 ApiError::BalanceInsufficient(_, _)
2499 ));
2500 assert_eq!(
2501 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2502 ApiError::RateLimit
2503 );
2504 assert_eq!(
2505 parse_binance_api_error("Error code -1015".into(), &instrument),
2506 ApiError::RateLimit
2507 );
2508 assert_eq!(
2510 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2511 ApiError::RateLimit
2512 );
2513 assert_eq!(
2515 parse_binance_api_error("Error code -2011".into(), &instrument),
2516 ApiError::OrderAlreadyCancelled
2517 );
2518 assert!(matches!(
2520 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2521 ApiError::OrderAlreadyCancelled
2522 ));
2523 assert!(matches!(
2525 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2526 ApiError::OrderAlreadyCancelled
2527 ));
2528 assert!(matches!(
2530 parse_binance_api_error("Unknown order encountered".into(), &instrument),
2531 ApiError::OrderRejected(_)
2532 ));
2533 assert!(matches!(
2534 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2535 ApiError::InstrumentInvalid(_, _)
2536 ));
2537 assert!(matches!(
2539 parse_binance_api_error(
2540 "Server-side response error (code -2010): Account has insufficient balance".into(),
2541 &instrument
2542 ),
2543 ApiError::BalanceInsufficient(_, _)
2544 ));
2545 assert!(matches!(
2546 parse_binance_api_error("Some other error".into(), &instrument),
2547 ApiError::OrderRejected(_)
2548 ));
2549 }
2550
2551 #[test]
2552 fn test_contains_error_code_suffix_guard() {
2553 assert!(
2555 !contains_error_code("-20130", "-2013"),
2556 "-20130 should not match -2013"
2557 );
2558 assert!(
2559 !contains_error_code("-20131", "-2013"),
2560 "-20131 should not match -2013"
2561 );
2562 assert!(contains_error_code("-2013", "-2013"), "exact match");
2564 assert!(
2565 contains_error_code("Error -2013: text", "-2013"),
2566 "match with trailing text"
2567 );
2568 }
2569
2570 #[test]
2571 fn test_contains_error_code_prefix_guard() {
2572 assert!(
2575 !contains_error_code("1-2013", "-2013"),
2576 "1-2013 should not match -2013"
2577 );
2578 assert!(
2579 !contains_error_code("error 1-2013 text", "-2013"),
2580 "embedded 1-2013 should not match"
2581 );
2582 assert!(
2584 contains_error_code("code=-2013,", "-2013"),
2585 "=-2013 prefix should match"
2586 );
2587 assert!(
2588 contains_error_code(" -2013 ", "-2013"),
2589 "space prefix should match"
2590 );
2591 }
2592
2593 #[test]
2594 fn test_contains_error_code_second_occurrence_valid() {
2595 assert!(
2598 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
2599 "second valid occurrence must match when first fails digit guard"
2600 );
2601 assert!(
2603 contains_error_code("-2013 or -20130", "-2013"),
2604 "first valid occurrence must match when second is a longer code"
2605 );
2606 }
2607
2608 #[test]
2609 fn test_dedup_cache() {
2610 let cache = new_dedup_cache();
2611 let key = DedupKey {
2612 instrument: SmolStr::from("BTCUSDT"),
2613 id: SmolStr::from("12345"),
2614 kind: DedupEventKind::Trade,
2615 };
2616
2617 assert!(!is_duplicate(&cache, key));
2619 let key = DedupKey {
2621 instrument: SmolStr::from("BTCUSDT"),
2622 id: SmolStr::from("12345"),
2623 kind: DedupEventKind::Trade,
2624 };
2625 assert!(is_duplicate(&cache, key));
2626
2627 let key2 = DedupKey {
2629 instrument: SmolStr::from("BTCUSDT"),
2630 id: SmolStr::from("12345"),
2631 kind: DedupEventKind::New,
2632 };
2633 assert!(!is_duplicate(&cache, key2));
2634 }
2635
2636 #[test]
2637 fn test_is_rate_limit_error() {
2638 assert!(is_rate_limit_error(&anyhow::anyhow!(
2640 "Too many requests. You are being rate-limited. Please slow down."
2641 )));
2642 assert!(is_rate_limit_error(&anyhow::anyhow!(
2644 "The IP address has been banned for exceeding rate limits. Contact support."
2645 )));
2646 assert!(is_rate_limit_error(&anyhow::anyhow!(
2648 "Error -1015: too many new orders"
2649 )));
2650 assert!(is_rate_limit_error(&anyhow::anyhow!(
2651 "Error -1003: too many requests"
2652 )));
2653 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
2655 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
2656 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
2657 assert!(
2659 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
2660 "-10150 should not match -1015"
2661 );
2662 assert!(
2663 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
2664 "-10030 should not match -1003"
2665 );
2666 }
2667
2668 #[test]
2669 fn test_is_api_rejection_error() {
2670 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
2672 code: -2010,
2673 message: "Account has insufficient balance for requested action.".into(),
2674 });
2675 assert!(
2676 is_api_rejection_error(&rejection),
2677 "ResponseError should be detected as API rejection"
2678 );
2679
2680 let transport = anyhow::anyhow!("connection reset by peer");
2682 assert!(
2683 !is_api_rejection_error(&transport),
2684 "plain transport error should not be detected as API rejection"
2685 );
2686
2687 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
2689 assert!(
2690 !is_api_rejection_error(&rate_limit),
2691 "rate-limit string error should not be detected as API rejection"
2692 );
2693 }
2694
2695 #[test]
2696 fn test_connectivity_error_detects_auth_failures() {
2697 let err = connectivity_error(anyhow::anyhow!("Error -1002: unauthorized"));
2699 assert!(
2700 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2701 "expected Unauthenticated for -1002, got {err:?}"
2702 );
2703
2704 let err = connectivity_error(anyhow::anyhow!("Error -2015: permission denied for action"));
2707 assert!(
2708 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2709 "expected Unauthenticated for -2015, got {err:?}"
2710 );
2711
2712 let err = connectivity_error(anyhow::anyhow!("invalid signature provided"));
2714 assert!(
2715 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2716 "expected Unauthenticated for 'invalid signature', got {err:?}"
2717 );
2718
2719 let err = connectivity_error(anyhow::anyhow!(
2721 "The signature for this request is not valid."
2722 ));
2723 assert!(
2724 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2725 "expected Unauthenticated for 'signature for this request is not valid', got {err:?}"
2726 );
2727
2728 let err = connectivity_error(anyhow::anyhow!("Invalid API-key format"));
2730 assert!(
2731 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2732 "expected Unauthenticated for 'invalid api-key', got {err:?}"
2733 );
2734
2735 let err = connectivity_error(anyhow::anyhow!("connection timeout"));
2737 assert!(
2738 matches!(err, UnindexedClientError::Connectivity(_)),
2739 "expected Connectivity for timeout, got {err:?}"
2740 );
2741 }
2742
2743 #[test]
2744 fn test_dedup_key_from_event_trade_includes_instrument() {
2745 use crate::order::id::{OrderId, StrategyId};
2746 use crate::trade::{AssetFees, Trade, TradeId};
2747 use chrono::Utc;
2748 use rust_decimal::Decimal;
2749 use rustrade_instrument::Side;
2750
2751 let instrument = InstrumentNameExchange::new("BTCUSDT");
2752 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
2753 TradeId::new("9001"),
2754 OrderId::new("4242"),
2755 instrument.clone(),
2756 StrategyId::unknown(),
2757 Utc::now(),
2758 Side::Buy,
2759 Decimal::ZERO,
2760 Decimal::ZERO,
2761 AssetFees::new(
2762 AssetNameExchange::from("USDT"),
2763 Decimal::ZERO,
2764 Some(Decimal::ZERO),
2765 ),
2766 );
2767 let event =
2768 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
2769
2770 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
2771 assert_eq!(key.kind, DedupEventKind::Trade);
2772 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
2773 assert_eq!(
2774 key.instrument.as_str(),
2775 "BTCUSDT",
2776 "key.instrument should be the symbol"
2777 );
2778
2779 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
2781 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
2782 TradeId::new("9001"),
2783 OrderId::new("7777"),
2784 instrument2,
2785 StrategyId::unknown(),
2786 Utc::now(),
2787 Side::Buy,
2788 Decimal::ZERO,
2789 Decimal::ZERO,
2790 AssetFees::new(
2791 AssetNameExchange::from("USDT"),
2792 Decimal::ZERO,
2793 Some(Decimal::ZERO),
2794 ),
2795 );
2796 let event2 =
2797 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
2798 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
2799 assert_ne!(
2800 key, key2,
2801 "same trade ID on different symbols must produce distinct keys"
2802 );
2803 }
2804
2805 fn make_balance_inner(
2810 asset: &str,
2811 free: &str,
2812 locked: &str,
2813 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2814 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2815 a: Some(asset.to_string()),
2816 f: Some(free.to_string()),
2817 l: Some(locked.to_string()),
2818 }
2819 }
2820
2821 #[test]
2822 fn test_convert_account_position_happy_path() {
2823 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2824 u: Some(1_700_000_000_000),
2825 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
2826 ..Default::default()
2827 };
2828 let mut buf = Vec::new();
2829 convert_account_position(position, &mut buf);
2830
2831 assert_eq!(buf.len(), 1);
2832 match &buf[0].kind {
2833 AccountEventKind::BalanceStreamUpdate(snap) => {
2834 let update = &snap.0;
2835 assert_eq!(update.asset.as_ref(), "BTC");
2836 let expected_free = Decimal::from_str("1.5").unwrap();
2838 let expected_locked = Decimal::from_str("0.5").unwrap();
2839 assert_eq!(update.update.free, expected_free);
2840 assert_eq!(update.update.locked, expected_locked);
2841 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
2842 }
2843 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
2844 }
2845 }
2846
2847 #[test]
2848 fn test_convert_account_position_u_field_none_uses_now() {
2849 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2851 u: None,
2852 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
2853 ..Default::default()
2854 };
2855 let mut buf = Vec::new();
2856 convert_account_position(position, &mut buf);
2857 assert_eq!(buf.len(), 1);
2858 }
2859
2860 #[test]
2861 fn test_convert_account_position_missing_asset_name_skipped() {
2862 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2863 u: Some(1_700_000_000_000),
2864 b_uppercase: Some(vec![
2865 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2866 a: None, f: Some("1.0".to_string()),
2868 l: Some("0.0".to_string()),
2869 },
2870 make_balance_inner("USDT", "100.0", "0.0"), ]),
2872 ..Default::default()
2873 };
2874 let mut buf = Vec::new();
2875 convert_account_position(position, &mut buf);
2876 assert_eq!(buf.len(), 1);
2878 match &buf[0].kind {
2879 AccountEventKind::BalanceStreamUpdate(snap) => {
2880 assert_eq!(snap.0.asset.as_ref(), "USDT");
2881 }
2882 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
2883 }
2884 }
2885
2886 #[test]
2887 fn test_convert_account_position_unparseable_free_skipped() {
2888 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2889 u: Some(1_700_000_000_000),
2890 b_uppercase: Some(vec![
2891 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2892 a: Some("BTC".to_string()),
2893 f: Some("not-a-number".to_string()),
2894 l: Some("0.0".to_string()),
2895 },
2896 make_balance_inner("ETH", "1.0", "0.0"), ]),
2898 ..Default::default()
2899 };
2900 let mut buf = Vec::new();
2901 convert_account_position(position, &mut buf);
2902 assert_eq!(buf.len(), 1);
2904 match &buf[0].kind {
2905 AccountEventKind::BalanceStreamUpdate(snap) => {
2906 assert_eq!(snap.0.asset.as_ref(), "ETH");
2907 }
2908 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
2909 }
2910 }
2911
2912 #[test]
2913 fn test_convert_account_position_empty_balances() {
2914 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2915 u: Some(1_700_000_000_000),
2916 b_uppercase: Some(vec![]),
2917 ..Default::default()
2918 };
2919 let mut buf = Vec::new();
2920 convert_account_position(position, &mut buf);
2921 assert!(
2922 buf.is_empty(),
2923 "empty balance list should produce no events"
2924 );
2925 }
2926
2927 #[test]
2928 fn test_convert_account_position_b_field_none() {
2929 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2930 u: Some(1_700_000_000_000),
2931 b_uppercase: None, ..Default::default()
2933 };
2934 let mut buf = Vec::new();
2935 convert_account_position(position, &mut buf);
2936 assert!(buf.is_empty(), "None B field should produce no events");
2937 }
2938
2939 #[tokio::test]
2941 async fn test_exponential_backoff_exhaustion() {
2942 tokio::time::pause(); let mut backoff = ExponentialBackoff::new();
2944
2945 for i in 0..MAX_RECONNECT_ATTEMPTS {
2947 assert!(
2948 backoff.wait().await,
2949 "expected true on attempt {i} (before exhaustion)"
2950 );
2951 }
2952
2953 assert!(!backoff.wait().await, "expected false after max attempts");
2955 }
2956
2957 #[tokio::test]
2958 async fn test_exponential_backoff_reset() {
2959 tokio::time::pause();
2960 let mut backoff = ExponentialBackoff::new();
2961
2962 for _ in 0..MAX_RECONNECT_ATTEMPTS {
2963 backoff.wait().await;
2964 }
2965 assert!(!backoff.wait().await, "should be exhausted");
2966
2967 backoff.reset();
2968 assert!(backoff.wait().await, "should succeed again after reset");
2969 }
2970
2971 fn make_base_report() -> binance_sdk::spot::websocket_api::ExecutionReport {
2975 binance_sdk::spot::websocket_api::ExecutionReport {
2976 s: Some("BTCUSDT".to_string()),
2977 i: Some(12345),
2978 c: Some("client-1".to_string()),
2979 t_uppercase: Some(1_700_000_000_000),
2980 ..Default::default()
2981 }
2982 }
2983
2984 #[test]
2985 fn test_convert_execution_report_new() {
2986 let report = binance_sdk::spot::websocket_api::ExecutionReport {
2987 x: Some("NEW".to_string()),
2988 s_uppercase: Some("BUY".to_string()),
2989 o: Some("LIMIT".to_string()),
2990 p: Some("50000.00".to_string()),
2991 q: Some("0.01".to_string()),
2992 f: Some("GTC".to_string()),
2993 z: Some("0".to_string()),
2994 ..make_base_report()
2995 };
2996
2997 let event = convert_execution_report(report).expect("NEW event should produce Some");
2998 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
2999 match &event.kind {
3000 AccountEventKind::OrderSnapshot(snap) => {
3001 let order = &snap.0;
3002 assert_eq!(order.side, Side::Buy);
3003 assert_eq!(order.kind, OrderKind::Limit);
3004 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3005 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3006 assert_eq!(order.key.cid.0.as_str(), "client-1");
3007 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3008 }
3009 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3010 }
3011 }
3012
3013 #[test]
3014 fn test_convert_execution_report_trade() {
3015 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3016 x: Some("TRADE".to_string()),
3017 s_uppercase: Some("BUY".to_string()),
3018 t: Some(9999),
3019 l_uppercase: Some("50000.00".to_string()),
3020 l: Some("0.01".to_string()),
3021 n: Some("0.000001".to_string()),
3022 ..make_base_report()
3023 };
3024
3025 let event = convert_execution_report(report).expect("TRADE event should produce Some");
3026 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3027 match &event.kind {
3028 AccountEventKind::Trade(trade) => {
3029 assert_eq!(trade.side, Side::Buy);
3030 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3031 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3032 assert_eq!(trade.id.0.as_str(), "9999");
3033 assert_eq!(trade.order_id.0.as_str(), "12345");
3034 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3035 }
3036 other => panic!("TRADE should yield Trade, got {other:?}"),
3037 }
3038 }
3039
3040 #[test]
3041 fn test_convert_execution_report_trade_missing_last_price() {
3042 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3044 x: Some("TRADE".to_string()),
3045 s_uppercase: Some("BUY".to_string()),
3046 t: Some(9999),
3047 l_uppercase: None, l: Some("0.01".to_string()),
3049 ..make_base_report()
3050 };
3051 assert!(
3052 convert_execution_report(report).is_none(),
3053 "missing last price (L) should return None"
3054 );
3055 }
3056
3057 #[test]
3058 fn test_convert_execution_report_trade_missing_last_qty() {
3059 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3061 x: Some("TRADE".to_string()),
3062 s_uppercase: Some("BUY".to_string()),
3063 t: Some(9999),
3064 l_uppercase: Some("50000.00".to_string()),
3065 l: None, ..make_base_report()
3067 };
3068 assert!(
3069 convert_execution_report(report).is_none(),
3070 "missing last qty (l) should return None"
3071 );
3072 }
3073
3074 #[test]
3075 fn test_convert_execution_report_canceled() {
3076 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3077 x: Some("CANCELED".to_string()),
3078 ..make_base_report()
3079 };
3080
3081 let event = convert_execution_report(report).expect("CANCELED should produce Some");
3082 assert!(
3083 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3084 "CANCELED should yield OrderCancelled with Ok state"
3085 );
3086 }
3087
3088 #[test]
3089 fn test_convert_execution_report_expired() {
3090 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3091 x: Some("EXPIRED".to_string()),
3092 ..make_base_report()
3093 };
3094 let event = convert_execution_report(report).expect("EXPIRED should produce Some");
3095 assert!(
3096 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3097 "EXPIRED should yield OrderCancelled with Ok state"
3098 );
3099 }
3100
3101 #[test]
3102 fn test_convert_execution_report_expired_in_match() {
3103 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3104 x: Some("EXPIRED_IN_MATCH".to_string()),
3105 ..make_base_report()
3106 };
3107 let event = convert_execution_report(report).expect("EXPIRED_IN_MATCH should produce Some");
3108 assert!(
3109 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3110 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3111 );
3112 }
3113
3114 #[test]
3115 fn test_convert_execution_report_rejected() {
3116 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3117 x: Some("REJECTED".to_string()),
3118 r: Some("INSUFFICIENT_FUNDS".to_string()),
3119 ..make_base_report()
3120 };
3121
3122 let event = convert_execution_report(report).expect("REJECTED should produce Some");
3123 assert!(
3124 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_err()),
3125 "REJECTED should yield OrderCancelled with Err state"
3126 );
3127 }
3128
3129 #[test]
3130 fn test_convert_execution_report_missing_exec_type() {
3131 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3133 s: Some("BTCUSDT".to_string()),
3134 ..Default::default()
3135 };
3136 assert!(
3137 convert_execution_report(report).is_none(),
3138 "missing execution type should return None"
3139 );
3140 }
3141
3142 #[test]
3143 fn test_convert_execution_report_missing_symbol() {
3144 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3146 x: Some("NEW".to_string()),
3147 ..Default::default()
3148 };
3149 assert!(
3150 convert_execution_report(report).is_none(),
3151 "missing symbol should return None"
3152 );
3153 }
3154
3155 #[test]
3156 fn test_convert_execution_report_missing_order_id() {
3157 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3159 x: Some("NEW".to_string()),
3160 s: Some("BTCUSDT".to_string()),
3161 i: None,
3162 ..Default::default()
3163 };
3164 assert!(
3165 convert_execution_report(report).is_none(),
3166 "missing orderId should return None"
3167 );
3168 }
3169
3170 #[test]
3171 fn test_convert_execution_report_trade_missing_trade_id() {
3172 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3174 x: Some("TRADE".to_string()),
3175 t: None,
3176 ..make_base_report()
3177 };
3178 assert!(
3179 convert_execution_report(report).is_none(),
3180 "TRADE event missing tradeId should return None"
3181 );
3182 }
3183
3184 #[test]
3185 fn test_convert_execution_report_replace_yields_cancelled() {
3186 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3187 x: Some("REPLACE".to_string()),
3188 ..make_base_report()
3189 };
3190 let event = convert_execution_report(report).expect("REPLACE should produce Some");
3193 assert!(
3194 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3195 "REPLACE should yield OrderCancelled with Ok state"
3196 );
3197 }
3198
3199 fn make_balance(
3204 asset: &str,
3205 free: &str,
3206 locked: &str,
3207 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3208 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3209 asset: Some(asset.to_string()),
3210 free: Some(free.to_string()),
3211 locked: Some(locked.to_string()),
3212 }
3213 }
3214
3215 #[test]
3216 fn test_filter_balances_empty_assets_returns_all() {
3217 let balances = vec![
3218 make_balance("BTC", "1.0", "0.0"),
3219 make_balance("USDT", "500.0", "50.0"),
3220 ];
3221 let result = filter_and_convert_balances(balances, &[]);
3222 assert_eq!(result.len(), 2, "empty filter should return all balances");
3223 }
3224
3225 #[test]
3226 fn test_filter_balances_matching_asset_returned() {
3227 let balances = vec![
3228 make_balance("BTC", "1.5", "0.5"),
3229 make_balance("ETH", "10.0", "0.0"),
3230 ];
3231 let assets = vec![AssetNameExchange::new("BTC")];
3232 let result = filter_and_convert_balances(balances, &assets);
3233 assert_eq!(result.len(), 1);
3234 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
3235 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
3237 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
3238 }
3239
3240 #[test]
3241 fn test_filter_balances_non_matching_asset_filtered_out() {
3242 let balances = vec![
3243 make_balance("BTC", "1.0", "0.0"),
3244 make_balance("ETH", "2.0", "0.0"),
3245 ];
3246 let assets = vec![AssetNameExchange::new("USDT")];
3247 let result = filter_and_convert_balances(balances, &assets);
3248 assert!(
3249 result.is_empty(),
3250 "non-matching asset should be filtered out"
3251 );
3252 }
3253
3254 #[test]
3255 fn test_filter_balances_missing_asset_field_skipped() {
3256 let balances = vec![
3257 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3258 asset: None,
3259 free: Some("1.0".to_string()),
3260 locked: Some("0.0".to_string()),
3261 },
3262 make_balance("USDT", "100.0", "0.0"),
3263 ];
3264 let result = filter_and_convert_balances(balances, &[]);
3265 assert_eq!(
3266 result.len(),
3267 1,
3268 "entry with missing asset should be skipped"
3269 );
3270 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
3271 }
3272
3273 #[test]
3274 fn test_filter_balances_unparseable_free_skipped() {
3275 let balances = vec![
3276 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3277 asset: Some("BTC".to_string()),
3278 free: Some("not-a-number".to_string()),
3279 locked: Some("0.0".to_string()),
3280 },
3281 ];
3282 let result = filter_and_convert_balances(balances, &[]);
3283 assert!(
3284 result.is_empty(),
3285 "unparseable free field should be skipped"
3286 );
3287 }
3288
3289 #[test]
3290 fn test_filter_balances_zero_balance_included() {
3291 let balances = vec![
3294 make_balance("BTC", "0.00000000", "0.00000000"),
3295 make_balance("USDT", "100.0", "0.0"),
3296 ];
3297 let result = filter_and_convert_balances(balances, &[]);
3298 assert_eq!(result.len(), 2, "zero-balance entries must be included");
3299 let btc = result
3300 .iter()
3301 .find(|b| b.asset == AssetNameExchange::new("BTC"))
3302 .unwrap();
3303 assert_eq!(btc.balance.total, Decimal::ZERO);
3304 assert_eq!(btc.balance.free, Decimal::ZERO);
3305 }
3306
3307 #[test]
3308 fn test_filter_balances_duplicate_assets_in_response() {
3309 let balances = vec![
3313 make_balance("BTC", "1.0", "0.0"),
3314 make_balance("BTC", "2.0", "0.0"),
3315 ];
3316 let result = filter_and_convert_balances(balances, &[]);
3317 assert_eq!(
3318 result.len(),
3319 2,
3320 "duplicate asset entries produce two AssetBalance entries"
3321 );
3322 }
3323
3324 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
3329 binance_sdk::spot::rest_api::MyTradesResponseInner {
3330 id: Some(9001),
3331 order_id: Some(4242),
3332 price: Some("50000.00".to_string()),
3333 qty: Some("0.01".to_string()),
3334 commission: Some("0.05".to_string()),
3335 time: Some(1_700_000_000_000),
3336 is_buyer: Some(true),
3337 ..Default::default()
3338 }
3339 }
3340
3341 #[test]
3342 fn test_convert_my_trade_happy_path() {
3343 let instrument = InstrumentNameExchange::new("BTCUSDT");
3344 let trade =
3345 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
3346 assert_eq!(trade.instrument, instrument);
3347 assert_eq!(trade.side, Side::Buy);
3348 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3349 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3350 }
3351
3352 #[test]
3353 fn test_convert_my_trade_sell_side() {
3354 let instrument = InstrumentNameExchange::new("BTCUSDT");
3355 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3356 is_buyer: Some(false),
3357 ..make_base_trade()
3358 };
3359 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
3360 assert_eq!(trade.side, Side::Sell);
3361 }
3362
3363 #[test]
3364 fn test_convert_my_trade_missing_id_returns_none() {
3365 let instrument = InstrumentNameExchange::new("BTCUSDT");
3366 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3367 id: None,
3368 ..make_base_trade()
3369 };
3370 assert!(
3371 convert_my_trade(&t, &instrument).is_none(),
3372 "missing id should return None"
3373 );
3374 }
3375
3376 #[test]
3377 fn test_convert_my_trade_missing_order_id_returns_none() {
3378 let instrument = InstrumentNameExchange::new("BTCUSDT");
3379 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3380 order_id: None,
3381 ..make_base_trade()
3382 };
3383 assert!(
3384 convert_my_trade(&t, &instrument).is_none(),
3385 "missing orderId should return None"
3386 );
3387 }
3388
3389 #[test]
3390 fn test_convert_my_trade_missing_is_buyer_returns_none() {
3391 let instrument = InstrumentNameExchange::new("BTCUSDT");
3392 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3393 is_buyer: None,
3394 ..make_base_trade()
3395 };
3396 assert!(
3397 convert_my_trade(&t, &instrument).is_none(),
3398 "missing isBuyer should return None"
3399 );
3400 }
3401
3402 #[test]
3403 fn test_convert_my_trade_commission_none_defaults_to_zero() {
3404 let instrument = InstrumentNameExchange::new("BTCUSDT");
3405 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3406 commission: None,
3407 ..make_base_trade()
3408 };
3409 let trade =
3410 convert_my_trade(&t, &instrument).expect("None commission should still convert");
3411 assert_eq!(trade.fees.fees, Decimal::ZERO);
3412 }
3413
3414 fn make_base_open_order() -> binance_sdk::spot::rest_api::AllOrdersResponseInner {
3419 binance_sdk::spot::rest_api::AllOrdersResponseInner {
3420 order_id: Some(12345),
3421 client_order_id: Some("cid-abc".to_string()),
3422 side: Some("BUY".to_string()),
3423 r#type: Some("LIMIT".to_string()),
3424 price: Some("50000.00".to_string()),
3425 orig_qty: Some("0.01".to_string()),
3426 executed_qty: Some("0.0".to_string()),
3427 time_in_force: Some("GTC".to_string()),
3428 time: Some(1_700_000_000_000),
3429 ..Default::default()
3430 }
3431 }
3432
3433 #[test]
3434 fn test_convert_open_order_happy_path() {
3435 let instrument = InstrumentNameExchange::new("BTCUSDT");
3436 let order = convert_open_order(&make_base_open_order(), &instrument)
3437 .expect("valid order should convert");
3438 assert_eq!(order.key.instrument, instrument);
3439 assert_eq!(order.side, Side::Buy);
3440 assert_eq!(order.kind, OrderKind::Limit);
3441 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3442 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3443 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
3444 }
3445
3446 #[test]
3447 fn test_convert_open_order_owned_symbol_recovers_instrument() {
3448 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3450 symbol: Some("ETHUSDT".to_string()),
3451 ..make_base_open_order()
3452 };
3453 let order = convert_open_order_owned_symbol(&o).expect("valid order should convert");
3454 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
3455 assert_eq!(order.side, Side::Buy);
3456 }
3457
3458 #[test]
3459 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
3460 let o = make_base_open_order();
3462 assert!(o.symbol.is_none());
3463 assert!(convert_open_order_owned_symbol(&o).is_none());
3464 }
3465
3466 #[test]
3467 fn test_convert_open_order_missing_order_id_returns_none() {
3468 let instrument = InstrumentNameExchange::new("BTCUSDT");
3469 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3470 order_id: None,
3471 ..make_base_open_order()
3472 };
3473 assert!(
3474 convert_open_order(&o, &instrument).is_none(),
3475 "missing orderId should return None"
3476 );
3477 }
3478
3479 #[test]
3480 fn test_convert_open_order_missing_side_returns_none() {
3481 let instrument = InstrumentNameExchange::new("BTCUSDT");
3482 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3483 side: None,
3484 ..make_base_open_order()
3485 };
3486 assert!(
3487 convert_open_order(&o, &instrument).is_none(),
3488 "missing side should return None"
3489 );
3490 }
3491
3492 #[test]
3493 fn test_convert_open_order_missing_type_returns_none() {
3494 let instrument = InstrumentNameExchange::new("BTCUSDT");
3495 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3496 r#type: None,
3497 ..make_base_open_order()
3498 };
3499 assert!(
3500 convert_open_order(&o, &instrument).is_none(),
3501 "missing type should return None"
3502 );
3503 }
3504
3505 #[test]
3506 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
3507 let instrument = InstrumentNameExchange::new("BTCUSDT");
3508 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3509 executed_qty: None,
3510 ..make_base_open_order()
3511 };
3512 let order =
3513 convert_open_order(&o, &instrument).expect("None executedQty should still convert");
3514 assert_eq!(
3515 order.state.filled_quantity,
3516 Decimal::ZERO,
3517 "None executedQty should default to zero"
3518 );
3519 }
3520
3521 #[test]
3522 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
3523 let instrument = InstrumentNameExchange::new("BTCUSDT");
3524 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3525 executed_qty: Some("bad-value".to_string()),
3526 ..make_base_open_order()
3527 };
3528 let order = convert_open_order(&o, &instrument)
3529 .expect("unparseable executedQty should still convert");
3530 assert_eq!(
3531 order.state.filled_quantity,
3532 Decimal::ZERO,
3533 "unparseable executedQty should default to zero"
3534 );
3535 }
3536
3537 #[test]
3542 fn test_dedup_key_from_event_non_open_states_return_none() {
3543 use crate::order::state::{
3544 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
3545 };
3546 use rustrade_integration::collection::snapshot::Snapshot;
3547
3548 let key = OrderKey::new(
3549 ExchangeId::BinanceSpot,
3550 InstrumentNameExchange::new("BTCUSDT"),
3551 StrategyId::unknown(),
3552 ClientOrderId::new("cid1"),
3553 );
3554
3555 let event = UnindexedAccountEvent::new(
3557 ExchangeId::BinanceSpot,
3558 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
3559 key.clone(),
3560 Side::Buy,
3561 None, Decimal::ZERO,
3563 OrderKind::Market,
3564 TimeInForce::ImmediateOrCancel,
3565 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
3566 ActiveOrderState::OpenInFlight(OpenInFlight),
3567 ),
3568 ))),
3569 );
3570 assert!(
3571 dedup_key_from_event(&event).is_none(),
3572 "OpenInFlight should return None — dedup not meaningful before exchange ack"
3573 );
3574
3575 let event = UnindexedAccountEvent::new(
3577 ExchangeId::BinanceSpot,
3578 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
3579 key.clone(),
3580 Side::Buy,
3581 None, Decimal::ZERO,
3583 OrderKind::Market,
3584 TimeInForce::ImmediateOrCancel,
3585 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
3586 ActiveOrderState::CancelInFlight(CancelInFlight { order: None }),
3587 ),
3588 ))),
3589 );
3590 assert!(
3591 dedup_key_from_event(&event).is_none(),
3592 "CancelInFlight should return None"
3593 );
3594
3595 let event = UnindexedAccountEvent::new(
3597 ExchangeId::BinanceSpot,
3598 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
3599 key,
3600 Side::Buy,
3601 None, Decimal::ONE,
3603 OrderKind::Market,
3604 TimeInForce::ImmediateOrCancel,
3605 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
3606 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
3607 OrderId::new("123"),
3608 Utc::now(),
3609 Decimal::ONE, None,
3611 )),
3612 ),
3613 ))),
3614 );
3615 assert!(
3616 dedup_key_from_event(&event).is_none(),
3617 "Inactive state should return None"
3618 );
3619 }
3620
3621 #[test]
3622 fn test_dedup_key_from_event_cancelled_error_returns_none() {
3623 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3626 x: Some("REJECTED".to_string()),
3627 r: Some("INSUFFICIENT_FUNDS".to_string()),
3628 ..make_base_report()
3629 };
3630 let event = convert_execution_report(report).expect("REJECTED report should produce Some");
3631 assert!(
3632 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
3633 "prerequisite: event is OrderCancelled with Err"
3634 );
3635 assert!(
3636 dedup_key_from_event(&event).is_none(),
3637 "OrderCancelled with Err state should return None"
3638 );
3639 }
3640
3641 #[test]
3646 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
3647 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3648 x: Some("NEW".to_string()),
3649 s_uppercase: Some("BUY".to_string()),
3650 o: Some("LIMIT".to_string()),
3651 p: Some("50000.00".to_string()),
3652 q: Some("0.01".to_string()),
3653 f: Some("GTC".to_string()),
3654 z: Some("0".to_string()),
3655 ..make_base_report()
3656 };
3657 let mut buf = Vec::new();
3658 let terminated = convert_user_data_events(
3659 UserDataStreamEventsResponse::ExecutionReport(Box::new(report)),
3660 &mut buf,
3661 );
3662 assert!(
3663 !terminated,
3664 "ExecutionReport should not signal stream termination"
3665 );
3666 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
3667 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
3668 }
3669
3670 #[test]
3671 fn test_convert_user_data_events_account_position_pushes_to_buf() {
3672 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3673 u: Some(1_700_000_000_000),
3674 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
3675 ..Default::default()
3676 };
3677 let mut buf = Vec::new();
3678 let terminated = convert_user_data_events(
3679 UserDataStreamEventsResponse::OutboundAccountPosition(Box::new(position)),
3680 &mut buf,
3681 );
3682 assert!(
3683 !terminated,
3684 "OutboundAccountPosition should not signal stream termination"
3685 );
3686 assert_eq!(
3687 buf.len(),
3688 1,
3689 "OutboundAccountPosition should push one balance event"
3690 );
3691 }
3692
3693 #[test]
3694 fn test_convert_user_data_events_balance_update_ignored() {
3695 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
3696 ..Default::default()
3697 };
3698 let mut buf = Vec::new();
3699 let terminated = convert_user_data_events(
3700 UserDataStreamEventsResponse::BalanceUpdate(Box::new(update)),
3701 &mut buf,
3702 );
3703 assert!(
3704 !terminated,
3705 "BalanceUpdate should not signal stream termination"
3706 );
3707 assert!(buf.is_empty(), "BalanceUpdate should push no events");
3708 }
3709
3710 #[test]
3711 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
3712 let mut buf = Vec::new();
3713 let terminated = convert_user_data_events(
3714 UserDataStreamEventsResponse::EventStreamTerminated(Default::default()),
3715 &mut buf,
3716 );
3717 assert!(
3718 terminated,
3719 "EventStreamTerminated must signal stream termination"
3720 );
3721 assert!(
3722 buf.is_empty(),
3723 "EventStreamTerminated should push no events"
3724 );
3725 }
3726
3727 #[tokio::test]
3732 async fn test_rate_limit_tracker_not_blocked_initially() {
3733 tokio::time::pause();
3734 let tracker = RateLimitTracker::new();
3735 tokio::time::timeout(
3737 std::time::Duration::from_millis(1),
3738 tracker.wait_if_blocked(),
3739 )
3740 .await
3741 .expect("wait_if_blocked should return immediately when not blocked");
3742 }
3743
3744 #[tokio::test]
3745 async fn test_rate_limit_tracker_blocks_until_deadline() {
3746 tokio::time::pause();
3747 let tracker = RateLimitTracker::new();
3748 let delay = Duration::from_secs(5);
3749 tracker.on_rate_limited(Some(delay));
3750
3751 assert!(
3753 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
3754 .await
3755 .is_err(),
3756 "wait_if_blocked should block while cooldown is active"
3757 );
3758
3759 tokio::time::advance(delay + Duration::from_millis(1)).await;
3761 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
3762 .await
3763 .expect("wait_if_blocked should return after cooldown expires");
3764 }
3765
3766 #[tokio::test]
3767 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
3768 tokio::time::pause();
3769 let tracker = RateLimitTracker::new();
3770
3771 tracker.on_rate_limited(Some(Duration::from_secs(5)));
3773 tracker.on_rate_limited(Some(Duration::from_secs(10)));
3775
3776 tokio::time::advance(Duration::from_secs(6)).await;
3778 assert!(
3779 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
3780 .await
3781 .is_err(),
3782 "cooldown should have been extended to 10s"
3783 );
3784
3785 tokio::time::advance(Duration::from_secs(5)).await;
3787 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
3788 .await
3789 .expect("wait_if_blocked should return after extended cooldown expires");
3790 }
3791
3792 #[tokio::test]
3793 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
3794 tokio::time::pause();
3795 let tracker = RateLimitTracker::new();
3796
3797 tracker.on_rate_limited(Some(Duration::from_secs(10)));
3799 tracker.on_rate_limited(Some(Duration::from_secs(2)));
3800
3801 tokio::time::advance(Duration::from_secs(3)).await;
3803 assert!(
3804 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
3805 .await
3806 .is_err(),
3807 "shorter on_rate_limited must not shorten existing cooldown"
3808 );
3809 }
3810
3811 #[test]
3816 fn test_parse_binance_api_error_balance_insufficient_holds_instrument_name() {
3817 let instrument = InstrumentNameExchange::new("BTCUSDT");
3818 match parse_binance_api_error("Insufficient balance".into(), &instrument) {
3819 ApiError::BalanceInsufficient(asset_field, _) => {
3820 assert_eq!(
3825 asset_field.name().as_str(),
3826 "BTCUSDT",
3827 "BalanceInsufficient.0 holds the instrument name, not an asset name"
3828 );
3829 }
3830 other => panic!("expected BalanceInsufficient, got {other:?}"),
3831 }
3832 }
3833
3834 #[test]
3839 fn test_is_api_rejection_error_with_wrapped_error_chain() {
3840 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
3845 code: -2010,
3846 message: "insufficient balance".into(),
3847 });
3848 assert!(
3850 is_api_rejection_error(&raw),
3851 "unwrapped ResponseError at root must be detected"
3852 );
3853 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
3855 assert!(
3856 is_api_rejection_error(&wrapped),
3857 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
3858 );
3859 }
3860}