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 emit_stream_terminated,
44 error::{
45 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
46 UnindexedOrderError,
47 },
48 order::{
49 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
50 id::{ClientOrderId, OrderId, StrategyId},
51 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
52 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
53 },
54 parse_env_bool,
55 trade::{AssetFees, Trade, TradeId},
56};
57use binance_sdk::{
58 common::{
59 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
60 models::WebsocketEvent,
61 },
62 spot::{
63 SpotRestApi, SpotWsApi,
64 rest_api::{GetAccountParams, GetOpenOrdersParams, MyTradesParams, RestApi},
65 websocket_api::{
66 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
67 OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
68 WebsocketApiHandle,
69 },
70 },
71};
72use chrono::{DateTime, TimeZone, Utc};
73use futures::stream::BoxStream;
74use rust_decimal::Decimal;
75use rustrade_instrument::{
76 Side, asset::name::AssetNameExchange, exchange::ExchangeId,
77 instrument::name::InstrumentNameExchange,
78};
79use serde::Deserialize;
80use smol_str::format_smolstr;
81use std::{
82 str::FromStr,
83 sync::{
84 Arc,
85 atomic::{AtomicBool, Ordering},
86 },
87 time::Duration,
88};
89use tokio::sync::{RwLock, mpsc, oneshot};
90use tracing::{debug, error, info, trace, warn};
91
92#[derive(Clone, Deserialize)]
99pub struct BinanceSpotConfig {
100 api_key: String,
103 secret_key: String,
104
105 #[serde(default = "default_testnet")]
107 pub testnet: bool,
108}
109
110fn default_testnet() -> bool {
116 true
117}
118
119impl std::fmt::Debug for BinanceSpotConfig {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.debug_struct("BinanceSpotConfig")
123 .field("api_key", &"***")
124 .field("secret_key", &"***")
125 .field("testnet", &self.testnet)
126 .finish()
127 }
128}
129
130impl BinanceSpotConfig {
131 pub fn new(api_key: String, secret_key: String) -> Self {
134 Self::testnet(api_key, secret_key)
135 }
136
137 pub fn testnet(api_key: String, secret_key: String) -> Self {
139 Self {
140 api_key,
141 secret_key,
142 testnet: true,
143 }
144 }
145
146 pub fn production(api_key: String, secret_key: String) -> Self {
151 Self {
152 api_key,
153 secret_key,
154 testnet: false,
155 }
156 }
157
158 pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
177 let api_key = match std::env::var("BINANCE_API_KEY") {
178 Ok(value) => value,
179 Err(std::env::VarError::NotPresent) => {
180 return Err(BinanceSpotConfigError::MissingApiKey);
181 }
182 Err(std::env::VarError::NotUnicode(_)) => {
183 return Err(BinanceSpotConfigError::InvalidApiKey);
184 }
185 };
186 let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
187 Ok(value) => value,
188 Err(std::env::VarError::NotPresent) => {
189 return Err(BinanceSpotConfigError::MissingSecretKey);
190 }
191 Err(std::env::VarError::NotUnicode(_)) => {
192 return Err(BinanceSpotConfigError::InvalidSecretKey);
193 }
194 };
195 let testnet = match std::env::var("BINANCE_TESTNET") {
196 Ok(value) => {
197 parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
198 }
199 Err(std::env::VarError::NotPresent) => true,
200 Err(std::env::VarError::NotUnicode(value)) => {
203 return Err(BinanceSpotConfigError::InvalidTestnet(
204 value.to_string_lossy().into_owned(),
205 ));
206 }
207 };
208
209 if testnet {
210 Ok(Self::testnet(api_key, secret_key))
211 } else {
212 Ok(Self::production(api_key, secret_key))
213 }
214 }
215
216 pub fn api_key(&self) -> &str {
218 &self.api_key
219 }
220}
221
222#[derive(Debug, PartialEq, thiserror::Error)]
223pub enum BinanceSpotConfigError {
224 #[error("BINANCE_API_KEY environment variable not set")]
225 MissingApiKey,
226
227 #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
230 InvalidApiKey,
231
232 #[error("BINANCE_SECRET_KEY environment variable not set")]
233 MissingSecretKey,
234
235 #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
236 InvalidSecretKey,
237
238 #[error("BINANCE_TESTNET must be true or false, got {0}")]
239 InvalidTestnet(String),
240}
241
242#[derive(Clone)]
251pub struct BinanceSpot {
252 config: Arc<BinanceSpotConfig>,
253 rest: Arc<RestApi>,
254 ws_handle: WebsocketApiHandle,
256 ws_api: Arc<RwLock<Option<WebsocketApi>>>,
262 rate_limiter: Arc<RateLimitTracker>,
264}
265
266impl std::fmt::Debug for BinanceSpot {
267 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
268 f.debug_struct("BinanceSpot")
269 .field("testnet", &self.config.testnet)
270 .finish_non_exhaustive()
271 }
272}
273
274impl BinanceSpot {
275 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
279 let rest_config = ConfigurationRestApi::builder()
280 .api_key(config.api_key.clone())
281 .api_secret(config.secret_key.clone())
282 .build()
283 .expect("failed to build Binance REST configuration");
284
285 Arc::new(if config.testnet {
286 SpotRestApi::testnet(rest_config)
287 } else {
288 SpotRestApi::production(rest_config)
289 })
290 }
291
292 #[allow(clippy::expect_used)] fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
296 let ws_config = ConfigurationWebsocketApi::builder()
297 .api_key(config.api_key.clone())
298 .api_secret(config.secret_key.clone())
299 .build()
300 .expect("failed to build Binance WebSocket configuration");
301
302 if config.testnet {
303 SpotWsApi::testnet(ws_config)
304 } else {
305 SpotWsApi::production(ws_config)
306 }
307 }
308
309 async fn get_ws_api(&self) -> anyhow::Result<WebsocketApi> {
313 {
315 let guard = self.ws_api.read().await;
316 if let Some(ref ws) = *guard {
317 return Ok(ws.clone());
318 }
319 }
320 let mut guard = self.ws_api.write().await;
327 if let Some(ref ws) = *guard {
329 return Ok(ws.clone());
330 }
331 let ws = tokio::time::timeout(
332 Duration::from_secs(CONNECT_TIMEOUT_SECS),
333 self.ws_handle.connect(),
334 )
335 .await
336 .map_err(|_| {
337 anyhow::anyhow!("BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
338 })??;
339 *guard = Some(ws.clone());
340 Ok(ws)
341 }
342
343 async fn clear_ws_api(&self) {
346 let ws = {
357 let mut guard = self.ws_api.write().await;
358 guard.take()
359 };
360 if let Some(ws) = ws {
361 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
362 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
363 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
364 Ok(Ok(())) => {}
365 }
366 }
367 }
368}
369
370async fn fetch_open_orders_for_instrument(
377 rest: Arc<RestApi>,
378 rate_limiter: Arc<RateLimitTracker>,
379 instrument: InstrumentNameExchange,
380) -> Result<
381 (
382 InstrumentNameExchange,
383 Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
384 ),
385 UnindexedClientError,
386> {
387 let symbol_str = instrument.name().to_string();
389 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
390 let sym = symbol_str.clone();
391 Box::pin(async move {
392 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
393 rest.get_open_orders(params).await
394 })
395 })
396 .await
397 .map_err(connectivity_error)?;
398
399 let orders_data = response
400 .data()
401 .await
402 .map_err(|e| connectivity_error(e.into()))?;
403
404 let orders = orders_data
405 .into_iter()
406 .filter_map(|o| convert_open_order(&o, &instrument))
407 .collect();
408
409 Ok((instrument, orders))
410}
411
412async fn fetch_all_open_orders(
417 rest: Arc<RestApi>,
418 rate_limiter: Arc<RateLimitTracker>,
419) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
420 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
421 Box::pin(async move {
422 let params = GetOpenOrdersParams::builder().build()?;
423 rest.get_open_orders(params).await
424 })
425 })
426 .await
427 .map_err(connectivity_error)?;
428
429 let orders_data = response
430 .data()
431 .await
432 .map_err(|e| connectivity_error(e.into()))?;
433
434 let orders = orders_data
435 .into_iter()
436 .filter_map(|o| convert_open_order_owned_symbol(&o))
437 .collect();
438
439 Ok(orders)
440}
441
442async fn paginate_my_trades(
450 rest: &Arc<RestApi>,
451 rate_limiter: &Arc<RateLimitTracker>,
452 instrument: &InstrumentNameExchange,
453 start_time_ms: i64,
454) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
455 let symbol_str = instrument.name().to_string();
457 let mut all_pages = Vec::new();
462 let mut cursor: Option<i64> = None;
463 loop {
464 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, |rest| {
466 let sym = symbol_str.clone();
467 let stm = start_time_ms;
468 Box::pin(async move {
469 #[allow(clippy::cast_possible_truncation)]
471 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
472 let params = if let Some(id) = fid {
473 builder.from_id(id).build()?
474 } else {
475 builder.start_time(stm).build()?
476 };
477 rest.my_trades(params).await
478 })
479 })
480 .await
481 .map_err(connectivity_error)?;
482
483 let page = response
484 .data()
485 .await
486 .map_err(|e| connectivity_error(e.into()))?;
487
488 let page_len = page.len();
489 let last_id = page.last().and_then(|t| t.id);
490 all_pages.extend(page);
491
492 if page_len < BINANCE_MAX_TRADES {
493 break;
494 }
495 match last_id {
496 Some(id) => {
497 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
498 match id.checked_add(1) {
499 Some(next) => cursor = Some(next),
500 None => break, }
502 }
503 None => {
504 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
505 break;
506 }
507 }
508 }
509 Ok(all_pages)
510}
511
512impl ExecutionClient for BinanceSpot {
517 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
518 type Config = BinanceSpotConfig;
519 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
520
521 fn new(config: Self::Config) -> Self {
526 let rest = Self::build_rest(&config);
527 let ws_handle = Self::build_ws_handle(&config);
528 Self {
529 config: Arc::new(config),
530 rest,
531 ws_handle,
532 ws_api: Arc::new(RwLock::new(None)),
533 rate_limiter: Arc::new(RateLimitTracker::new()),
534 }
535 }
536
537 async fn account_snapshot(
538 &self,
539 assets: &[AssetNameExchange],
540 instruments: &[InstrumentNameExchange],
541 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
542 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
544 Box::pin(async move {
545 let params = GetAccountParams::builder().build()?;
546 rest.get_account(params).await
547 })
548 })
549 .await
550 .map_err(connectivity_error)?;
551
552 let account = response
553 .data()
554 .await
555 .map_err(|e| connectivity_error(e.into()))?;
556
557 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
559
560 use futures::{StreamExt as _, TryStreamExt};
566 let instrument_snapshots: Vec<_> =
567 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
568 fetch_open_orders_for_instrument(
569 self.rest.clone(),
570 self.rate_limiter.clone(),
571 instrument,
572 )
573 }))
574 .buffer_unordered(8)
575 .map(|result| {
576 let (inst, orders) = result?;
577 let wrapped = orders
578 .into_iter()
579 .map(|o| Order {
580 key: o.key,
581 side: o.side,
582 price: o.price,
583 quantity: o.quantity,
584 kind: o.kind,
585 time_in_force: o.time_in_force,
586 state: OrderState::active(o.state),
587 })
588 .collect();
589 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
590 inst, wrapped, None, None,
591 ))
592 })
593 .try_collect()
594 .await?;
595
596 Ok(AccountSnapshot::new(
597 ExchangeId::BinanceSpot,
598 balances,
599 instrument_snapshots,
600 ))
601 }
602
603 async fn account_stream(
619 &self,
620 _assets: &[AssetNameExchange],
624 instruments: &[InstrumentNameExchange],
625 ) -> Result<Self::AccountStream, UnindexedClientError> {
626 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
641 let dedup = new_dedup_cache();
642 let ws_handle = self.ws_handle.clone();
643 let rest = self.rest.clone();
644 let rate_limiter = self.rate_limiter.clone();
645 let instruments = instruments.to_vec();
646 debug_assert!(
650 instruments.iter().all(|i| i.name().len() <= 23),
651 "instrument name exceeds SmolStr inline capacity: {:?}",
652 instruments.iter().find(|i| i.name().len() > 23)
653 );
654
655 let initial_ws = ws_handle.connect().await.map_err(|e| {
659 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
660 })?;
661
662 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
664 .build()
665 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
666
667 match initial_ws
668 .user_data_stream_subscribe_signature(params)
669 .await
670 {
671 Ok(_) => {}
672 Err(e) => {
673 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
678 Ok(Err(de)) => {
679 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
680 }
681 Err(_) => {
682 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
683 }
684 Ok(Ok(())) => {}
685 }
686 return Err(UnindexedClientError::Internal(e.to_string()));
687 }
688 }
689
690 let cm_handle = tokio::spawn(connection_manager(
702 tx,
703 dedup,
704 ws_handle,
705 rest,
706 rate_limiter,
707 instruments,
708 Some(initial_ws),
709 ));
710
711 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
715 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
716 Ok(futures::StreamExt::boxed(guarded_stream))
717 }
718
719 async fn cancel_order(
720 &self,
721 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
722 ) -> Option<UnindexedOrderResponseCancel> {
723 let instrument = request.key.instrument.clone();
724 let key = OrderKey {
725 exchange: request.key.exchange,
726 instrument: instrument.clone(),
727 strategy: request.key.strategy.clone(),
728 cid: request.key.cid.clone(),
729 };
730
731 let ws = match self.get_ws_api().await {
732 Ok(ws) => ws,
733 Err(e) => {
734 return Some(UnindexedOrderResponseCancel {
735 key,
736 state: Err(UnindexedOrderError::Connectivity(
737 ConnectivityError::Socket(format!("{e:#}")),
738 )),
739 });
740 }
741 };
742
743 let mut params_builder =
746 OrderCancelParams::builder(request.key.instrument.name().to_string());
747
748 if let Some(ref order_id) = request.state.id {
750 if let Ok(id) = order_id.0.parse::<i64>() {
751 params_builder = params_builder.order_id(id);
752 } else {
753 error!(
758 order_id = %order_id.0,
759 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
760 );
761 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
762 }
763 } else {
764 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
765 }
766
767 let params = match params_builder.build() {
768 Ok(p) => p,
769 Err(e) => {
770 error!(%e, "BinanceSpot failed to build cancel order params");
771 return Some(UnindexedOrderResponseCancel {
772 key,
773 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
774 e.to_string(),
775 ))),
776 });
777 }
778 };
779
780 match ws.order_cancel(params).await {
781 Ok(response) => match response.data() {
782 Ok(data) => {
783 let time_exchange = data
784 .transact_time
785 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
786 .unwrap_or_else(Utc::now);
787
788 let exchange_order_id = match data.order_id {
789 Some(id) => OrderId(format_smolstr!("{id}")),
790 None => {
791 error!("BinanceSpot cancel response missing orderId");
792 return Some(UnindexedOrderResponseCancel {
793 key,
794 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
795 "cancel response missing orderId".into(),
796 ))),
797 });
798 }
799 };
800
801 let filled_qty = data
802 .executed_qty
803 .as_deref()
804 .and_then(|q| Decimal::from_str(q).ok())
805 .unwrap_or(Decimal::ZERO);
806
807 Some(UnindexedOrderResponseCancel {
808 key,
809 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
810 })
811 }
812 Err(e) => {
813 Some(UnindexedOrderResponseCancel {
815 key,
816 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
817 e.to_string(),
818 ))),
819 })
820 }
821 },
822 Err(e) => {
823 if is_api_rejection_error(&e) {
830 let api_err = parse_binance_api_error(e.to_string(), &instrument);
832 Some(UnindexedOrderResponseCancel {
837 key,
838 state: Err(UnindexedOrderError::from(api_err)),
839 })
840 } else if is_rate_limit_error(&e) {
841 self.rate_limiter.on_rate_limited(None);
843 Some(UnindexedOrderResponseCancel {
844 key,
845 state: Err(UnindexedOrderError::from(ApiError::RateLimit)),
846 })
847 } else {
848 self.clear_ws_api().await;
851 Some(UnindexedOrderResponseCancel {
852 key,
853 state: Err(UnindexedOrderError::Connectivity(
854 ConnectivityError::Socket(format!("{e:#}")),
855 )),
856 })
857 }
858 }
859 }
860 }
861
862 async fn open_order(
863 &self,
864 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
865 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
866 let instrument = request.key.instrument.clone();
867 let side = request.state.side;
868 let price = request.state.price;
869 let quantity = request.state.quantity;
870 let kind = request.state.kind;
871 let time_in_force = request.state.time_in_force;
872 let cid = request.key.cid.clone();
873
874 let order_key = OrderKey::new(
875 ExchangeId::BinanceSpot,
876 instrument.clone(),
877 request.key.strategy.clone(),
878 cid.clone(),
879 );
880
881 let ws = match self.get_ws_api().await {
882 Ok(ws) => ws,
883 Err(e) => {
884 return Some(Order {
885 key: order_key,
886 side,
887 price,
888 quantity,
889 kind,
890 time_in_force,
891 state: OrderState::inactive(OrderError::Connectivity(
892 ConnectivityError::Socket(format!("{e:#}")),
893 )),
894 });
895 }
896 };
897
898 let binance_side = match side {
899 Side::Buy => OrderPlaceSideEnum::Buy,
900 Side::Sell => OrderPlaceSideEnum::Sell,
901 };
902
903 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
904 Some(converted) => converted,
905 None => {
906 return Some(Order {
907 key: order_key,
908 side,
909 price,
910 quantity,
911 kind,
912 time_in_force,
913 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
914 "Binance Spot does not yet support OrderKind::{kind:?}"
915 ))),
916 });
917 }
918 };
919
920 let mut params_builder =
927 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
928 .quantity(quantity)
929 .new_client_order_id(cid.0.to_string());
930
931 match kind {
934 OrderKind::Limit => {
935 params_builder = params_builder.price(price);
936 }
937 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
938 params_builder = params_builder.stop_price(trigger_price);
939 }
940 OrderKind::StopLimit { trigger_price }
941 | OrderKind::TakeProfitLimit { trigger_price } => {
942 params_builder = params_builder.price(price).stop_price(trigger_price);
943 }
944 OrderKind::TrailingStop {
945 offset,
946 offset_type,
947 } => {
948 let basis_points: i32 = match offset_type {
950 TrailingOffsetType::BasisPoints => {
951 let Ok(bp) = i32::try_from(offset) else {
952 return Some(Order {
953 key: order_key,
954 side,
955 price,
956 quantity,
957 kind,
958 time_in_force,
959 state: OrderState::inactive(OrderError::UnsupportedOrderType(
960 format!(
961 "TrailingStop basis-point offset {offset} overflows i32 \
962 (Binance trailingDelta filter typically caps at 2000)"
963 ),
964 )),
965 });
966 };
967 bp
968 }
969 TrailingOffsetType::Percentage => {
970 let Ok(bp) = i32::try_from(offset * Decimal::from(100)) else {
971 return Some(Order {
972 key: order_key,
973 side,
974 price,
975 quantity,
976 kind,
977 time_in_force,
978 state: OrderState::inactive(OrderError::UnsupportedOrderType(
979 format!(
980 "TrailingStop percentage offset {offset} overflows i32 \
981 after scaling to basis points"
982 ),
983 )),
984 });
985 };
986 bp
987 }
988 TrailingOffsetType::Absolute => {
989 return Some(Order {
993 key: order_key,
994 side,
995 price,
996 quantity,
997 kind,
998 time_in_force,
999 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1000 "TrailingStop with Absolute offset is not supported by Binance; \
1001 convert to basis points: (absolute / price) * 10000"
1002 .into(),
1003 )),
1004 });
1005 }
1006 };
1007 params_builder = params_builder.trailing_delta(basis_points);
1008 }
1009 _ => {}
1013 }
1014
1015 if let Some(tif) = binance_tif {
1016 params_builder = params_builder.time_in_force(tif);
1017 }
1018
1019 let params = match params_builder.build() {
1020 Ok(p) => p,
1021 Err(e) => {
1022 error!(%e, "BinanceSpot failed to build new order params");
1023 return Some(Order {
1024 key: order_key,
1025 side,
1026 price,
1027 quantity,
1028 kind,
1029 time_in_force,
1030 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1031 e.to_string(),
1032 ))),
1033 });
1034 }
1035 };
1036
1037 match ws.order_place(params).await {
1038 Ok(response) => match response.data() {
1039 Ok(data) => {
1040 let time_exchange = data
1041 .transact_time
1042 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1043 .unwrap_or_else(Utc::now);
1044
1045 let exchange_order_id = match data.order_id {
1046 Some(id) => OrderId(format_smolstr!("{id}")),
1047 None => {
1048 error!("BinanceSpot open_order response missing orderId");
1049 return Some(Order {
1050 key: order_key,
1051 side,
1052 price,
1053 quantity,
1054 kind,
1055 time_in_force,
1056 state: OrderState::inactive(OrderError::Rejected(
1057 ApiError::OrderRejected(
1058 "open_order response missing orderId".into(),
1059 ),
1060 )),
1061 });
1062 }
1063 };
1064
1065 let filled_qty = data
1066 .executed_qty
1067 .as_deref()
1068 .and_then(|q| Decimal::from_str(q).ok())
1069 .unwrap_or(Decimal::ZERO);
1070
1071 let state = if filled_qty >= quantity {
1072 OrderState::fully_filled(Filled::new(
1074 exchange_order_id,
1075 time_exchange,
1076 filled_qty,
1077 None, ))
1079 } else {
1080 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1082 };
1083
1084 Some(Order {
1085 key: order_key,
1086 side,
1087 price,
1088 quantity,
1089 kind,
1090 time_in_force,
1091 state,
1092 })
1093 }
1094 Err(e) => {
1095 Some(Order {
1097 key: order_key,
1098 side,
1099 price,
1100 quantity,
1101 kind,
1102 time_in_force,
1103 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1104 e.to_string(),
1105 ))),
1106 })
1107 }
1108 },
1109 Err(e) => {
1110 if is_api_rejection_error(&e) {
1117 let api_err = parse_binance_api_error(e.to_string(), &instrument);
1119 Some(Order {
1124 key: order_key,
1125 side,
1126 price,
1127 quantity,
1128 kind,
1129 time_in_force,
1130 state: OrderState::inactive(OrderError::from(api_err)),
1131 })
1132 } else if is_rate_limit_error(&e) {
1133 self.rate_limiter.on_rate_limited(None);
1135 Some(Order {
1136 key: order_key,
1137 side,
1138 price,
1139 quantity,
1140 kind,
1141 time_in_force,
1142 state: OrderState::inactive(OrderError::from(ApiError::RateLimit)),
1143 })
1144 } else {
1145 self.clear_ws_api().await;
1148 Some(Order {
1149 key: order_key,
1150 side,
1151 price,
1152 quantity,
1153 kind,
1154 time_in_force,
1155 state: OrderState::inactive(OrderError::Connectivity(
1156 ConnectivityError::Socket(format!("{e:#}")),
1157 )),
1158 })
1159 }
1160 }
1161 }
1162 }
1163
1164 async fn fetch_balances(
1165 &self,
1166 assets: &[AssetNameExchange],
1167 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1168 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
1169 Box::pin(async move {
1170 let params = GetAccountParams::builder().build()?;
1171 rest.get_account(params).await
1172 })
1173 })
1174 .await
1175 .map_err(connectivity_error)?;
1176
1177 let account = response
1178 .data()
1179 .await
1180 .map_err(|e| connectivity_error(e.into()))?;
1181
1182 Ok(filter_and_convert_balances(
1183 account.balances.unwrap_or_default(),
1184 assets,
1185 ))
1186 }
1187
1188 async fn fetch_open_orders(
1193 &self,
1194 instruments: &[InstrumentNameExchange],
1195 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1196 if instruments.is_empty() {
1199 return fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await;
1200 }
1201 use futures::{StreamExt as _, TryStreamExt as _};
1206 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1207 fetch_open_orders_for_instrument(
1208 self.rest.clone(),
1209 self.rate_limiter.clone(),
1210 instrument,
1211 )
1212 }))
1213 .buffer_unordered(8)
1214 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, orders)| async move {
1215 acc.extend(orders);
1216 Ok(acc)
1217 })
1218 .await
1219 }
1220
1221 #[allow(clippy::redundant_iter_cloned)]
1229 async fn fetch_trades(
1230 &self,
1231 time_since: DateTime<Utc>,
1232 instruments: &[InstrumentNameExchange],
1233 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1234 use futures::StreamExt;
1235
1236 if instruments.is_empty() {
1237 debug!(
1238 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1239 );
1240 return Ok(Vec::new());
1241 }
1242 let start_time_ms = time_since.timestamp_millis();
1243 let mut all_trades = Vec::new();
1246
1247 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1251 let rest = self.rest.clone();
1252 let rate_limiter = self.rate_limiter.clone();
1253 async move {
1254 let pages = paginate_my_trades(&rest, &rate_limiter, &inst, start_time_ms).await?;
1255 Ok::<_, UnindexedClientError>((inst, pages))
1256 }
1257 }))
1258 .buffer_unordered(8);
1259 while let Some(result) = stream.next().await {
1260 let (instrument, trades_data) = result?;
1261 for t in trades_data {
1262 if let Some(trade) = convert_my_trade(&t, &instrument) {
1263 all_trades.push(trade);
1264 }
1265 }
1266 }
1267
1268 Ok(all_trades)
1269 }
1270}
1271
1272async fn connect_and_subscribe(ws_handle: &WebsocketApiHandle) -> anyhow::Result<WebsocketApi> {
1279 let ws = ws_handle.connect().await?;
1280 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1282 .build()
1283 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1284 match ws.user_data_stream_subscribe_signature(params).await {
1285 Ok(_) => Ok(ws),
1286 Err(e) => {
1287 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1288 let ws_cleanup = ws;
1289 tokio::spawn(async move {
1299 match tokio::time::timeout(Duration::from_secs(5), ws_cleanup.disconnect()).await {
1300 Ok(Err(dc_err)) => warn!(%dc_err, "BinanceSpot WS cleanup disconnect failed"),
1301 Err(_) => warn!("BinanceSpot WS cleanup disconnect timed out (5s)"),
1302 Ok(Ok(())) => {}
1303 }
1304 });
1305 Err(e)
1306 }
1307 }
1308}
1309
1310#[allow(clippy::cognitive_complexity)]
1332async fn connection_manager(
1333 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1334 dedup: SharedDedupCache,
1335 ws_handle: WebsocketApiHandle,
1336 rest: Arc<RestApi>,
1337 rate_limiter: Arc<RateLimitTracker>,
1338 instruments: Vec<InstrumentNameExchange>,
1339 initial_ws: Option<WebsocketApi>,
1340) {
1341 let mut backoff = ExponentialBackoff::new();
1342 let mut disconnect_time: Option<DateTime<Utc>> = None;
1343 let mut current_ws = initial_ws;
1344
1345 loop {
1346 let ws = match current_ws.take() {
1348 Some(ws) => ws,
1349 None => match connect_and_subscribe(&ws_handle).await {
1350 Ok(ws) => ws,
1356 Err(e) => {
1357 error!(%e, "BinanceSpot WS connect/subscribe failed");
1358 if !backoff.wait().await {
1359 error!("BinanceSpot max reconnect attempts exhausted");
1360 emit_stream_terminated(
1362 &tx,
1363 ExchangeId::BinanceSpot,
1364 StreamTerminationReason::ReconnectBudgetExhausted {
1365 attempts: backoff.attempts(),
1366 last_error: e.to_string(),
1367 },
1368 );
1369 break;
1370 }
1371 continue;
1372 }
1373 },
1374 };
1375 info!("BinanceSpot account_stream connected and subscribed");
1376
1377 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1383 let mut signal_tx_opt = Some(signal_tx);
1384 let heartbeat_flag = Arc::new(AtomicBool::new(true));
1388 let hb_callback = heartbeat_flag.clone();
1389 let dedup_callback = dedup.clone();
1390 let mut event_tx = Some(tx.clone());
1394 let mut event_buf = Vec::with_capacity(32);
1398
1399 let subscription = ws.subscribe_on_ws_events(move |event| {
1406 let Some(ref sender) = event_tx else { return };
1407 match event {
1408 WebsocketEvent::Message(json_str) => {
1409 hb_callback.store(true, Ordering::Release);
1412 let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1416 for ev in event_buf.drain(..) {
1417 if let Some(key) = dedup_key_from_event(&ev)
1419 && is_duplicate(&dedup_callback, key)
1420 {
1421 trace!("BinanceSpot dedup: skipping duplicate event");
1422 continue;
1423 }
1424 if sender.send(ev).is_err() {
1425 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1426 event_tx.take();
1427 if let Some(s) = signal_tx_opt.take() {
1428 let _ = s.send(());
1429 }
1430 return;
1431 }
1432 }
1433 if stream_terminated {
1436 event_tx.take();
1437 if let Some(s) = signal_tx_opt.take() {
1438 let _ = s.send(());
1439 }
1440 }
1441 }
1442 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1443 hb_callback.store(true, Ordering::Release);
1446 }
1447 WebsocketEvent::Error(e) => {
1448 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1452 event_tx.take();
1453 if let Some(s) = signal_tx_opt.take() {
1454 let _ = s.send(());
1455 }
1456 }
1457 WebsocketEvent::Close(code, reason) => {
1458 warn!(code, %reason, "BinanceSpot WebSocket closed");
1459 event_tx.take();
1460 if let Some(s) = signal_tx_opt.take() {
1461 let _ = s.send(());
1462 }
1463 }
1464 _ => {}
1465 }
1466 });
1467
1468 if let Some(dt) = disconnect_time.take() {
1477 match tokio::time::timeout(
1478 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
1479 recover_fills(&rest, &rate_limiter, &instruments, dt, &tx, &dedup),
1480 )
1481 .await
1482 {
1483 Ok(()) => {}
1484 Err(_) => {
1485 warn!(
1490 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
1491 "BinanceSpot fill recovery timed out — remaining instruments not queried, some fills may be missing"
1492 );
1493 }
1494 }
1495 }
1496
1497 enum DisconnectReason {
1499 Signal,
1500 HeartbeatTimeout,
1501 ConsumerDropped,
1502 }
1503 let reason = {
1504 let mut signal_rx = signal_rx;
1505 loop {
1506 tokio::select! {
1507 _ = tx.closed() => {
1508 debug!("BinanceSpot account_stream consumer dropped, terminating");
1509 break DisconnectReason::ConsumerDropped;
1510 }
1511 _ = &mut signal_rx => {
1512 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1513 break DisconnectReason::Signal;
1514 }
1515 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1516 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1523 backoff.reset();
1527 continue;
1528 }
1529 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1530 break DisconnectReason::HeartbeatTimeout;
1531 }
1532 }
1533 }
1534 };
1535 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1536
1537 if should_reconnect {
1544 disconnect_time = Some(match reason {
1545 DisconnectReason::HeartbeatTimeout => {
1546 Utc::now()
1547 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1548 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1549 }
1550 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1551 });
1552 }
1553
1554 subscription.unsubscribe();
1562 if let Err(e) = ws.disconnect().await {
1565 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1566 }
1567
1568 if !should_reconnect || tx.is_closed() {
1569 debug!("BinanceSpot connection manager exiting");
1572 break;
1573 }
1574 if !backoff.wait().await {
1575 error!("BinanceSpot max reconnect attempts exhausted, stream terminating");
1576 let last_error = match reason {
1579 DisconnectReason::HeartbeatTimeout => {
1580 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
1581 }
1582 _ => "WebSocket disconnected (server close/error)".to_string(),
1583 };
1584 emit_stream_terminated(
1585 &tx,
1586 ExchangeId::BinanceSpot,
1587 StreamTerminationReason::ReconnectBudgetExhausted {
1588 attempts: backoff.attempts(),
1589 last_error,
1590 },
1591 );
1592 break;
1593 }
1594 }
1595}
1596
1597#[allow(clippy::redundant_iter_cloned)]
1606async fn recover_fills(
1607 rest: &Arc<RestApi>,
1608 rate_limiter: &Arc<RateLimitTracker>,
1609 instruments: &[InstrumentNameExchange],
1610 disconnect_time: DateTime<Utc>,
1611 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1612 dedup: &SharedDedupCache,
1613) {
1614 use futures::StreamExt;
1615
1616 if instruments.is_empty() {
1617 debug!(
1618 "BinanceSpot recover_fills called with empty instruments slice — no fills will be recovered"
1619 );
1620 return;
1621 }
1622 info!(
1623 since = %disconnect_time,
1624 instruments = instruments.len(),
1625 "BinanceSpot recovering fills after reconnect"
1626 );
1627
1628 let start_time_ms = disconnect_time.timestamp_millis();
1629 let mut recovered = 0u32;
1630 let mut duplicates = 0u32;
1631 let mut failed_instruments = 0u32;
1632
1633 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1640 let rest = rest.clone();
1641 let rl = rate_limiter.clone();
1642 async move {
1643 let raw = match paginate_my_trades(&rest, &rl, &inst, start_time_ms).await {
1644 Ok(pages) => pages,
1645 Err(e) => {
1646 warn!(%e, %inst, "BinanceSpot fill recovery: REST request failed");
1647 return None;
1648 }
1649 };
1650 let trades: Vec<_> = raw
1651 .into_iter()
1652 .filter_map(|t| convert_my_trade(&t, &inst))
1653 .collect();
1654 Some(trades)
1655 }
1656 }))
1657 .buffer_unordered(8);
1658 while let Some(result) = stream.next().await {
1659 let trades = match result {
1660 Some(t) => t,
1661 None => {
1662 failed_instruments += 1;
1663 continue;
1664 }
1665 };
1666 for trade in trades {
1667 let event =
1670 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
1671 if let Some(key) = dedup_key_from_event(&event)
1674 && is_duplicate(dedup, key)
1675 {
1676 duplicates += 1;
1677 continue;
1678 }
1679 if tx.send(event).is_err() {
1680 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
1684 return;
1685 }
1686 recovered += 1;
1687 }
1688 }
1689
1690 if failed_instruments > 0 {
1691 error!(
1692 recovered,
1693 duplicates,
1694 failed_instruments,
1695 "BinanceSpot fill recovery complete with failures — some fills may be permanently missed"
1696 );
1697 } else {
1698 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
1699 }
1700}
1701
1702fn convert_balance_entry(
1716 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
1717 now: chrono::DateTime<Utc>,
1718) -> Option<AssetBalance<AssetNameExchange>> {
1719 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
1720 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1721 Some(v) => v,
1722 None => {
1723 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
1724 return None;
1725 }
1726 };
1727 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1728 Some(v) => v,
1729 None => {
1730 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
1731 return None;
1732 }
1733 };
1734 Some(AssetBalance::new(
1735 asset_name,
1736 Balance::new(free + locked, free),
1737 now,
1738 ))
1739}
1740
1741fn filter_and_convert_balances(
1742 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
1743 assets: &[AssetNameExchange],
1744) -> Vec<AssetBalance<AssetNameExchange>> {
1745 let now = Utc::now();
1746 if assets.is_empty() {
1748 return balances
1749 .into_iter()
1750 .filter_map(|b| convert_balance_entry(b, now))
1751 .collect();
1752 }
1753 if assets.len() <= 16 {
1756 return balances
1757 .into_iter()
1758 .filter_map(|b| {
1759 let asset_name_str = b.asset.as_deref()?;
1760 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
1761 return None;
1762 }
1763 convert_balance_entry(b, now)
1764 })
1765 .collect();
1766 }
1767 use std::collections::HashSet;
1768 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
1769 balances
1770 .into_iter()
1771 .filter_map(|b| {
1772 let asset_name_str = b.asset.as_deref()?;
1773 if !asset_set.contains(asset_name_str) {
1774 return None;
1775 }
1776 convert_balance_entry(b, now)
1777 })
1778 .collect()
1779}
1780
1781fn convert_open_order(
1786 o: &binance_sdk::spot::rest_api::AllOrdersResponseInner,
1787 instrument: &InstrumentNameExchange,
1788) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
1789 let order_id_raw = match o.order_id {
1790 Some(id) => id,
1791 None => {
1792 warn!(%instrument, "BinanceSpot open order missing orderId");
1793 return None;
1794 }
1795 };
1796 let order_id = OrderId(format_smolstr!("{}", order_id_raw));
1797 if o.client_order_id.is_none() {
1798 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing clientOrderId, using orderId as fallback — order may not reconcile with engine state");
1799 }
1800 let cid = ClientOrderId::new(
1801 o.client_order_id
1802 .as_deref()
1803 .unwrap_or(&format_smolstr!("{}", order_id_raw)),
1804 );
1805 let side = match o.side.as_deref() {
1806 Some(s) => parse_side(s)?,
1808 None => {
1809 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing side");
1810 return None;
1811 }
1812 };
1813 let price = o.price.as_deref().and_then(|s| Decimal::from_str(s).ok());
1814 let quantity = match o
1815 .orig_qty
1816 .as_deref()
1817 .and_then(|s| Decimal::from_str(s).ok())
1818 {
1819 Some(v) => v,
1820 None => {
1821 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing/unparseable origQty");
1822 return None;
1823 }
1824 };
1825 let filled_qty = match o.executed_qty.as_deref() {
1826 Some(s) => match Decimal::from_str(s) {
1827 Ok(v) => v,
1828 Err(_) => {
1829 warn!(%instrument, order_id = %order_id_raw, executed_qty = s, "BinanceSpot open order unparseable executedQty, defaulting to 0");
1830 Decimal::ZERO
1831 }
1832 },
1833 None => Decimal::ZERO,
1834 };
1835 let kind = match o.r#type.as_deref() {
1836 Some(t) => parse_order_kind(t)?,
1838 None => {
1839 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing type");
1840 return None;
1841 }
1842 };
1843 let time_in_force = parse_time_in_force(o.time_in_force.as_deref().unwrap_or("GTC"));
1844 let time_exchange = match o.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
1845 Some(ts) => ts,
1846 None => {
1847 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing/unparseable time, using now");
1848 Utc::now()
1849 }
1850 };
1851
1852 Some(Order {
1853 key: OrderKey::new(
1854 ExchangeId::BinanceSpot,
1855 instrument.clone(),
1856 StrategyId::unknown(),
1859 cid,
1860 ),
1861 side,
1862 price,
1863 quantity,
1864 kind,
1865 time_in_force,
1866 state: Open::new(order_id, time_exchange, filled_qty),
1867 })
1868}
1869
1870fn convert_open_order_owned_symbol(
1875 o: &binance_sdk::spot::rest_api::AllOrdersResponseInner,
1876) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
1877 let instrument = match o.symbol.as_deref() {
1878 Some(s) => InstrumentNameExchange::new(s),
1879 None => {
1880 warn!("BinanceSpot open order missing symbol in return-all query, dropping order");
1881 return None;
1882 }
1883 };
1884 convert_open_order(o, &instrument)
1885}
1886
1887fn convert_my_trade(
1889 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
1890 instrument: &InstrumentNameExchange,
1891) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
1892 let trade_id_raw = match t.id {
1893 Some(id) => id,
1894 None => {
1895 warn!(%instrument, "BinanceSpot trade missing id");
1896 return None;
1897 }
1898 };
1899 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
1900 let order_id = match t.order_id {
1901 Some(id) => OrderId(format_smolstr!("{}", id)),
1902 None => {
1903 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
1904 return None;
1905 }
1906 };
1907 let side = match t.is_buyer {
1908 Some(is_buyer) => {
1909 if is_buyer {
1910 Side::Buy
1911 } else {
1912 Side::Sell
1913 }
1914 }
1915 None => {
1916 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
1917 return None;
1918 }
1919 };
1920 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1921 Some(v) => v,
1922 None => {
1923 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
1924 return None;
1925 }
1926 };
1927 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1928 Some(v) => v,
1929 None => {
1930 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
1931 return None;
1932 }
1933 };
1934 let commission = t
1935 .commission
1936 .as_deref()
1937 .and_then(|s| Decimal::from_str(s).ok())
1938 .unwrap_or(Decimal::ZERO);
1939 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
1940 Some(ts) => ts,
1941 None => {
1942 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
1943 Utc::now()
1944 }
1945 };
1946
1947 let fee_asset = t
1952 .commission_asset
1953 .as_deref()
1954 .map(AssetNameExchange::from)
1955 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
1956
1957 Some(Trade::new(
1958 trade_id,
1959 order_id,
1960 instrument.clone(),
1961 StrategyId::unknown(), time_exchange,
1963 side,
1964 price,
1965 quantity,
1966 AssetFees::new(fee_asset, commission, None),
1967 ))
1968}
1969
1970fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1987 #[derive(Deserialize)]
1992 struct EventTag<'a> {
1993 #[serde(borrow, default)]
1994 e: Option<&'a str>,
1995 }
1996
1997 let event_type = serde_json::from_str::<EventTag<'_>>(frame)
1998 .ok()
1999 .and_then(|tag| tag.e)
2000 .unwrap_or_default();
2001 match event_type {
2002 "executionReport" => {
2003 match serde_json::from_str::<binance_sdk::spot::websocket_api::ExecutionReport>(frame) {
2006 Ok(report) => {
2007 if let Some(ev) = convert_execution_report(report) {
2008 buf.push(ev);
2009 }
2010 }
2011 Err(e) => {
2012 warn!(error = %e, "BinanceSpot: undeserializable executionReport, dropping")
2013 }
2014 }
2015 false
2016 }
2017 "outboundAccountPosition" => {
2018 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2019 frame,
2020 ) {
2021 Ok(position) => convert_account_position(position, buf),
2022 Err(e) => {
2023 warn!(error = %e, "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2024 }
2025 }
2026 false
2027 }
2028 "balanceUpdate" => {
2029 false
2036 }
2037 "eventStreamTerminated" => {
2038 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2042 true
2043 }
2044 _ => {
2047 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2048 false
2049 }
2050 }
2051}
2052
2053#[allow(clippy::cognitive_complexity)]
2064fn convert_execution_report(
2065 report: binance_sdk::spot::websocket_api::ExecutionReport,
2066) -> Option<UnindexedAccountEvent> {
2067 let exec_type = match report.x.as_deref() {
2068 Some(t) => t,
2069 None => {
2070 warn!("BinanceSpot executionReport missing execution type (x), dropping");
2071 return None;
2072 }
2073 };
2074 let symbol = match report.s.as_deref() {
2075 Some(s) => InstrumentNameExchange::new(s),
2076 None => {
2077 warn!("BinanceSpot executionReport missing symbol (s), dropping");
2078 return None;
2079 }
2080 };
2081 let order_id = match report.i {
2084 Some(id) => OrderId(format_smolstr!("{id}")),
2085 None => {
2086 warn!(%symbol, "BinanceSpot executionReport missing orderId (i), dropping");
2087 return None;
2088 }
2089 };
2090 let cid = match report.c.as_deref() {
2091 Some(c) => ClientOrderId::new(c),
2092 None => ClientOrderId::new(order_id.0.as_str()),
2093 };
2094
2095 let time_exchange = match report
2097 .t_uppercase
2098 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2099 {
2100 Some(t) => t,
2101 None => {
2102 warn!(%symbol, "BinanceSpot executionReport missing/unparseable transaction time (T), using now");
2103 Utc::now()
2104 }
2105 };
2106
2107 match exec_type {
2108 "NEW" => convert_new_order(&report, symbol, cid, order_id, time_exchange),
2109 "TRADE" => {
2110 let trade_id = match report.t {
2112 Some(id) => TradeId(format_smolstr!("{id}")),
2113 None => {
2114 warn!(%symbol, "BinanceSpot TRADE event missing trade ID (t), dropping");
2115 return None;
2116 }
2117 };
2118 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
2119 Some(s) => s,
2120 None => {
2121 warn!(%symbol, "BinanceSpot TRADE event missing/unknown side (S), dropping");
2122 return None;
2123 }
2124 };
2125 let last_price = match report.l_uppercase.as_deref() {
2126 Some(s) => match Decimal::from_str(s) {
2127 Ok(v) => v,
2128 Err(e) => {
2129 warn!(%symbol, error = %e, raw = s, "BinanceSpot TRADE event unparseable last price (L), dropping fill");
2130 return None;
2131 }
2132 },
2133 None => {
2134 warn!(%symbol, "BinanceSpot TRADE event missing last price (L), dropping fill");
2135 return None;
2136 }
2137 };
2138 let last_qty = match report.l.as_deref() {
2139 Some(s) => match Decimal::from_str(s) {
2140 Ok(v) => v,
2141 Err(e) => {
2142 warn!(%symbol, error = %e, raw = s, "BinanceSpot TRADE event unparseable last qty (l), dropping fill");
2143 return None;
2144 }
2145 },
2146 None => {
2147 warn!(%symbol, "BinanceSpot TRADE event missing last qty (l), dropping fill");
2148 return None;
2149 }
2150 };
2151 let commission = match Decimal::from_str(report.n.as_deref().unwrap_or("0")) {
2153 Ok(v) => v,
2154 Err(e) => {
2155 warn!(%symbol, error = %e, "BinanceSpot TRADE event unparseable commission (n), defaulting to 0");
2156 Decimal::ZERO
2157 }
2158 };
2159
2160 let fee_asset = report
2164 .n_uppercase
2165 .as_deref()
2166 .map(AssetNameExchange::from)
2167 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2168
2169 let trade = Trade::new(
2170 trade_id,
2171 order_id,
2172 symbol,
2173 StrategyId::unknown(), time_exchange,
2175 side,
2176 last_price,
2177 last_qty,
2178 AssetFees::new(fee_asset, commission, None),
2179 );
2180
2181 Some(UnindexedAccountEvent::new(
2182 ExchangeId::BinanceSpot,
2183 AccountEventKind::Trade(trade),
2184 ))
2185 }
2186 "CANCELED" | "EXPIRED" | "EXPIRED_IN_MATCH" => {
2187 let filled_qty = report
2188 .z
2189 .as_deref()
2190 .and_then(|s| Decimal::from_str(s).ok())
2191 .unwrap_or(Decimal::ZERO);
2192 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
2193 let response = UnindexedOrderResponseCancel {
2194 key: OrderKey::new(
2195 ExchangeId::BinanceSpot,
2196 symbol,
2197 StrategyId::unknown(), cid,
2199 ),
2200 state: Ok(cancelled),
2201 };
2202
2203 Some(UnindexedAccountEvent::new(
2204 ExchangeId::BinanceSpot,
2205 AccountEventKind::OrderCancelled(response),
2206 ))
2207 }
2208 "REJECTED" => {
2209 let reject_reason = report.r.unwrap_or_else(|| "unknown".to_string());
2213 warn!(
2214 %symbol, %order_id, reason = %reject_reason,
2215 "BinanceSpot order REJECTED by matching engine"
2216 );
2217 let response = UnindexedOrderResponseCancel {
2218 key: OrderKey::new(ExchangeId::BinanceSpot, symbol, StrategyId::unknown(), cid),
2219 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
2220 reject_reason,
2221 ))),
2222 };
2223
2224 Some(UnindexedAccountEvent::new(
2225 ExchangeId::BinanceSpot,
2226 AccountEventKind::OrderCancelled(response),
2227 ))
2228 }
2229 "REPLACE" => {
2230 let filled_qty = report
2237 .z
2238 .as_deref()
2239 .and_then(|s| Decimal::from_str(s).ok())
2240 .unwrap_or(Decimal::ZERO);
2241 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
2242 let response = UnindexedOrderResponseCancel {
2243 key: OrderKey::new(ExchangeId::BinanceSpot, symbol, StrategyId::unknown(), cid),
2244 state: Ok(cancelled),
2245 };
2246 Some(UnindexedAccountEvent::new(
2247 ExchangeId::BinanceSpot,
2248 AccountEventKind::OrderCancelled(response),
2249 ))
2250 }
2251 _ => {
2252 trace!(exec_type, "BinanceSpot ignoring execution type");
2255 None
2256 }
2257 }
2258}
2259
2260fn convert_new_order(
2262 report: &binance_sdk::spot::websocket_api::ExecutionReport,
2263 symbol: InstrumentNameExchange,
2264 cid: ClientOrderId,
2265 order_id: OrderId,
2266 time_exchange: DateTime<Utc>,
2267) -> Option<UnindexedAccountEvent> {
2268 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
2269 Some(s) => s,
2270 None => {
2271 warn!(%symbol, "BinanceSpot NEW event missing/unknown side (S), dropping");
2272 return None;
2273 }
2274 };
2275 let kind = parse_order_kind(report.o.as_deref().unwrap_or("LIMIT"))?;
2276 let price: Option<Decimal> = match (report.p.as_deref(), &kind) {
2277 (Some(p), _) => match Decimal::from_str(p) {
2278 Ok(v) if !v.is_zero() => Some(v),
2279 Ok(_) => {
2280 if matches!(
2284 kind,
2285 OrderKind::Limit
2286 | OrderKind::StopLimit { .. }
2287 | OrderKind::TakeProfitLimit { .. }
2288 | OrderKind::TrailingStopLimit { .. }
2289 ) {
2290 trace!(%symbol, %kind, "BinanceSpot NEW event has zero price (p) on limit-type order, treating as no limit price");
2291 }
2292 None
2293 }
2294 Err(e) => {
2295 warn!(%symbol, price = p, error = %e, "BinanceSpot NEW event unparseable price (p), dropping");
2296 return None;
2297 }
2298 },
2299 (
2300 None,
2301 OrderKind::Market
2302 | OrderKind::Stop { .. }
2303 | OrderKind::TakeProfit { .. }
2304 | OrderKind::TrailingStop { .. },
2305 ) => {
2306 None
2308 }
2309 (
2310 None,
2311 OrderKind::Limit
2312 | OrderKind::StopLimit { .. }
2313 | OrderKind::TakeProfitLimit { .. }
2314 | OrderKind::TrailingStopLimit { .. },
2315 ) => {
2316 warn!(%symbol, "BinanceSpot NEW limit-type order missing price (p), dropping");
2317 return None;
2318 }
2319 };
2320 let quantity = match report.q.as_deref() {
2321 Some(q) => match Decimal::from_str(q) {
2322 Ok(v) => v,
2323 Err(e) => {
2324 warn!(%symbol, qty = q, error = %e, "BinanceSpot NEW event unparseable quantity (q), dropping");
2325 return None;
2326 }
2327 },
2328 None => {
2329 warn!(%symbol, "BinanceSpot NEW order missing quantity (q), dropping");
2330 return None;
2331 }
2332 };
2333 let time_in_force = parse_time_in_force(report.f.as_deref().unwrap_or("GTC"));
2334 let filled_qty = report
2337 .z
2338 .as_deref()
2339 .and_then(|s| Decimal::from_str(s).ok())
2340 .unwrap_or(Decimal::ZERO);
2341
2342 let order = Order {
2343 key: OrderKey::new(
2344 ExchangeId::BinanceSpot,
2345 symbol,
2346 StrategyId::unknown(), cid,
2348 ),
2349 side,
2350 price,
2351 quantity,
2352 kind,
2353 time_in_force,
2354 state: OrderState::active(Open::new(order_id, time_exchange, filled_qty)),
2355 };
2356
2357 Some(UnindexedAccountEvent::new(
2358 ExchangeId::BinanceSpot,
2359 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot::new(
2360 order,
2361 )),
2362 ))
2363}
2364
2365fn convert_account_position(
2370 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2371 buf: &mut Vec<UnindexedAccountEvent>,
2372) {
2373 let time_exchange = position
2376 .u
2377 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2378 .unwrap_or_else(Utc::now);
2379
2380 for b in position.b_uppercase.unwrap_or_default() {
2381 let asset = match b.a {
2382 Some(a) => AssetNameExchange::new(a),
2383 None => {
2384 warn!("BinanceSpot account position entry missing asset name");
2385 continue;
2386 }
2387 };
2388 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2389 Some(v) => v,
2390 None => {
2391 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2392 continue;
2393 }
2394 };
2395 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2396 Some(v) => v,
2397 None => {
2398 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2399 continue;
2400 }
2401 };
2402 let update =
2407 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2408 buf.push(UnindexedAccountEvent::new(
2409 ExchangeId::BinanceSpot,
2410 AccountEventKind::BalanceStreamUpdate(
2411 rustrade_integration::collection::snapshot::Snapshot::new(update),
2412 ),
2413 ));
2414 }
2415}
2416
2417fn convert_order_kind_tif(
2444 kind: OrderKind,
2445 tif: TimeInForce,
2446) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2447 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2451 let type_enum = match binance_type {
2452 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2453 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2454 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2455 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2456 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2457 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2458 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2459 };
2460 let tif_enum = binance_tif.map(|t| match t {
2461 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2462 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2463 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2464 });
2465 Some((type_enum, tif_enum))
2466}
2467
2468#[cfg(test)]
2469#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2471 use super::*;
2472 use crate::client::binance::shared::*;
2473 use crate::order::TrailingOffsetType;
2474 use binance_sdk::common::errors::WebsocketError;
2475 use smol_str::SmolStr;
2476
2477 #[test]
2478 fn test_binance_spot_config_new_uses_testnet_by_default() {
2479 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2480 assert!(cfg.testnet);
2481 assert_eq!(cfg.api_key(), "my_key");
2482 }
2483
2484 #[test]
2485 fn test_binance_spot_config_production_is_explicit() {
2486 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2487 assert!(!cfg.testnet);
2488 }
2489
2490 #[test]
2491 fn test_binance_spot_config_debug_redacts_credentials() {
2492 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2493 let debug = format!("{cfg:?}");
2494
2495 assert!(!debug.contains("my_key"), "api_key should be redacted");
2496 assert!(
2497 !debug.contains("my_secret"),
2498 "secret_key should be redacted"
2499 );
2500 assert!(debug.contains("testnet: true"));
2501 }
2502
2503 #[test]
2504 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2505 let cfg: BinanceSpotConfig = serde_json::from_str(
2506 r#"{
2507 "api_key": "my_key",
2508 "secret_key": "my_secret"
2509 }"#,
2510 )
2511 .unwrap();
2512
2513 assert!(cfg.testnet);
2514 assert_eq!(cfg.api_key(), "my_key");
2515 }
2516
2517 #[test]
2518 fn test_binance_spot_config_deserialize_testnet_true() {
2519 let cfg: BinanceSpotConfig = serde_json::from_str(
2520 r#"{
2521 "api_key": "my_key",
2522 "secret_key": "my_secret",
2523 "testnet": true
2524 }"#,
2525 )
2526 .unwrap();
2527
2528 assert!(cfg.testnet);
2529 }
2530
2531 #[test]
2532 fn test_binance_spot_config_deserialize_testnet_false() {
2533 let cfg: BinanceSpotConfig = serde_json::from_str(
2534 r#"{
2535 "api_key": "my_key",
2536 "secret_key": "my_secret",
2537 "testnet": false
2538 }"#,
2539 )
2540 .unwrap();
2541
2542 assert!(!cfg.testnet);
2543 }
2544
2545 #[test]
2546 #[serial_test::serial]
2547 fn test_binance_spot_config_from_env_defaults_to_testnet() {
2548 temp_env::with_vars(
2549 [
2550 ("BINANCE_API_KEY", Some("my_key")),
2551 ("BINANCE_SECRET_KEY", Some("my_secret")),
2552 ("BINANCE_TESTNET", None),
2553 ],
2554 || {
2555 let cfg = BinanceSpotConfig::from_env().unwrap();
2556 assert!(cfg.testnet);
2557 assert_eq!(cfg.api_key(), "my_key");
2558 },
2559 );
2560 }
2561
2562 #[test]
2563 #[serial_test::serial]
2564 fn test_binance_spot_config_from_env_accepts_explicit_production() {
2565 temp_env::with_vars(
2566 [
2567 ("BINANCE_API_KEY", Some("my_key")),
2568 ("BINANCE_SECRET_KEY", Some("my_secret")),
2569 ("BINANCE_TESTNET", Some("false")),
2570 ],
2571 || {
2572 let cfg = BinanceSpotConfig::from_env().unwrap();
2573 assert!(!cfg.testnet);
2574 },
2575 );
2576 }
2577
2578 #[test]
2579 #[serial_test::serial]
2580 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2581 temp_env::with_vars(
2582 [
2583 ("BINANCE_API_KEY", Some("my_key")),
2584 ("BINANCE_SECRET_KEY", Some("my_secret")),
2585 ("BINANCE_TESTNET", Some("maybe")),
2586 ],
2587 || {
2588 let err = BinanceSpotConfig::from_env().unwrap_err();
2589 assert!(
2590 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2591 );
2592 },
2593 );
2594 }
2595
2596 #[test]
2597 #[serial_test::serial]
2598 fn test_binance_spot_config_from_env_requires_credentials() {
2599 temp_env::with_vars(
2600 [
2601 ("BINANCE_API_KEY", None),
2602 ("BINANCE_SECRET_KEY", Some("my_secret")),
2603 ("BINANCE_TESTNET", None),
2604 ],
2605 || {
2606 let err = BinanceSpotConfig::from_env().unwrap_err();
2607 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2608 },
2609 );
2610 }
2611
2612 #[test]
2613 fn test_parse_side() {
2614 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2615 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2616 assert_eq!(parse_side("buy"), None);
2617 assert_eq!(parse_side("UNKNOWN"), None);
2618 }
2619
2620 #[test]
2621 fn test_parse_order_kind() {
2622 assert_eq!(parse_order_kind("MARKET"), Some(OrderKind::Market));
2623 assert_eq!(parse_order_kind("LIMIT"), Some(OrderKind::Limit));
2624 assert_eq!(parse_order_kind("LIMIT_MAKER"), Some(OrderKind::Limit));
2625 assert_eq!(parse_order_kind("STOP_LOSS"), None);
2626 assert_eq!(parse_order_kind("TAKE_PROFIT"), None);
2627 assert_eq!(parse_order_kind("STOP_LOSS_LIMIT"), Some(OrderKind::Limit));
2628 assert_eq!(
2629 parse_order_kind("TAKE_PROFIT_LIMIT"),
2630 Some(OrderKind::Limit)
2631 );
2632 assert_eq!(parse_order_kind("UNKNOWN_TYPE"), None);
2633 }
2634
2635 #[test]
2636 fn test_parse_time_in_force() {
2637 assert_eq!(
2638 parse_time_in_force("GTC"),
2639 TimeInForce::GoodUntilCancelled { post_only: false }
2640 );
2641 assert_eq!(
2642 parse_time_in_force("GTX"),
2643 TimeInForce::GoodUntilCancelled { post_only: true }
2644 );
2645 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2646 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2647 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2648 assert_eq!(
2650 parse_time_in_force("UNKNOWN"),
2651 TimeInForce::GoodUntilCancelled { post_only: false }
2652 );
2653 }
2654
2655 #[test]
2656 fn test_convert_order_kind_tif() {
2657 use rust_decimal::Decimal;
2658
2659 assert!(matches!(
2661 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2662 Some((OrderPlaceTypeEnum::Market, None))
2663 ));
2664 assert!(matches!(
2665 convert_order_kind_tif(
2666 OrderKind::Limit,
2667 TimeInForce::GoodUntilCancelled { post_only: false }
2668 ),
2669 Some((
2670 OrderPlaceTypeEnum::Limit,
2671 Some(OrderPlaceTimeInForceEnum::Gtc)
2672 ))
2673 ));
2674 assert!(matches!(
2675 convert_order_kind_tif(
2676 OrderKind::Limit,
2677 TimeInForce::GoodUntilCancelled { post_only: true }
2678 ),
2679 Some((OrderPlaceTypeEnum::LimitMaker, None))
2680 ));
2681 assert!(matches!(
2682 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2683 Some((
2684 OrderPlaceTypeEnum::Limit,
2685 Some(OrderPlaceTimeInForceEnum::Fok)
2686 ))
2687 ));
2688 assert!(matches!(
2689 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2690 Some((
2691 OrderPlaceTypeEnum::Limit,
2692 Some(OrderPlaceTimeInForceEnum::Ioc)
2693 ))
2694 ));
2695 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2698
2699 assert!(matches!(
2701 convert_order_kind_tif(
2702 OrderKind::Stop {
2703 trigger_price: Decimal::from(100)
2704 },
2705 TimeInForce::GoodUntilCancelled { post_only: false }
2706 ),
2707 Some((OrderPlaceTypeEnum::StopLoss, None))
2708 ));
2709 assert!(matches!(
2710 convert_order_kind_tif(
2711 OrderKind::StopLimit {
2712 trigger_price: Decimal::from(100)
2713 },
2714 TimeInForce::GoodUntilCancelled { post_only: false }
2715 ),
2716 Some((
2717 OrderPlaceTypeEnum::StopLossLimit,
2718 Some(OrderPlaceTimeInForceEnum::Gtc)
2719 ))
2720 ));
2721 assert!(matches!(
2722 convert_order_kind_tif(
2723 OrderKind::TakeProfit {
2724 trigger_price: Decimal::from(150)
2725 },
2726 TimeInForce::ImmediateOrCancel
2727 ),
2728 Some((OrderPlaceTypeEnum::TakeProfit, None))
2729 ));
2730 assert!(matches!(
2731 convert_order_kind_tif(
2732 OrderKind::TakeProfitLimit {
2733 trigger_price: Decimal::from(150)
2734 },
2735 TimeInForce::FillOrKill
2736 ),
2737 Some((
2738 OrderPlaceTypeEnum::TakeProfitLimit,
2739 Some(OrderPlaceTimeInForceEnum::Fok)
2740 ))
2741 ));
2742
2743 assert!(matches!(
2745 convert_order_kind_tif(
2746 OrderKind::TrailingStop {
2747 offset: Decimal::from(100),
2748 offset_type: TrailingOffsetType::BasisPoints,
2749 },
2750 TimeInForce::GoodUntilCancelled { post_only: false }
2751 ),
2752 Some((OrderPlaceTypeEnum::StopLoss, None))
2753 ));
2754 assert!(matches!(
2755 convert_order_kind_tif(
2756 OrderKind::TrailingStop {
2757 offset: Decimal::from(5),
2758 offset_type: TrailingOffsetType::Percentage,
2759 },
2760 TimeInForce::GoodUntilCancelled { post_only: false }
2761 ),
2762 Some((OrderPlaceTypeEnum::StopLoss, None))
2763 ));
2764
2765 assert!(
2767 convert_order_kind_tif(
2768 OrderKind::TrailingStop {
2769 offset: Decimal::from(10),
2770 offset_type: TrailingOffsetType::Absolute,
2771 },
2772 TimeInForce::GoodUntilCancelled { post_only: false }
2773 )
2774 .is_none()
2775 );
2776
2777 assert!(
2779 convert_order_kind_tif(
2780 OrderKind::TrailingStopLimit {
2781 offset: Decimal::from(100),
2782 offset_type: TrailingOffsetType::BasisPoints,
2783 limit_offset: Decimal::from(10),
2784 },
2785 TimeInForce::GoodUntilCancelled { post_only: false }
2786 )
2787 .is_none()
2788 );
2789 }
2790
2791 #[test]
2792 fn test_parse_binance_api_error() {
2793 let instrument = InstrumentNameExchange::new("BTCUSDT");
2794
2795 assert!(matches!(
2796 parse_binance_api_error("Insufficient balance".into(), &instrument),
2797 ApiError::BalanceInsufficient(_, _)
2798 ));
2799 assert!(matches!(
2800 parse_binance_api_error("Not enough funds".into(), &instrument),
2801 ApiError::BalanceInsufficient(_, _)
2802 ));
2803 assert_eq!(
2804 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2805 ApiError::RateLimit
2806 );
2807 assert_eq!(
2808 parse_binance_api_error("Error code -1015".into(), &instrument),
2809 ApiError::RateLimit
2810 );
2811 assert_eq!(
2813 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2814 ApiError::RateLimit
2815 );
2816 assert_eq!(
2818 parse_binance_api_error("Error code -2011".into(), &instrument),
2819 ApiError::OrderAlreadyCancelled
2820 );
2821 assert!(matches!(
2823 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2824 ApiError::OrderAlreadyCancelled
2825 ));
2826 assert!(matches!(
2828 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2829 ApiError::OrderAlreadyCancelled
2830 ));
2831 assert!(matches!(
2833 parse_binance_api_error("Unknown order encountered".into(), &instrument),
2834 ApiError::OrderRejected(_)
2835 ));
2836 assert!(matches!(
2837 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2838 ApiError::InstrumentInvalid(_, _)
2839 ));
2840 assert!(matches!(
2842 parse_binance_api_error(
2843 "Server-side response error (code -2010): Account has insufficient balance".into(),
2844 &instrument
2845 ),
2846 ApiError::BalanceInsufficient(_, _)
2847 ));
2848 assert!(matches!(
2849 parse_binance_api_error("Some other error".into(), &instrument),
2850 ApiError::OrderRejected(_)
2851 ));
2852 }
2853
2854 #[test]
2855 fn test_contains_error_code_suffix_guard() {
2856 assert!(
2858 !contains_error_code("-20130", "-2013"),
2859 "-20130 should not match -2013"
2860 );
2861 assert!(
2862 !contains_error_code("-20131", "-2013"),
2863 "-20131 should not match -2013"
2864 );
2865 assert!(contains_error_code("-2013", "-2013"), "exact match");
2867 assert!(
2868 contains_error_code("Error -2013: text", "-2013"),
2869 "match with trailing text"
2870 );
2871 }
2872
2873 #[test]
2874 fn test_contains_error_code_prefix_guard() {
2875 assert!(
2878 !contains_error_code("1-2013", "-2013"),
2879 "1-2013 should not match -2013"
2880 );
2881 assert!(
2882 !contains_error_code("error 1-2013 text", "-2013"),
2883 "embedded 1-2013 should not match"
2884 );
2885 assert!(
2887 contains_error_code("code=-2013,", "-2013"),
2888 "=-2013 prefix should match"
2889 );
2890 assert!(
2891 contains_error_code(" -2013 ", "-2013"),
2892 "space prefix should match"
2893 );
2894 }
2895
2896 #[test]
2897 fn test_contains_error_code_second_occurrence_valid() {
2898 assert!(
2901 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
2902 "second valid occurrence must match when first fails digit guard"
2903 );
2904 assert!(
2906 contains_error_code("-2013 or -20130", "-2013"),
2907 "first valid occurrence must match when second is a longer code"
2908 );
2909 }
2910
2911 #[test]
2912 fn test_dedup_cache() {
2913 let cache = new_dedup_cache();
2914 let key = DedupKey {
2915 instrument: SmolStr::from("BTCUSDT"),
2916 id: SmolStr::from("12345"),
2917 kind: DedupEventKind::Trade,
2918 };
2919
2920 assert!(!is_duplicate(&cache, key));
2922 let key = DedupKey {
2924 instrument: SmolStr::from("BTCUSDT"),
2925 id: SmolStr::from("12345"),
2926 kind: DedupEventKind::Trade,
2927 };
2928 assert!(is_duplicate(&cache, key));
2929
2930 let key2 = DedupKey {
2932 instrument: SmolStr::from("BTCUSDT"),
2933 id: SmolStr::from("12345"),
2934 kind: DedupEventKind::New,
2935 };
2936 assert!(!is_duplicate(&cache, key2));
2937 }
2938
2939 #[test]
2940 fn test_is_rate_limit_error() {
2941 assert!(is_rate_limit_error(&anyhow::anyhow!(
2943 "Too many requests. You are being rate-limited. Please slow down."
2944 )));
2945 assert!(is_rate_limit_error(&anyhow::anyhow!(
2947 "The IP address has been banned for exceeding rate limits. Contact support."
2948 )));
2949 assert!(is_rate_limit_error(&anyhow::anyhow!(
2951 "Error -1015: too many new orders"
2952 )));
2953 assert!(is_rate_limit_error(&anyhow::anyhow!(
2954 "Error -1003: too many requests"
2955 )));
2956 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
2958 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
2959 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
2960 assert!(
2962 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
2963 "-10150 should not match -1015"
2964 );
2965 assert!(
2966 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
2967 "-10030 should not match -1003"
2968 );
2969 }
2970
2971 #[test]
2972 fn test_is_api_rejection_error() {
2973 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
2975 code: -2010,
2976 message: "Account has insufficient balance for requested action.".into(),
2977 });
2978 assert!(
2979 is_api_rejection_error(&rejection),
2980 "ResponseError should be detected as API rejection"
2981 );
2982
2983 let transport = anyhow::anyhow!("connection reset by peer");
2985 assert!(
2986 !is_api_rejection_error(&transport),
2987 "plain transport error should not be detected as API rejection"
2988 );
2989
2990 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
2992 assert!(
2993 !is_api_rejection_error(&rate_limit),
2994 "rate-limit string error should not be detected as API rejection"
2995 );
2996 }
2997
2998 #[test]
2999 fn test_connectivity_error_detects_auth_failures() {
3000 let err = connectivity_error(anyhow::anyhow!("Error -1002: unauthorized"));
3002 assert!(
3003 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3004 "expected Unauthenticated for -1002, got {err:?}"
3005 );
3006
3007 let err = connectivity_error(anyhow::anyhow!("Error -2015: permission denied for action"));
3010 assert!(
3011 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3012 "expected Unauthenticated for -2015, got {err:?}"
3013 );
3014
3015 let err = connectivity_error(anyhow::anyhow!("invalid signature provided"));
3017 assert!(
3018 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3019 "expected Unauthenticated for 'invalid signature', got {err:?}"
3020 );
3021
3022 let err = connectivity_error(anyhow::anyhow!(
3024 "The signature for this request is not valid."
3025 ));
3026 assert!(
3027 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3028 "expected Unauthenticated for 'signature for this request is not valid', got {err:?}"
3029 );
3030
3031 let err = connectivity_error(anyhow::anyhow!("Invalid API-key format"));
3033 assert!(
3034 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3035 "expected Unauthenticated for 'invalid api-key', got {err:?}"
3036 );
3037
3038 let err = connectivity_error(anyhow::anyhow!("connection timeout"));
3040 assert!(
3041 matches!(err, UnindexedClientError::Connectivity(_)),
3042 "expected Connectivity for timeout, got {err:?}"
3043 );
3044 }
3045
3046 #[test]
3047 fn test_dedup_key_from_event_trade_includes_instrument() {
3048 use crate::order::id::{OrderId, StrategyId};
3049 use crate::trade::{AssetFees, Trade, TradeId};
3050 use chrono::Utc;
3051 use rust_decimal::Decimal;
3052 use rustrade_instrument::Side;
3053
3054 let instrument = InstrumentNameExchange::new("BTCUSDT");
3055 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3056 TradeId::new("9001"),
3057 OrderId::new("4242"),
3058 instrument.clone(),
3059 StrategyId::unknown(),
3060 Utc::now(),
3061 Side::Buy,
3062 Decimal::ZERO,
3063 Decimal::ZERO,
3064 AssetFees::new(
3065 AssetNameExchange::from("USDT"),
3066 Decimal::ZERO,
3067 Some(Decimal::ZERO),
3068 ),
3069 );
3070 let event =
3071 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
3072
3073 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
3074 assert_eq!(key.kind, DedupEventKind::Trade);
3075 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
3076 assert_eq!(
3077 key.instrument.as_str(),
3078 "BTCUSDT",
3079 "key.instrument should be the symbol"
3080 );
3081
3082 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
3084 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3085 TradeId::new("9001"),
3086 OrderId::new("7777"),
3087 instrument2,
3088 StrategyId::unknown(),
3089 Utc::now(),
3090 Side::Buy,
3091 Decimal::ZERO,
3092 Decimal::ZERO,
3093 AssetFees::new(
3094 AssetNameExchange::from("USDT"),
3095 Decimal::ZERO,
3096 Some(Decimal::ZERO),
3097 ),
3098 );
3099 let event2 =
3100 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
3101 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
3102 assert_ne!(
3103 key, key2,
3104 "same trade ID on different symbols must produce distinct keys"
3105 );
3106 }
3107
3108 fn make_balance_inner(
3113 asset: &str,
3114 free: &str,
3115 locked: &str,
3116 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3117 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3118 a: Some(asset.to_string()),
3119 f: Some(free.to_string()),
3120 l: Some(locked.to_string()),
3121 }
3122 }
3123
3124 #[test]
3125 fn test_convert_account_position_happy_path() {
3126 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3127 u: Some(1_700_000_000_000),
3128 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
3129 ..Default::default()
3130 };
3131 let mut buf = Vec::new();
3132 convert_account_position(position, &mut buf);
3133
3134 assert_eq!(buf.len(), 1);
3135 match &buf[0].kind {
3136 AccountEventKind::BalanceStreamUpdate(snap) => {
3137 let update = &snap.0;
3138 assert_eq!(update.asset.as_ref(), "BTC");
3139 let expected_free = Decimal::from_str("1.5").unwrap();
3141 let expected_locked = Decimal::from_str("0.5").unwrap();
3142 assert_eq!(update.update.free, expected_free);
3143 assert_eq!(update.update.locked, expected_locked);
3144 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3145 }
3146 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3147 }
3148 }
3149
3150 #[test]
3151 fn test_convert_account_position_u_field_none_uses_now() {
3152 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3154 u: None,
3155 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3156 ..Default::default()
3157 };
3158 let mut buf = Vec::new();
3159 convert_account_position(position, &mut buf);
3160 assert_eq!(buf.len(), 1);
3161 }
3162
3163 #[test]
3164 fn test_convert_account_position_missing_asset_name_skipped() {
3165 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3166 u: Some(1_700_000_000_000),
3167 b_uppercase: Some(vec![
3168 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3169 a: None, f: Some("1.0".to_string()),
3171 l: Some("0.0".to_string()),
3172 },
3173 make_balance_inner("USDT", "100.0", "0.0"), ]),
3175 ..Default::default()
3176 };
3177 let mut buf = Vec::new();
3178 convert_account_position(position, &mut buf);
3179 assert_eq!(buf.len(), 1);
3181 match &buf[0].kind {
3182 AccountEventKind::BalanceStreamUpdate(snap) => {
3183 assert_eq!(snap.0.asset.as_ref(), "USDT");
3184 }
3185 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3186 }
3187 }
3188
3189 #[test]
3190 fn test_convert_account_position_unparseable_free_skipped() {
3191 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3192 u: Some(1_700_000_000_000),
3193 b_uppercase: Some(vec![
3194 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3195 a: Some("BTC".to_string()),
3196 f: Some("not-a-number".to_string()),
3197 l: Some("0.0".to_string()),
3198 },
3199 make_balance_inner("ETH", "1.0", "0.0"), ]),
3201 ..Default::default()
3202 };
3203 let mut buf = Vec::new();
3204 convert_account_position(position, &mut buf);
3205 assert_eq!(buf.len(), 1);
3207 match &buf[0].kind {
3208 AccountEventKind::BalanceStreamUpdate(snap) => {
3209 assert_eq!(snap.0.asset.as_ref(), "ETH");
3210 }
3211 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3212 }
3213 }
3214
3215 #[test]
3216 fn test_convert_account_position_empty_balances() {
3217 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3218 u: Some(1_700_000_000_000),
3219 b_uppercase: Some(vec![]),
3220 ..Default::default()
3221 };
3222 let mut buf = Vec::new();
3223 convert_account_position(position, &mut buf);
3224 assert!(
3225 buf.is_empty(),
3226 "empty balance list should produce no events"
3227 );
3228 }
3229
3230 #[test]
3231 fn test_convert_account_position_b_field_none() {
3232 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3233 u: Some(1_700_000_000_000),
3234 b_uppercase: None, ..Default::default()
3236 };
3237 let mut buf = Vec::new();
3238 convert_account_position(position, &mut buf);
3239 assert!(buf.is_empty(), "None B field should produce no events");
3240 }
3241
3242 #[tokio::test]
3244 async fn test_exponential_backoff_exhaustion() {
3245 tokio::time::pause(); let mut backoff = ExponentialBackoff::new();
3247
3248 for i in 0..MAX_RECONNECT_ATTEMPTS {
3250 assert!(
3251 backoff.wait().await,
3252 "expected true on attempt {i} (before exhaustion)"
3253 );
3254 }
3255
3256 assert!(!backoff.wait().await, "expected false after max attempts");
3258 }
3259
3260 #[tokio::test]
3261 async fn test_exponential_backoff_reset() {
3262 tokio::time::pause();
3263 let mut backoff = ExponentialBackoff::new();
3264
3265 for _ in 0..MAX_RECONNECT_ATTEMPTS {
3266 backoff.wait().await;
3267 }
3268 assert!(!backoff.wait().await, "should be exhausted");
3269
3270 backoff.reset();
3271 assert!(backoff.wait().await, "should succeed again after reset");
3272 }
3273
3274 fn make_base_report() -> binance_sdk::spot::websocket_api::ExecutionReport {
3278 binance_sdk::spot::websocket_api::ExecutionReport {
3279 s: Some("BTCUSDT".to_string()),
3280 i: Some(12345),
3281 c: Some("client-1".to_string()),
3282 t_uppercase: Some(1_700_000_000_000),
3283 ..Default::default()
3284 }
3285 }
3286
3287 #[test]
3288 fn test_convert_execution_report_new() {
3289 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3290 x: Some("NEW".to_string()),
3291 s_uppercase: Some("BUY".to_string()),
3292 o: Some("LIMIT".to_string()),
3293 p: Some("50000.00".to_string()),
3294 q: Some("0.01".to_string()),
3295 f: Some("GTC".to_string()),
3296 z: Some("0".to_string()),
3297 ..make_base_report()
3298 };
3299
3300 let event = convert_execution_report(report).expect("NEW event should produce Some");
3301 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3302 match &event.kind {
3303 AccountEventKind::OrderSnapshot(snap) => {
3304 let order = &snap.0;
3305 assert_eq!(order.side, Side::Buy);
3306 assert_eq!(order.kind, OrderKind::Limit);
3307 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3308 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3309 assert_eq!(order.key.cid.0.as_str(), "client-1");
3310 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3311 }
3312 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3313 }
3314 }
3315
3316 #[test]
3317 fn test_convert_execution_report_trade() {
3318 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3319 x: Some("TRADE".to_string()),
3320 s_uppercase: Some("BUY".to_string()),
3321 t: Some(9999),
3322 l_uppercase: Some("50000.00".to_string()),
3323 l: Some("0.01".to_string()),
3324 n: Some("0.000001".to_string()),
3325 ..make_base_report()
3326 };
3327
3328 let event = convert_execution_report(report).expect("TRADE event should produce Some");
3329 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3330 match &event.kind {
3331 AccountEventKind::Trade(trade) => {
3332 assert_eq!(trade.side, Side::Buy);
3333 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3334 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3335 assert_eq!(trade.id.0.as_str(), "9999");
3336 assert_eq!(trade.order_id.0.as_str(), "12345");
3337 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3338 }
3339 other => panic!("TRADE should yield Trade, got {other:?}"),
3340 }
3341 }
3342
3343 #[test]
3344 fn test_convert_execution_report_trade_missing_last_price() {
3345 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3347 x: Some("TRADE".to_string()),
3348 s_uppercase: Some("BUY".to_string()),
3349 t: Some(9999),
3350 l_uppercase: None, l: Some("0.01".to_string()),
3352 ..make_base_report()
3353 };
3354 assert!(
3355 convert_execution_report(report).is_none(),
3356 "missing last price (L) should return None"
3357 );
3358 }
3359
3360 #[test]
3361 fn test_convert_execution_report_trade_missing_last_qty() {
3362 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3364 x: Some("TRADE".to_string()),
3365 s_uppercase: Some("BUY".to_string()),
3366 t: Some(9999),
3367 l_uppercase: Some("50000.00".to_string()),
3368 l: None, ..make_base_report()
3370 };
3371 assert!(
3372 convert_execution_report(report).is_none(),
3373 "missing last qty (l) should return None"
3374 );
3375 }
3376
3377 #[test]
3378 fn test_convert_execution_report_canceled() {
3379 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3380 x: Some("CANCELED".to_string()),
3381 ..make_base_report()
3382 };
3383
3384 let event = convert_execution_report(report).expect("CANCELED should produce Some");
3385 assert!(
3386 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3387 "CANCELED should yield OrderCancelled with Ok state"
3388 );
3389 }
3390
3391 #[test]
3392 fn test_convert_execution_report_expired() {
3393 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3394 x: Some("EXPIRED".to_string()),
3395 ..make_base_report()
3396 };
3397 let event = convert_execution_report(report).expect("EXPIRED should produce Some");
3398 assert!(
3399 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3400 "EXPIRED should yield OrderCancelled with Ok state"
3401 );
3402 }
3403
3404 #[test]
3405 fn test_convert_execution_report_expired_in_match() {
3406 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3407 x: Some("EXPIRED_IN_MATCH".to_string()),
3408 ..make_base_report()
3409 };
3410 let event = convert_execution_report(report).expect("EXPIRED_IN_MATCH should produce Some");
3411 assert!(
3412 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3413 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3414 );
3415 }
3416
3417 #[test]
3418 fn test_convert_execution_report_rejected() {
3419 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3420 x: Some("REJECTED".to_string()),
3421 r: Some("INSUFFICIENT_FUNDS".to_string()),
3422 ..make_base_report()
3423 };
3424
3425 let event = convert_execution_report(report).expect("REJECTED should produce Some");
3426 assert!(
3427 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_err()),
3428 "REJECTED should yield OrderCancelled with Err state"
3429 );
3430 }
3431
3432 #[test]
3433 fn test_convert_execution_report_missing_exec_type() {
3434 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3436 s: Some("BTCUSDT".to_string()),
3437 ..Default::default()
3438 };
3439 assert!(
3440 convert_execution_report(report).is_none(),
3441 "missing execution type should return None"
3442 );
3443 }
3444
3445 #[test]
3446 fn test_convert_execution_report_missing_symbol() {
3447 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3449 x: Some("NEW".to_string()),
3450 ..Default::default()
3451 };
3452 assert!(
3453 convert_execution_report(report).is_none(),
3454 "missing symbol should return None"
3455 );
3456 }
3457
3458 #[test]
3459 fn test_convert_execution_report_missing_order_id() {
3460 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3462 x: Some("NEW".to_string()),
3463 s: Some("BTCUSDT".to_string()),
3464 i: None,
3465 ..Default::default()
3466 };
3467 assert!(
3468 convert_execution_report(report).is_none(),
3469 "missing orderId should return None"
3470 );
3471 }
3472
3473 #[test]
3474 fn test_convert_execution_report_trade_missing_trade_id() {
3475 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3477 x: Some("TRADE".to_string()),
3478 t: None,
3479 ..make_base_report()
3480 };
3481 assert!(
3482 convert_execution_report(report).is_none(),
3483 "TRADE event missing tradeId should return None"
3484 );
3485 }
3486
3487 #[test]
3488 fn test_convert_execution_report_replace_yields_cancelled() {
3489 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3490 x: Some("REPLACE".to_string()),
3491 ..make_base_report()
3492 };
3493 let event = convert_execution_report(report).expect("REPLACE should produce Some");
3496 assert!(
3497 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3498 "REPLACE should yield OrderCancelled with Ok state"
3499 );
3500 }
3501
3502 fn make_balance(
3507 asset: &str,
3508 free: &str,
3509 locked: &str,
3510 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3511 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3512 asset: Some(asset.to_string()),
3513 free: Some(free.to_string()),
3514 locked: Some(locked.to_string()),
3515 }
3516 }
3517
3518 #[test]
3519 fn test_filter_balances_empty_assets_returns_all() {
3520 let balances = vec![
3521 make_balance("BTC", "1.0", "0.0"),
3522 make_balance("USDT", "500.0", "50.0"),
3523 ];
3524 let result = filter_and_convert_balances(balances, &[]);
3525 assert_eq!(result.len(), 2, "empty filter should return all balances");
3526 }
3527
3528 #[test]
3529 fn test_filter_balances_matching_asset_returned() {
3530 let balances = vec![
3531 make_balance("BTC", "1.5", "0.5"),
3532 make_balance("ETH", "10.0", "0.0"),
3533 ];
3534 let assets = vec![AssetNameExchange::new("BTC")];
3535 let result = filter_and_convert_balances(balances, &assets);
3536 assert_eq!(result.len(), 1);
3537 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
3538 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
3540 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
3541 }
3542
3543 #[test]
3544 fn test_filter_balances_non_matching_asset_filtered_out() {
3545 let balances = vec![
3546 make_balance("BTC", "1.0", "0.0"),
3547 make_balance("ETH", "2.0", "0.0"),
3548 ];
3549 let assets = vec![AssetNameExchange::new("USDT")];
3550 let result = filter_and_convert_balances(balances, &assets);
3551 assert!(
3552 result.is_empty(),
3553 "non-matching asset should be filtered out"
3554 );
3555 }
3556
3557 #[test]
3558 fn test_filter_balances_missing_asset_field_skipped() {
3559 let balances = vec![
3560 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3561 asset: None,
3562 free: Some("1.0".to_string()),
3563 locked: Some("0.0".to_string()),
3564 },
3565 make_balance("USDT", "100.0", "0.0"),
3566 ];
3567 let result = filter_and_convert_balances(balances, &[]);
3568 assert_eq!(
3569 result.len(),
3570 1,
3571 "entry with missing asset should be skipped"
3572 );
3573 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
3574 }
3575
3576 #[test]
3577 fn test_filter_balances_unparseable_free_skipped() {
3578 let balances = vec![
3579 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3580 asset: Some("BTC".to_string()),
3581 free: Some("not-a-number".to_string()),
3582 locked: Some("0.0".to_string()),
3583 },
3584 ];
3585 let result = filter_and_convert_balances(balances, &[]);
3586 assert!(
3587 result.is_empty(),
3588 "unparseable free field should be skipped"
3589 );
3590 }
3591
3592 #[test]
3593 fn test_filter_balances_zero_balance_included() {
3594 let balances = vec![
3597 make_balance("BTC", "0.00000000", "0.00000000"),
3598 make_balance("USDT", "100.0", "0.0"),
3599 ];
3600 let result = filter_and_convert_balances(balances, &[]);
3601 assert_eq!(result.len(), 2, "zero-balance entries must be included");
3602 let btc = result
3603 .iter()
3604 .find(|b| b.asset == AssetNameExchange::new("BTC"))
3605 .unwrap();
3606 assert_eq!(btc.balance.total, Decimal::ZERO);
3607 assert_eq!(btc.balance.free, Decimal::ZERO);
3608 }
3609
3610 #[test]
3611 fn test_filter_balances_duplicate_assets_in_response() {
3612 let balances = vec![
3616 make_balance("BTC", "1.0", "0.0"),
3617 make_balance("BTC", "2.0", "0.0"),
3618 ];
3619 let result = filter_and_convert_balances(balances, &[]);
3620 assert_eq!(
3621 result.len(),
3622 2,
3623 "duplicate asset entries produce two AssetBalance entries"
3624 );
3625 }
3626
3627 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
3632 binance_sdk::spot::rest_api::MyTradesResponseInner {
3633 id: Some(9001),
3634 order_id: Some(4242),
3635 price: Some("50000.00".to_string()),
3636 qty: Some("0.01".to_string()),
3637 commission: Some("0.05".to_string()),
3638 time: Some(1_700_000_000_000),
3639 is_buyer: Some(true),
3640 ..Default::default()
3641 }
3642 }
3643
3644 #[test]
3645 fn test_convert_my_trade_happy_path() {
3646 let instrument = InstrumentNameExchange::new("BTCUSDT");
3647 let trade =
3648 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
3649 assert_eq!(trade.instrument, instrument);
3650 assert_eq!(trade.side, Side::Buy);
3651 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3652 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3653 }
3654
3655 #[test]
3656 fn test_convert_my_trade_sell_side() {
3657 let instrument = InstrumentNameExchange::new("BTCUSDT");
3658 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3659 is_buyer: Some(false),
3660 ..make_base_trade()
3661 };
3662 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
3663 assert_eq!(trade.side, Side::Sell);
3664 }
3665
3666 #[test]
3667 fn test_convert_my_trade_missing_id_returns_none() {
3668 let instrument = InstrumentNameExchange::new("BTCUSDT");
3669 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3670 id: None,
3671 ..make_base_trade()
3672 };
3673 assert!(
3674 convert_my_trade(&t, &instrument).is_none(),
3675 "missing id should return None"
3676 );
3677 }
3678
3679 #[test]
3680 fn test_convert_my_trade_missing_order_id_returns_none() {
3681 let instrument = InstrumentNameExchange::new("BTCUSDT");
3682 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3683 order_id: None,
3684 ..make_base_trade()
3685 };
3686 assert!(
3687 convert_my_trade(&t, &instrument).is_none(),
3688 "missing orderId should return None"
3689 );
3690 }
3691
3692 #[test]
3693 fn test_convert_my_trade_missing_is_buyer_returns_none() {
3694 let instrument = InstrumentNameExchange::new("BTCUSDT");
3695 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3696 is_buyer: None,
3697 ..make_base_trade()
3698 };
3699 assert!(
3700 convert_my_trade(&t, &instrument).is_none(),
3701 "missing isBuyer should return None"
3702 );
3703 }
3704
3705 #[test]
3706 fn test_convert_my_trade_commission_none_defaults_to_zero() {
3707 let instrument = InstrumentNameExchange::new("BTCUSDT");
3708 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3709 commission: None,
3710 ..make_base_trade()
3711 };
3712 let trade =
3713 convert_my_trade(&t, &instrument).expect("None commission should still convert");
3714 assert_eq!(trade.fees.fees, Decimal::ZERO);
3715 }
3716
3717 fn make_base_open_order() -> binance_sdk::spot::rest_api::AllOrdersResponseInner {
3722 binance_sdk::spot::rest_api::AllOrdersResponseInner {
3723 order_id: Some(12345),
3724 client_order_id: Some("cid-abc".to_string()),
3725 side: Some("BUY".to_string()),
3726 r#type: Some("LIMIT".to_string()),
3727 price: Some("50000.00".to_string()),
3728 orig_qty: Some("0.01".to_string()),
3729 executed_qty: Some("0.0".to_string()),
3730 time_in_force: Some("GTC".to_string()),
3731 time: Some(1_700_000_000_000),
3732 ..Default::default()
3733 }
3734 }
3735
3736 #[test]
3737 fn test_convert_open_order_happy_path() {
3738 let instrument = InstrumentNameExchange::new("BTCUSDT");
3739 let order = convert_open_order(&make_base_open_order(), &instrument)
3740 .expect("valid order should convert");
3741 assert_eq!(order.key.instrument, instrument);
3742 assert_eq!(order.side, Side::Buy);
3743 assert_eq!(order.kind, OrderKind::Limit);
3744 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3745 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3746 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
3747 }
3748
3749 #[test]
3750 fn test_convert_open_order_owned_symbol_recovers_instrument() {
3751 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3753 symbol: Some("ETHUSDT".to_string()),
3754 ..make_base_open_order()
3755 };
3756 let order = convert_open_order_owned_symbol(&o).expect("valid order should convert");
3757 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
3758 assert_eq!(order.side, Side::Buy);
3759 }
3760
3761 #[test]
3762 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
3763 let o = make_base_open_order();
3765 assert!(o.symbol.is_none());
3766 assert!(convert_open_order_owned_symbol(&o).is_none());
3767 }
3768
3769 #[test]
3770 fn test_convert_open_order_missing_order_id_returns_none() {
3771 let instrument = InstrumentNameExchange::new("BTCUSDT");
3772 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3773 order_id: None,
3774 ..make_base_open_order()
3775 };
3776 assert!(
3777 convert_open_order(&o, &instrument).is_none(),
3778 "missing orderId should return None"
3779 );
3780 }
3781
3782 #[test]
3783 fn test_convert_open_order_missing_side_returns_none() {
3784 let instrument = InstrumentNameExchange::new("BTCUSDT");
3785 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3786 side: None,
3787 ..make_base_open_order()
3788 };
3789 assert!(
3790 convert_open_order(&o, &instrument).is_none(),
3791 "missing side should return None"
3792 );
3793 }
3794
3795 #[test]
3796 fn test_convert_open_order_missing_type_returns_none() {
3797 let instrument = InstrumentNameExchange::new("BTCUSDT");
3798 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3799 r#type: None,
3800 ..make_base_open_order()
3801 };
3802 assert!(
3803 convert_open_order(&o, &instrument).is_none(),
3804 "missing type should return None"
3805 );
3806 }
3807
3808 #[test]
3809 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
3810 let instrument = InstrumentNameExchange::new("BTCUSDT");
3811 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3812 executed_qty: None,
3813 ..make_base_open_order()
3814 };
3815 let order =
3816 convert_open_order(&o, &instrument).expect("None executedQty should still convert");
3817 assert_eq!(
3818 order.state.filled_quantity,
3819 Decimal::ZERO,
3820 "None executedQty should default to zero"
3821 );
3822 }
3823
3824 #[test]
3825 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
3826 let instrument = InstrumentNameExchange::new("BTCUSDT");
3827 let o = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3828 executed_qty: Some("bad-value".to_string()),
3829 ..make_base_open_order()
3830 };
3831 let order = convert_open_order(&o, &instrument)
3832 .expect("unparseable executedQty should still convert");
3833 assert_eq!(
3834 order.state.filled_quantity,
3835 Decimal::ZERO,
3836 "unparseable executedQty should default to zero"
3837 );
3838 }
3839
3840 #[test]
3845 fn test_dedup_key_from_event_non_open_states_return_none() {
3846 use crate::order::state::{
3847 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
3848 };
3849 use rustrade_integration::collection::snapshot::Snapshot;
3850
3851 let key = OrderKey::new(
3852 ExchangeId::BinanceSpot,
3853 InstrumentNameExchange::new("BTCUSDT"),
3854 StrategyId::unknown(),
3855 ClientOrderId::new("cid1"),
3856 );
3857
3858 let event = UnindexedAccountEvent::new(
3860 ExchangeId::BinanceSpot,
3861 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
3862 key.clone(),
3863 Side::Buy,
3864 None, Decimal::ZERO,
3866 OrderKind::Market,
3867 TimeInForce::ImmediateOrCancel,
3868 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
3869 ActiveOrderState::OpenInFlight(OpenInFlight),
3870 ),
3871 ))),
3872 );
3873 assert!(
3874 dedup_key_from_event(&event).is_none(),
3875 "OpenInFlight should return None — dedup not meaningful before exchange ack"
3876 );
3877
3878 let event = UnindexedAccountEvent::new(
3880 ExchangeId::BinanceSpot,
3881 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
3882 key.clone(),
3883 Side::Buy,
3884 None, Decimal::ZERO,
3886 OrderKind::Market,
3887 TimeInForce::ImmediateOrCancel,
3888 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
3889 ActiveOrderState::CancelInFlight(CancelInFlight { order: None }),
3890 ),
3891 ))),
3892 );
3893 assert!(
3894 dedup_key_from_event(&event).is_none(),
3895 "CancelInFlight should return None"
3896 );
3897
3898 let event = UnindexedAccountEvent::new(
3900 ExchangeId::BinanceSpot,
3901 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
3902 key,
3903 Side::Buy,
3904 None, Decimal::ONE,
3906 OrderKind::Market,
3907 TimeInForce::ImmediateOrCancel,
3908 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
3909 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
3910 OrderId::new("123"),
3911 Utc::now(),
3912 Decimal::ONE, None,
3914 )),
3915 ),
3916 ))),
3917 );
3918 assert!(
3919 dedup_key_from_event(&event).is_none(),
3920 "Inactive state should return None"
3921 );
3922 }
3923
3924 #[test]
3925 fn test_dedup_key_from_event_cancelled_error_returns_none() {
3926 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3929 x: Some("REJECTED".to_string()),
3930 r: Some("INSUFFICIENT_FUNDS".to_string()),
3931 ..make_base_report()
3932 };
3933 let event = convert_execution_report(report).expect("REJECTED report should produce Some");
3934 assert!(
3935 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
3936 "prerequisite: event is OrderCancelled with Err"
3937 );
3938 assert!(
3939 dedup_key_from_event(&event).is_none(),
3940 "OrderCancelled with Err state should return None"
3941 );
3942 }
3943
3944 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
3954 let mut value = serde_json::to_value(event).expect("event serializes to Value");
3955 value
3956 .as_object_mut()
3957 .expect("event serializes to a JSON object")
3958 .insert(
3959 "e".to_string(),
3960 serde_json::Value::String(event_type.to_string()),
3961 );
3962 serde_json::to_string(&value).expect("frame serializes")
3963 }
3964
3965 #[test]
3966 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
3967 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3968 x: Some("NEW".to_string()),
3969 s_uppercase: Some("BUY".to_string()),
3970 o: Some("LIMIT".to_string()),
3971 p: Some("50000.00".to_string()),
3972 q: Some("0.01".to_string()),
3973 f: Some("GTC".to_string()),
3974 z: Some("0".to_string()),
3975 ..make_base_report()
3976 };
3977 let frame = user_data_frame("executionReport", &report);
3978 let mut buf = Vec::new();
3979 let terminated = convert_user_data_events(&frame, &mut buf);
3980 assert!(
3981 !terminated,
3982 "ExecutionReport should not signal stream termination"
3983 );
3984 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
3985 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
3986 }
3987
3988 #[test]
3989 fn test_convert_user_data_events_account_position_pushes_to_buf() {
3990 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3991 u: Some(1_700_000_000_000),
3992 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
3993 ..Default::default()
3994 };
3995 let frame = user_data_frame("outboundAccountPosition", &position);
3996 let mut buf = Vec::new();
3997 let terminated = convert_user_data_events(&frame, &mut buf);
3998 assert!(
3999 !terminated,
4000 "OutboundAccountPosition should not signal stream termination"
4001 );
4002 assert_eq!(
4003 buf.len(),
4004 1,
4005 "OutboundAccountPosition should push one balance event"
4006 );
4007 }
4008
4009 #[test]
4010 fn test_convert_user_data_events_balance_update_ignored() {
4011 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4012 ..Default::default()
4013 };
4014 let frame = user_data_frame("balanceUpdate", &update);
4015 let mut buf = Vec::new();
4016 let terminated = convert_user_data_events(&frame, &mut buf);
4017 assert!(
4018 !terminated,
4019 "BalanceUpdate should not signal stream termination"
4020 );
4021 assert!(buf.is_empty(), "BalanceUpdate should push no events");
4022 }
4023
4024 #[test]
4025 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4026 let frame = r#"{"e":"eventStreamTerminated","E":1700000000000}"#;
4028 let mut buf = Vec::new();
4029 let terminated = convert_user_data_events(frame, &mut buf);
4030 assert!(
4031 terminated,
4032 "eventStreamTerminated must signal stream termination"
4033 );
4034 assert!(
4035 buf.is_empty(),
4036 "eventStreamTerminated should push no events"
4037 );
4038 }
4039
4040 #[test]
4041 fn test_convert_user_data_events_unknown_event_ignored() {
4042 let frame = r#"{"e":"listStatus","E":1700000000000,"s":"BTCUSDT"}"#;
4045 let mut buf = Vec::new();
4046 let terminated = convert_user_data_events(frame, &mut buf);
4047 assert!(!terminated, "unknown event must not signal termination");
4048 assert!(buf.is_empty(), "unknown event should push no events");
4049 }
4050
4051 #[test]
4052 fn test_convert_user_data_events_non_user_data_frame_ignored() {
4053 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4055 let mut buf = Vec::new();
4056 let terminated = convert_user_data_events(frame, &mut buf);
4057 assert!(
4058 !terminated,
4059 "non-user-data frame must not signal termination"
4060 );
4061 assert!(buf.is_empty(), "non-user-data frame should push no events");
4062 }
4063
4064 #[tokio::test]
4069 async fn test_rate_limit_tracker_not_blocked_initially() {
4070 tokio::time::pause();
4071 let tracker = RateLimitTracker::new();
4072 tokio::time::timeout(
4074 std::time::Duration::from_millis(1),
4075 tracker.wait_if_blocked(),
4076 )
4077 .await
4078 .expect("wait_if_blocked should return immediately when not blocked");
4079 }
4080
4081 #[tokio::test]
4082 async fn test_rate_limit_tracker_blocks_until_deadline() {
4083 tokio::time::pause();
4084 let tracker = RateLimitTracker::new();
4085 let delay = Duration::from_secs(5);
4086 tracker.on_rate_limited(Some(delay));
4087
4088 assert!(
4090 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4091 .await
4092 .is_err(),
4093 "wait_if_blocked should block while cooldown is active"
4094 );
4095
4096 tokio::time::advance(delay + Duration::from_millis(1)).await;
4098 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4099 .await
4100 .expect("wait_if_blocked should return after cooldown expires");
4101 }
4102
4103 #[tokio::test]
4104 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
4105 tokio::time::pause();
4106 let tracker = RateLimitTracker::new();
4107
4108 tracker.on_rate_limited(Some(Duration::from_secs(5)));
4110 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4112
4113 tokio::time::advance(Duration::from_secs(6)).await;
4115 assert!(
4116 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4117 .await
4118 .is_err(),
4119 "cooldown should have been extended to 10s"
4120 );
4121
4122 tokio::time::advance(Duration::from_secs(5)).await;
4124 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4125 .await
4126 .expect("wait_if_blocked should return after extended cooldown expires");
4127 }
4128
4129 #[tokio::test]
4130 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
4131 tokio::time::pause();
4132 let tracker = RateLimitTracker::new();
4133
4134 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4136 tracker.on_rate_limited(Some(Duration::from_secs(2)));
4137
4138 tokio::time::advance(Duration::from_secs(3)).await;
4140 assert!(
4141 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4142 .await
4143 .is_err(),
4144 "shorter on_rate_limited must not shorten existing cooldown"
4145 );
4146 }
4147
4148 #[test]
4153 fn test_parse_binance_api_error_balance_insufficient_holds_instrument_name() {
4154 let instrument = InstrumentNameExchange::new("BTCUSDT");
4155 match parse_binance_api_error("Insufficient balance".into(), &instrument) {
4156 ApiError::BalanceInsufficient(asset_field, _) => {
4157 assert_eq!(
4162 asset_field.name().as_str(),
4163 "BTCUSDT",
4164 "BalanceInsufficient.0 holds the instrument name, not an asset name"
4165 );
4166 }
4167 other => panic!("expected BalanceInsufficient, got {other:?}"),
4168 }
4169 }
4170
4171 #[test]
4176 fn test_is_api_rejection_error_with_wrapped_error_chain() {
4177 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
4182 code: -2010,
4183 message: "insufficient balance".into(),
4184 });
4185 assert!(
4187 is_api_rejection_error(&raw),
4188 "unwrapped ResponseError at root must be detected"
4189 );
4190 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
4192 assert!(
4193 is_api_rejection_error(&wrapped),
4194 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
4195 );
4196 }
4197}