1use super::shared::{
35 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
36 CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
37 MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing, RateLimitTracker,
38 SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, classify_order_kind_tif, connectivity_error,
39 convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
40 dedup_key_from_event, is_api_rejection_error, is_duplicate, is_rate_limit_error,
41 new_dedup_cache, parse_binance_api_error, recovered_order_totals, rest_call_with_retry,
42};
43use crate::{
44 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
45 UnindexedAccountSnapshot,
46 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
47 client::ExecutionClient,
48 emit_stream_terminated,
49 error::{
50 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
51 UnindexedOrderError,
52 },
53 order::{
54 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
55 id::{OrderId, StrategyId, VenueOrderId},
56 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
57 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
58 },
59 parse_env_bool,
60 trade::{AssetFees, Trade, TradeId},
61};
62use binance_sdk::{
63 common::{
64 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
65 models::WebsocketEvent,
66 },
67 spot::{
68 SpotRestApi, SpotWsApi,
69 rest_api::{GetAccountParams, GetOpenOrdersParams, MyTradesParams, RestApi},
70 websocket_api::{
71 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
72 OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
73 WebsocketApiHandle,
74 },
75 },
76};
77use chrono::{DateTime, TimeZone, Utc};
78use futures::stream::BoxStream;
79use rust_decimal::Decimal;
80use rustrade_instrument::{
81 Side,
82 asset::name::AssetNameExchange,
83 exchange::ExchangeId,
84 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
85};
86use serde::Deserialize;
87use smol_str::format_smolstr;
88use std::{
89 str::FromStr,
90 sync::{
91 Arc,
92 atomic::{AtomicBool, Ordering},
93 },
94 time::Duration,
95};
96use tokio::sync::{RwLock, mpsc, oneshot};
97use tracing::{debug, error, info, trace, warn};
98
99#[derive(Clone, Deserialize)]
106pub struct BinanceSpotConfig {
107 api_key: String,
110 secret_key: String,
111
112 #[serde(default = "default_testnet")]
114 pub testnet: bool,
115}
116
117fn default_testnet() -> bool {
123 true
124}
125
126impl std::fmt::Debug for BinanceSpotConfig {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 f.debug_struct("BinanceSpotConfig")
130 .field("api_key", &"***")
131 .field("secret_key", &"***")
132 .field("testnet", &self.testnet)
133 .finish()
134 }
135}
136
137impl BinanceSpotConfig {
138 pub fn new(api_key: String, secret_key: String) -> Self {
141 Self::testnet(api_key, secret_key)
142 }
143
144 pub fn testnet(api_key: String, secret_key: String) -> Self {
146 Self {
147 api_key,
148 secret_key,
149 testnet: true,
150 }
151 }
152
153 pub fn production(api_key: String, secret_key: String) -> Self {
158 Self {
159 api_key,
160 secret_key,
161 testnet: false,
162 }
163 }
164
165 pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
184 let api_key = match std::env::var("BINANCE_API_KEY") {
185 Ok(value) => value,
186 Err(std::env::VarError::NotPresent) => {
187 return Err(BinanceSpotConfigError::MissingApiKey);
188 }
189 Err(std::env::VarError::NotUnicode(_)) => {
190 return Err(BinanceSpotConfigError::InvalidApiKey);
191 }
192 };
193 let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
194 Ok(value) => value,
195 Err(std::env::VarError::NotPresent) => {
196 return Err(BinanceSpotConfigError::MissingSecretKey);
197 }
198 Err(std::env::VarError::NotUnicode(_)) => {
199 return Err(BinanceSpotConfigError::InvalidSecretKey);
200 }
201 };
202 let testnet = match std::env::var("BINANCE_TESTNET") {
203 Ok(value) => {
204 parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
205 }
206 Err(std::env::VarError::NotPresent) => true,
207 Err(std::env::VarError::NotUnicode(value)) => {
210 return Err(BinanceSpotConfigError::InvalidTestnet(
211 value.to_string_lossy().into_owned(),
212 ));
213 }
214 };
215
216 if testnet {
217 Ok(Self::testnet(api_key, secret_key))
218 } else {
219 Ok(Self::production(api_key, secret_key))
220 }
221 }
222
223 pub fn api_key(&self) -> &str {
225 &self.api_key
226 }
227}
228
229#[derive(Debug, PartialEq, thiserror::Error)]
230pub enum BinanceSpotConfigError {
231 #[error("BINANCE_API_KEY environment variable not set")]
232 MissingApiKey,
233
234 #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
237 InvalidApiKey,
238
239 #[error("BINANCE_SECRET_KEY environment variable not set")]
240 MissingSecretKey,
241
242 #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
243 InvalidSecretKey,
244
245 #[error("BINANCE_TESTNET must be true or false, got {0}")]
246 InvalidTestnet(String),
247}
248
249#[derive(Clone)]
258pub struct BinanceSpot {
259 config: Arc<BinanceSpotConfig>,
260 rest: Arc<RestApi>,
261 ws_handle: WebsocketApiHandle,
263 ws_api: Arc<RwLock<Option<WebsocketApi>>>,
269 rate_limiter: Arc<RateLimitTracker>,
271}
272
273impl std::fmt::Debug for BinanceSpot {
274 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275 f.debug_struct("BinanceSpot")
276 .field("testnet", &self.config.testnet)
277 .finish_non_exhaustive()
278 }
279}
280
281impl BinanceSpot {
282 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
286 let rest_config = ConfigurationRestApi::builder()
287 .api_key(config.api_key.clone())
288 .api_secret(config.secret_key.clone())
289 .build()
290 .expect("failed to build Binance REST configuration");
291
292 Arc::new(if config.testnet {
293 SpotRestApi::testnet(rest_config)
294 } else {
295 SpotRestApi::production(rest_config)
296 })
297 }
298
299 #[allow(clippy::expect_used)] fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
303 let ws_config = ConfigurationWebsocketApi::builder()
304 .api_key(config.api_key.clone())
305 .api_secret(config.secret_key.clone())
306 .build()
307 .expect("failed to build Binance WebSocket configuration");
308
309 if config.testnet {
310 SpotWsApi::testnet(ws_config)
311 } else {
312 SpotWsApi::production(ws_config)
313 }
314 }
315
316 async fn get_ws_api(&self) -> anyhow::Result<WebsocketApi> {
320 {
322 let guard = self.ws_api.read().await;
323 if let Some(ref ws) = *guard {
324 return Ok(ws.clone());
325 }
326 }
327 let mut guard = self.ws_api.write().await;
334 if let Some(ref ws) = *guard {
336 return Ok(ws.clone());
337 }
338 let ws = tokio::time::timeout(
339 Duration::from_secs(CONNECT_TIMEOUT_SECS),
340 self.ws_handle.connect(),
341 )
342 .await
343 .map_err(|_| {
344 anyhow::anyhow!("BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
345 })??;
346 *guard = Some(ws.clone());
347 Ok(ws)
348 }
349
350 async fn clear_ws_api(&self) {
353 let ws = {
364 let mut guard = self.ws_api.write().await;
365 guard.take()
366 };
367 if let Some(ws) = ws {
368 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
369 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
370 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
371 Ok(Ok(())) => {}
372 }
373 }
374 }
375}
376
377async fn fetch_open_orders_for_instrument(
384 rest: Arc<RestApi>,
385 rate_limiter: Arc<RateLimitTracker>,
386 instrument: InstrumentNameExchange,
387) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
388 let symbol_str = instrument.name().to_string();
390 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
391 let sym = symbol_str.clone();
392 Box::pin(async move {
393 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
394 rest.get_open_orders(params).await
395 })
396 })
397 .await
398 .map_err(connectivity_error)?;
399
400 let orders_data = response
401 .data()
402 .await
403 .map_err(|e| connectivity_error(e.into()))?;
404
405 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceSpot, &instrument);
406
407 Ok((instrument, listing))
408}
409
410async fn fetch_all_open_orders(
415 rest: Arc<RestApi>,
416 rate_limiter: Arc<RateLimitTracker>,
417) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
418 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
419 Box::pin(async move {
420 let params = GetOpenOrdersParams::builder().build()?;
421 rest.get_open_orders(params).await
422 })
423 })
424 .await
425 .map_err(connectivity_error)?;
426
427 let orders_data = response
428 .data()
429 .await
430 .map_err(|e| connectivity_error(e.into()))?;
431
432 let orders = orders_data
433 .into_iter()
434 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot))
435 .collect();
436
437 Ok(orders)
438}
439
440async fn paginate_my_trades(
450 rest: &Arc<RestApi>,
451 rate_limiter: &Arc<RateLimitTracker>,
452 instrument: &InstrumentNameExchange,
453 from: MyTradesFrom,
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 Box::pin(async move {
468 #[allow(clippy::cast_possible_truncation)]
470 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
471 let params = match (from, fid) {
472 (MyTradesFrom::Time(start_time_ms), None) => builder.start_time(start_time_ms),
473 (MyTradesFrom::Time(_), Some(id)) => builder.from_id(id),
474 (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
475 (MyTradesFrom::Order(order_id), Some(id)) => {
476 builder.order_id(order_id).from_id(id)
477 }
478 }
479 .build()?;
480 rest.my_trades(params).await
481 })
482 })
483 .await
484 .map_err(connectivity_error)?;
485
486 let page = response
487 .data()
488 .await
489 .map_err(|e| connectivity_error(e.into()))?;
490
491 let page_len = page.len();
492 let last_id = page.last().and_then(|t| t.id);
493 all_pages.extend(page);
494
495 if page_len < BINANCE_MAX_TRADES {
496 break;
497 }
498 match last_id {
499 Some(id) => {
500 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
501 match id.checked_add(1) {
502 Some(next) => cursor = Some(next),
503 None => break, }
505 }
506 None => {
507 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
508 break;
509 }
510 }
511 }
512 Ok(all_pages)
513}
514
515impl ExecutionClient for BinanceSpot {
520 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
521
522 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
525 &[InstrumentKindDiscriminant::Spot];
526 type Config = BinanceSpotConfig;
527 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
528
529 fn new(config: Self::Config) -> Self {
534 let rest = Self::build_rest(&config);
535 let ws_handle = Self::build_ws_handle(&config);
536 Self {
537 config: Arc::new(config),
538 rest,
539 ws_handle,
540 ws_api: Arc::new(RwLock::new(None)),
541 rate_limiter: Arc::new(RateLimitTracker::new()),
542 }
543 }
544
545 async fn account_snapshot(
546 &self,
547 assets: &[AssetNameExchange],
548 instruments: &[InstrumentNameExchange],
549 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
550 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
552 Box::pin(async move {
553 let params = GetAccountParams::builder().build()?;
554 rest.get_account(params).await
555 })
556 })
557 .await
558 .map_err(connectivity_error)?;
559
560 let account = response
561 .data()
562 .await
563 .map_err(|e| connectivity_error(e.into()))?;
564
565 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
567
568 use futures::{StreamExt as _, TryStreamExt};
574 let instrument_snapshots: Vec<_> =
575 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
576 fetch_open_orders_for_instrument(
577 self.rest.clone(),
578 self.rate_limiter.clone(),
579 instrument,
580 )
581 }))
582 .buffer_unordered(8)
583 .map(|result| {
584 let (inst, listing) = result?;
585 let wrapped = listing
586 .orders
587 .into_iter()
588 .map(|o| Order {
589 key: o.key,
590 side: o.side,
591 price: o.price,
592 quantity: o.quantity,
593 kind: o.kind,
594 time_in_force: o.time_in_force,
595 state: OrderState::active(o.state),
596 })
597 .collect();
598 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
599 inst,
600 wrapped,
601 listing.complete,
602 None,
603 None,
604 ))
605 })
606 .try_collect()
607 .await?;
608
609 Ok(AccountSnapshot::new(
610 ExchangeId::BinanceSpot,
611 balances,
612 instrument_snapshots,
613 ))
614 }
615
616 async fn account_stream(
647 &self,
648 _assets: &[AssetNameExchange],
652 instruments: &[InstrumentNameExchange],
653 ) -> Result<Self::AccountStream, UnindexedClientError> {
654 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
669 let dedup = new_dedup_cache();
670 let ws_handle = self.ws_handle.clone();
671 let rest = self.rest.clone();
672 let rate_limiter = self.rate_limiter.clone();
673 let instruments = instruments.to_vec();
674 debug_assert!(
678 instruments.iter().all(|i| i.name().len() <= 23),
679 "instrument name exceeds SmolStr inline capacity: {:?}",
680 instruments.iter().find(|i| i.name().len() > 23)
681 );
682
683 let initial_ws = ws_handle.connect().await.map_err(|e| {
687 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
688 })?;
689
690 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
692 .build()
693 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
694
695 match initial_ws
696 .user_data_stream_subscribe_signature(params)
697 .await
698 {
699 Ok(_) => {}
700 Err(e) => {
701 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
706 Ok(Err(de)) => {
707 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
708 }
709 Err(_) => {
710 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
711 }
712 Ok(Ok(())) => {}
713 }
714 return Err(UnindexedClientError::Internal(e.to_string()));
715 }
716 }
717
718 let cm_handle = tokio::spawn(connection_manager(
730 tx,
731 dedup,
732 ws_handle,
733 rest,
734 rate_limiter,
735 instruments,
736 Some(initial_ws),
737 ));
738
739 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
743 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
744 Ok(futures::StreamExt::boxed(guarded_stream))
745 }
746
747 async fn cancel_order(
748 &self,
749 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
750 ) -> Option<UnindexedOrderResponseCancel> {
751 let instrument = request.key.instrument.clone();
752 let key = OrderKey {
753 exchange: request.key.exchange,
754 instrument: instrument.clone(),
755 strategy: request.key.strategy.clone(),
756 cid: request.key.cid.clone(),
757 };
758
759 let ws = match self.get_ws_api().await {
760 Ok(ws) => ws,
761 Err(e) => {
762 return Some(UnindexedOrderResponseCancel {
763 key,
764 state: Err(UnindexedOrderError::Connectivity(
765 ConnectivityError::Socket(format!("{e:#}")),
766 )),
767 });
768 }
769 };
770
771 let mut params_builder =
774 OrderCancelParams::builder(request.key.instrument.name().to_string());
775
776 if let Some(order_id) = request.state.id.as_ref().and_then(VenueOrderId::assigned) {
778 if let Ok(id) = order_id.0.parse::<i64>() {
779 params_builder = params_builder.order_id(id);
780 } else {
781 error!(
786 order_id = %order_id.0,
787 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
788 );
789 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
790 }
791 } else {
792 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
793 }
794
795 let params = match params_builder.build() {
796 Ok(p) => p,
797 Err(e) => {
798 error!(%e, "BinanceSpot failed to build cancel order params");
799 return Some(UnindexedOrderResponseCancel {
800 key,
801 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
802 e.to_string(),
803 ))),
804 });
805 }
806 };
807
808 match ws.order_cancel(params).await {
809 Ok(response) => match response.data() {
810 Ok(data) => {
811 let time_exchange = data
812 .transact_time
813 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
814 .unwrap_or_else(Utc::now);
815
816 let exchange_order_id = match data.order_id {
817 Some(id) => OrderId(format_smolstr!("{id}")),
818 None => {
819 error!("BinanceSpot cancel response missing orderId");
820 return Some(UnindexedOrderResponseCancel {
821 key,
822 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
823 "cancel response missing orderId".into(),
824 ))),
825 });
826 }
827 };
828
829 let filled_qty = data
830 .executed_qty
831 .as_deref()
832 .and_then(|q| Decimal::from_str(q).ok())
833 .unwrap_or(Decimal::ZERO);
834
835 Some(UnindexedOrderResponseCancel {
836 key,
837 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
838 })
839 }
840 Err(e) => {
841 Some(UnindexedOrderResponseCancel {
843 key,
844 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
845 e.to_string(),
846 ))),
847 })
848 }
849 },
850 Err(e) => {
851 if is_api_rejection_error(&e) {
858 let api_err = parse_binance_api_error(e.to_string(), &instrument);
860 Some(UnindexedOrderResponseCancel {
865 key,
866 state: Err(UnindexedOrderError::from(api_err)),
867 })
868 } else if is_rate_limit_error(&e) {
869 self.rate_limiter.on_rate_limited(None);
871 Some(UnindexedOrderResponseCancel {
872 key,
873 state: Err(UnindexedOrderError::from(ApiError::RateLimit)),
874 })
875 } else {
876 self.clear_ws_api().await;
879 Some(UnindexedOrderResponseCancel {
880 key,
881 state: Err(UnindexedOrderError::Connectivity(
882 ConnectivityError::Socket(format!("{e:#}")),
883 )),
884 })
885 }
886 }
887 }
888 }
889
890 async fn open_order(
891 &self,
892 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
893 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
894 let instrument = request.key.instrument.clone();
895 let side = request.state.side;
896 let price = request.state.price;
897 let quantity = request.state.quantity;
898 let kind = request.state.kind;
899 let time_in_force = request.state.time_in_force;
900 let cid = request.key.cid.clone();
901
902 let order_key = OrderKey::new(
903 ExchangeId::BinanceSpot,
904 instrument.clone(),
905 request.key.strategy.clone(),
906 cid.clone(),
907 );
908
909 let ws = match self.get_ws_api().await {
910 Ok(ws) => ws,
911 Err(e) => {
912 return Some(Order {
913 key: order_key,
914 side,
915 price,
916 quantity,
917 kind,
918 time_in_force,
919 state: OrderState::inactive(OrderError::Connectivity(
920 ConnectivityError::Socket(format!("{e:#}")),
921 )),
922 });
923 }
924 };
925
926 let binance_side = match side {
927 Side::Buy => OrderPlaceSideEnum::Buy,
928 Side::Sell => OrderPlaceSideEnum::Sell,
929 };
930
931 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
932 Some(converted) => converted,
933 None => {
934 return Some(Order {
935 key: order_key,
936 side,
937 price,
938 quantity,
939 kind,
940 time_in_force,
941 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
942 "Binance Spot does not yet support OrderKind::{kind:?}"
943 ))),
944 });
945 }
946 };
947
948 let mut params_builder =
955 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
956 .quantity(quantity)
957 .new_client_order_id(cid.0.to_string());
958
959 match kind {
962 OrderKind::Limit => {
963 params_builder = params_builder.price(price);
964 }
965 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
966 params_builder = params_builder.stop_price(trigger_price);
967 }
968 OrderKind::StopLimit { trigger_price }
969 | OrderKind::TakeProfitLimit { trigger_price } => {
970 params_builder = params_builder.price(price).stop_price(trigger_price);
971 }
972 OrderKind::TrailingStop {
973 offset,
974 offset_type,
975 } => {
976 let basis_points: i32 = match offset_type {
978 TrailingOffsetType::BasisPoints => {
979 let Ok(bp) = i32::try_from(offset) else {
980 return Some(Order {
981 key: order_key,
982 side,
983 price,
984 quantity,
985 kind,
986 time_in_force,
987 state: OrderState::inactive(OrderError::UnsupportedOrderType(
988 format!(
989 "TrailingStop basis-point offset {offset} overflows i32 \
990 (Binance trailingDelta filter typically caps at 2000)"
991 ),
992 )),
993 });
994 };
995 bp
996 }
997 TrailingOffsetType::Percentage => {
998 let Ok(bp) = i32::try_from(offset * Decimal::from(100)) else {
999 return Some(Order {
1000 key: order_key,
1001 side,
1002 price,
1003 quantity,
1004 kind,
1005 time_in_force,
1006 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1007 format!(
1008 "TrailingStop percentage offset {offset} overflows i32 \
1009 after scaling to basis points"
1010 ),
1011 )),
1012 });
1013 };
1014 bp
1015 }
1016 TrailingOffsetType::Absolute => {
1017 return Some(Order {
1021 key: order_key,
1022 side,
1023 price,
1024 quantity,
1025 kind,
1026 time_in_force,
1027 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1028 "TrailingStop with Absolute offset is not supported by Binance; \
1029 convert to basis points: (absolute / price) * 10000"
1030 .into(),
1031 )),
1032 });
1033 }
1034 };
1035 params_builder = params_builder.trailing_delta(basis_points);
1036 }
1037 _ => {}
1041 }
1042
1043 if let Some(tif) = binance_tif {
1044 params_builder = params_builder.time_in_force(tif);
1045 }
1046
1047 let params = match params_builder.build() {
1048 Ok(p) => p,
1049 Err(e) => {
1050 error!(%e, "BinanceSpot failed to build new order params");
1051 return Some(Order {
1052 key: order_key,
1053 side,
1054 price,
1055 quantity,
1056 kind,
1057 time_in_force,
1058 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1059 e.to_string(),
1060 ))),
1061 });
1062 }
1063 };
1064
1065 match ws.order_place(params).await {
1066 Ok(response) => match response.data() {
1067 Ok(data) => {
1068 let time_exchange = data
1069 .transact_time
1070 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1071 .unwrap_or_else(Utc::now);
1072
1073 let exchange_order_id = match data.order_id {
1074 Some(id) => OrderId(format_smolstr!("{id}")),
1075 None => {
1076 error!("BinanceSpot open_order response missing orderId");
1077 return Some(Order {
1078 key: order_key,
1079 side,
1080 price,
1081 quantity,
1082 kind,
1083 time_in_force,
1084 state: OrderState::inactive(OrderError::Rejected(
1085 ApiError::OrderRejected(
1086 "open_order response missing orderId".into(),
1087 ),
1088 )),
1089 });
1090 }
1091 };
1092
1093 let filled_qty = data
1094 .executed_qty
1095 .as_deref()
1096 .and_then(|q| Decimal::from_str(q).ok())
1097 .unwrap_or(Decimal::ZERO);
1098
1099 let state = if filled_qty >= quantity {
1100 OrderState::fully_filled(Filled::new(
1102 exchange_order_id,
1103 time_exchange,
1104 filled_qty,
1105 None, ))
1107 } else {
1108 OrderState::active(Open::new(
1110 VenueOrderId::Assigned(exchange_order_id),
1111 time_exchange,
1112 filled_qty,
1113 ))
1114 };
1115
1116 Some(Order {
1117 key: order_key,
1118 side,
1119 price,
1120 quantity,
1121 kind,
1122 time_in_force,
1123 state,
1124 })
1125 }
1126 Err(e) => {
1127 Some(Order {
1129 key: order_key,
1130 side,
1131 price,
1132 quantity,
1133 kind,
1134 time_in_force,
1135 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1136 e.to_string(),
1137 ))),
1138 })
1139 }
1140 },
1141 Err(e) => {
1142 if is_api_rejection_error(&e) {
1149 let api_err = parse_binance_api_error(e.to_string(), &instrument);
1151 Some(Order {
1156 key: order_key,
1157 side,
1158 price,
1159 quantity,
1160 kind,
1161 time_in_force,
1162 state: OrderState::inactive(OrderError::from(api_err)),
1163 })
1164 } else if is_rate_limit_error(&e) {
1165 self.rate_limiter.on_rate_limited(None);
1167 Some(Order {
1168 key: order_key,
1169 side,
1170 price,
1171 quantity,
1172 kind,
1173 time_in_force,
1174 state: OrderState::inactive(OrderError::from(ApiError::RateLimit)),
1175 })
1176 } else {
1177 self.clear_ws_api().await;
1180 Some(Order {
1181 key: order_key,
1182 side,
1183 price,
1184 quantity,
1185 kind,
1186 time_in_force,
1187 state: OrderState::inactive(OrderError::Connectivity(
1188 ConnectivityError::Socket(format!("{e:#}")),
1189 )),
1190 })
1191 }
1192 }
1193 }
1194 }
1195
1196 async fn fetch_balances(
1197 &self,
1198 assets: &[AssetNameExchange],
1199 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1200 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
1201 Box::pin(async move {
1202 let params = GetAccountParams::builder().build()?;
1203 rest.get_account(params).await
1204 })
1205 })
1206 .await
1207 .map_err(connectivity_error)?;
1208
1209 let account = response
1210 .data()
1211 .await
1212 .map_err(|e| connectivity_error(e.into()))?;
1213
1214 Ok(filter_and_convert_balances(
1215 account.balances.unwrap_or_default(),
1216 assets,
1217 ))
1218 }
1219
1220 async fn fetch_open_orders(
1225 &self,
1226 instruments: &[InstrumentNameExchange],
1227 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1228 if instruments.is_empty() {
1231 return fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await;
1232 }
1233 use futures::{StreamExt as _, TryStreamExt as _};
1238 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1239 fetch_open_orders_for_instrument(
1240 self.rest.clone(),
1241 self.rate_limiter.clone(),
1242 instrument,
1243 )
1244 }))
1245 .buffer_unordered(8)
1246 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1247 acc.extend(listing.orders);
1248 Ok(acc)
1249 })
1250 .await
1251 }
1252
1253 #[allow(clippy::redundant_iter_cloned)]
1261 async fn fetch_trades(
1262 &self,
1263 time_since: DateTime<Utc>,
1264 instruments: &[InstrumentNameExchange],
1265 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1266 use futures::StreamExt;
1267
1268 if instruments.is_empty() {
1269 debug!(
1270 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1271 );
1272 return Ok(Vec::new());
1273 }
1274 let start_time_ms = time_since.timestamp_millis();
1275 let mut all_trades = Vec::new();
1278
1279 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1283 let rest = self.rest.clone();
1284 let rate_limiter = self.rate_limiter.clone();
1285 async move {
1286 let pages = paginate_my_trades(
1287 &rest,
1288 &rate_limiter,
1289 &inst,
1290 MyTradesFrom::Time(start_time_ms),
1291 )
1292 .await?;
1293 Ok::<_, UnindexedClientError>((inst, pages))
1294 }
1295 }))
1296 .buffer_unordered(8);
1297 while let Some(result) = stream.next().await {
1298 let (instrument, trades_data) = result?;
1299 for t in trades_data {
1300 if let Some(trade) = convert_my_trade(&t, &instrument) {
1301 all_trades.push(trade);
1302 }
1303 }
1304 }
1305
1306 Ok(all_trades)
1307 }
1308}
1309
1310async fn connect_and_subscribe(ws_handle: &WebsocketApiHandle) -> anyhow::Result<WebsocketApi> {
1317 let ws = ws_handle.connect().await?;
1318 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1320 .build()
1321 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1322 match ws.user_data_stream_subscribe_signature(params).await {
1323 Ok(_) => Ok(ws),
1324 Err(e) => {
1325 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1326 let ws_cleanup = ws;
1327 tokio::spawn(async move {
1337 match tokio::time::timeout(Duration::from_secs(5), ws_cleanup.disconnect()).await {
1338 Ok(Err(dc_err)) => warn!(%dc_err, "BinanceSpot WS cleanup disconnect failed"),
1339 Err(_) => warn!("BinanceSpot WS cleanup disconnect timed out (5s)"),
1340 Ok(Ok(())) => {}
1341 }
1342 });
1343 Err(e)
1344 }
1345 }
1346}
1347
1348#[allow(clippy::cognitive_complexity)]
1370async fn connection_manager(
1371 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1372 dedup: SharedDedupCache,
1373 ws_handle: WebsocketApiHandle,
1374 rest: Arc<RestApi>,
1375 rate_limiter: Arc<RateLimitTracker>,
1376 instruments: Vec<InstrumentNameExchange>,
1377 initial_ws: Option<WebsocketApi>,
1378) {
1379 let mut backoff = ExponentialBackoff::new();
1380 let mut disconnect_time: Option<DateTime<Utc>> = None;
1381 let mut current_ws = initial_ws;
1382
1383 loop {
1384 let ws = match current_ws.take() {
1386 Some(ws) => ws,
1387 None => match connect_and_subscribe(&ws_handle).await {
1388 Ok(ws) => ws,
1394 Err(e) => {
1395 error!(%e, "BinanceSpot WS connect/subscribe failed");
1396 if !backoff.wait().await {
1397 error!("BinanceSpot max reconnect attempts exhausted");
1398 emit_stream_terminated(
1400 &tx,
1401 ExchangeId::BinanceSpot,
1402 StreamTerminationReason::ReconnectBudgetExhausted {
1403 attempts: backoff.attempts(),
1404 last_error: e.to_string(),
1405 },
1406 );
1407 break;
1408 }
1409 continue;
1410 }
1411 },
1412 };
1413 info!("BinanceSpot account_stream connected and subscribed");
1414
1415 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1421 let mut signal_tx_opt = Some(signal_tx);
1422 let heartbeat_flag = Arc::new(AtomicBool::new(true));
1426 let hb_callback = heartbeat_flag.clone();
1427 let dedup_callback = dedup.clone();
1428 let mut event_tx = Some(tx.clone());
1432 let mut event_buf = Vec::with_capacity(32);
1436
1437 let subscription = ws.subscribe_on_ws_events(move |event| {
1444 let Some(ref sender) = event_tx else { return };
1445 match event {
1446 WebsocketEvent::Message(json_str) => {
1447 hb_callback.store(true, Ordering::Release);
1450 let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1454 for ev in event_buf.drain(..) {
1455 if let Some(key) = dedup_key_from_event(&ev)
1457 && is_duplicate(&dedup_callback, key)
1458 {
1459 trace!("BinanceSpot dedup: skipping duplicate event");
1460 continue;
1461 }
1462 if sender.send(ev).is_err() {
1463 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1464 event_tx.take();
1465 if let Some(s) = signal_tx_opt.take() {
1466 let _ = s.send(());
1467 }
1468 return;
1469 }
1470 }
1471 if stream_terminated {
1474 event_tx.take();
1475 if let Some(s) = signal_tx_opt.take() {
1476 let _ = s.send(());
1477 }
1478 }
1479 }
1480 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1481 hb_callback.store(true, Ordering::Release);
1484 }
1485 WebsocketEvent::Error(e) => {
1486 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1490 event_tx.take();
1491 if let Some(s) = signal_tx_opt.take() {
1492 let _ = s.send(());
1493 }
1494 }
1495 WebsocketEvent::Close(code, reason) => {
1496 warn!(code, %reason, "BinanceSpot WebSocket closed");
1497 event_tx.take();
1498 if let Some(s) = signal_tx_opt.take() {
1499 let _ = s.send(());
1500 }
1501 }
1502 _ => {}
1503 }
1504 });
1505
1506 if let Some(dt) = disconnect_time.take() {
1515 match tokio::time::timeout(
1516 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
1517 recover_fills(&rest, &rate_limiter, &instruments, dt, &tx, &dedup),
1518 )
1519 .await
1520 {
1521 Ok(()) => {}
1522 Err(_) => {
1523 warn!(
1528 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
1529 "BinanceSpot fill recovery timed out — remaining instruments not queried, some fills may be missing"
1530 );
1531 }
1532 }
1533 }
1534
1535 enum DisconnectReason {
1537 Signal,
1538 HeartbeatTimeout,
1539 ConsumerDropped,
1540 }
1541 let reason = {
1542 let mut signal_rx = signal_rx;
1543 loop {
1544 tokio::select! {
1545 _ = tx.closed() => {
1546 debug!("BinanceSpot account_stream consumer dropped, terminating");
1547 break DisconnectReason::ConsumerDropped;
1548 }
1549 _ = &mut signal_rx => {
1550 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1551 break DisconnectReason::Signal;
1552 }
1553 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1554 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1561 backoff.reset();
1565 continue;
1566 }
1567 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1568 break DisconnectReason::HeartbeatTimeout;
1569 }
1570 }
1571 }
1572 };
1573 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1574
1575 if should_reconnect {
1582 disconnect_time = Some(match reason {
1583 DisconnectReason::HeartbeatTimeout => {
1584 Utc::now()
1585 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1586 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1587 }
1588 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1589 });
1590 }
1591
1592 subscription.unsubscribe();
1600 if let Err(e) = ws.disconnect().await {
1603 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1604 }
1605
1606 if !should_reconnect || tx.is_closed() {
1607 debug!("BinanceSpot connection manager exiting");
1610 break;
1611 }
1612 if !backoff.wait().await {
1613 error!("BinanceSpot max reconnect attempts exhausted, stream terminating");
1614 let last_error = match reason {
1617 DisconnectReason::HeartbeatTimeout => {
1618 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
1619 }
1620 _ => "WebSocket disconnected (server close/error)".to_string(),
1621 };
1622 emit_stream_terminated(
1623 &tx,
1624 ExchangeId::BinanceSpot,
1625 StreamTerminationReason::ReconnectBudgetExhausted {
1626 attempts: backoff.attempts(),
1627 last_error,
1628 },
1629 );
1630 break;
1631 }
1632 }
1633}
1634
1635#[allow(clippy::redundant_iter_cloned)]
1648async fn recover_fills(
1649 rest: &Arc<RestApi>,
1650 rate_limiter: &Arc<RateLimitTracker>,
1651 instruments: &[InstrumentNameExchange],
1652 disconnect_time: DateTime<Utc>,
1653 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1654 dedup: &SharedDedupCache,
1655) {
1656 use futures::StreamExt;
1657
1658 if instruments.is_empty() {
1659 debug!(
1660 "BinanceSpot recover_fills called with empty instruments slice — no fills will be recovered"
1661 );
1662 return;
1663 }
1664 info!(
1665 since = %disconnect_time,
1666 instruments = instruments.len(),
1667 "BinanceSpot recovering fills after reconnect"
1668 );
1669
1670 let start_time_ms = disconnect_time.timestamp_millis();
1671 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
1672 let mut recovered = 0u32;
1673 let mut duplicates = 0u32;
1674 let mut failed_instruments = 0u32;
1675
1676 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1683 let rest = rest.clone();
1684 let rl = rate_limiter.clone();
1685 async move {
1686 let raw = match paginate_my_trades(&rest, &rl, &inst, MyTradesFrom::Time(start_time_ms))
1687 .await
1688 {
1689 Ok(pages) => pages,
1690 Err(e) => {
1691 warn!(%e, %inst, "BinanceSpot fill recovery: REST request failed");
1692 return None;
1693 }
1694 };
1695 let totals = recovered_order_totals(
1698 ExchangeId::BinanceSpot,
1699 &inst,
1700 &raw,
1701 order_executions_deadline,
1702 |order_id| paginate_my_trades(&rest, &rl, &inst, MyTradesFrom::Order(order_id)),
1703 )
1704 .await;
1705 let trades: Vec<_> = raw
1706 .iter()
1707 .filter_map(|t| {
1708 let mut trade = convert_my_trade(t, &inst)?;
1709 trade.order_filled_quantity = t.id.and_then(|id| totals.get(&id).copied());
1710 Some(trade)
1711 })
1712 .collect();
1713 Some(trades)
1714 }
1715 }))
1716 .buffer_unordered(8);
1717 while let Some(result) = stream.next().await {
1718 let trades = match result {
1719 Some(t) => t,
1720 None => {
1721 failed_instruments += 1;
1722 continue;
1723 }
1724 };
1725 for trade in trades {
1726 let event =
1729 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
1730 if let Some(key) = dedup_key_from_event(&event)
1733 && is_duplicate(dedup, key)
1734 {
1735 duplicates += 1;
1736 continue;
1737 }
1738 if tx.send(event).is_err() {
1739 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
1743 return;
1744 }
1745 recovered += 1;
1746 }
1747 }
1748
1749 if failed_instruments > 0 {
1750 error!(
1751 recovered,
1752 duplicates,
1753 failed_instruments,
1754 "BinanceSpot fill recovery complete with failures — some fills may be permanently missed"
1755 );
1756 } else {
1757 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
1758 }
1759}
1760
1761fn convert_balance_entry(
1775 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
1776 now: chrono::DateTime<Utc>,
1777) -> Option<AssetBalance<AssetNameExchange>> {
1778 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
1779 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1780 Some(v) => v,
1781 None => {
1782 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
1783 return None;
1784 }
1785 };
1786 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1787 Some(v) => v,
1788 None => {
1789 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
1790 return None;
1791 }
1792 };
1793 Some(AssetBalance::new(
1794 asset_name,
1795 Balance::new(free + locked, free),
1796 now,
1797 ))
1798}
1799
1800fn filter_and_convert_balances(
1801 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
1802 assets: &[AssetNameExchange],
1803) -> Vec<AssetBalance<AssetNameExchange>> {
1804 let now = Utc::now();
1805 if assets.is_empty() {
1807 return balances
1808 .into_iter()
1809 .filter_map(|b| convert_balance_entry(b, now))
1810 .collect();
1811 }
1812 if assets.len() <= 16 {
1815 return balances
1816 .into_iter()
1817 .filter_map(|b| {
1818 let asset_name_str = b.asset.as_deref()?;
1819 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
1820 return None;
1821 }
1822 convert_balance_entry(b, now)
1823 })
1824 .collect();
1825 }
1826 use std::collections::HashSet;
1827 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
1828 balances
1829 .into_iter()
1830 .filter_map(|b| {
1831 let asset_name_str = b.asset.as_deref()?;
1832 if !asset_set.contains(asset_name_str) {
1833 return None;
1834 }
1835 convert_balance_entry(b, now)
1836 })
1837 .collect()
1838}
1839
1840fn convert_my_trade(
1842 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
1843 instrument: &InstrumentNameExchange,
1844) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
1845 let trade_id_raw = match t.id {
1846 Some(id) => id,
1847 None => {
1848 warn!(%instrument, "BinanceSpot trade missing id");
1849 return None;
1850 }
1851 };
1852 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
1853 let order_id = match t.order_id {
1854 Some(id) => OrderId(format_smolstr!("{}", id)),
1855 None => {
1856 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
1857 return None;
1858 }
1859 };
1860 let side = match t.is_buyer {
1861 Some(is_buyer) => {
1862 if is_buyer {
1863 Side::Buy
1864 } else {
1865 Side::Sell
1866 }
1867 }
1868 None => {
1869 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
1870 return None;
1871 }
1872 };
1873 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1874 Some(v) => v,
1875 None => {
1876 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
1877 return None;
1878 }
1879 };
1880 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1881 Some(v) => v,
1882 None => {
1883 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
1884 return None;
1885 }
1886 };
1887 let commission = t
1888 .commission
1889 .as_deref()
1890 .and_then(|s| Decimal::from_str(s).ok())
1891 .unwrap_or(Decimal::ZERO);
1892 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
1893 Some(ts) => ts,
1894 None => {
1895 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
1896 Utc::now()
1897 }
1898 };
1899
1900 let fee_asset = t
1905 .commission_asset
1906 .as_deref()
1907 .map(AssetNameExchange::from)
1908 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
1909
1910 Some(Trade::new(
1911 trade_id,
1912 order_id,
1913 instrument.clone(),
1914 StrategyId::unknown(), time_exchange,
1916 side,
1917 price,
1918 quantity,
1919 None,
1921 AssetFees::new(fee_asset, commission, None),
1922 ))
1923}
1924
1925fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1942 #[derive(Deserialize)]
1947 struct EventTag<'a> {
1948 #[serde(borrow, default)]
1949 e: Option<&'a str>,
1950 }
1951
1952 let event_type = serde_json::from_str::<EventTag<'_>>(frame)
1953 .ok()
1954 .and_then(|tag| tag.e)
1955 .unwrap_or_default();
1956 match event_type {
1957 "executionReport" => {
1958 match serde_json::from_str::<binance_sdk::spot::websocket_api::ExecutionReport>(frame) {
1961 Ok(report) => {
1962 convert_execution_report(&report, ExchangeId::BinanceSpot, buf);
1963 }
1964 Err(e) => {
1965 warn!(error = %e, "BinanceSpot: undeserializable executionReport, dropping")
1966 }
1967 }
1968 false
1969 }
1970 "outboundAccountPosition" => {
1971 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
1972 frame,
1973 ) {
1974 Ok(position) => convert_account_position(position, buf),
1975 Err(e) => {
1976 warn!(error = %e, "BinanceSpot: undeserializable outboundAccountPosition, dropping")
1977 }
1978 }
1979 false
1980 }
1981 "balanceUpdate" => {
1982 false
1989 }
1990 "eventStreamTerminated" => {
1991 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
1995 true
1996 }
1997 _ => {
2000 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2001 false
2002 }
2003 }
2004}
2005
2006fn convert_account_position(
2011 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2012 buf: &mut Vec<UnindexedAccountEvent>,
2013) {
2014 let time_exchange = position
2017 .u
2018 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2019 .unwrap_or_else(Utc::now);
2020
2021 for b in position.b_uppercase.unwrap_or_default() {
2022 let asset = match b.a {
2023 Some(a) => AssetNameExchange::new(a),
2024 None => {
2025 warn!("BinanceSpot account position entry missing asset name");
2026 continue;
2027 }
2028 };
2029 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2030 Some(v) => v,
2031 None => {
2032 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2033 continue;
2034 }
2035 };
2036 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2037 Some(v) => v,
2038 None => {
2039 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2040 continue;
2041 }
2042 };
2043 let update =
2048 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2049 buf.push(UnindexedAccountEvent::new(
2050 ExchangeId::BinanceSpot,
2051 AccountEventKind::BalanceStreamUpdate(
2052 rustrade_integration::collection::snapshot::Snapshot::new(update),
2053 ),
2054 ));
2055 }
2056}
2057
2058fn convert_order_kind_tif(
2085 kind: OrderKind,
2086 tif: TimeInForce,
2087) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2088 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2092 let type_enum = match binance_type {
2093 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2094 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2095 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2096 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2097 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2098 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2099 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2100 };
2101 let tif_enum = binance_tif.map(|t| match t {
2102 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2103 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2104 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2105 });
2106 Some((type_enum, tif_enum))
2107}
2108
2109#[cfg(test)]
2110#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2112 use super::*;
2113 use crate::client::binance::shared::*;
2114 use crate::client::dedup::{DedupEventKind, DedupKey};
2115 use crate::order::{TrailingOffsetType, id::ClientOrderId};
2116 use binance_sdk::common::errors::WebsocketError;
2117 use smol_str::SmolStr;
2118
2119 #[test]
2120 fn test_binance_spot_config_new_uses_testnet_by_default() {
2121 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2122 assert!(cfg.testnet);
2123 assert_eq!(cfg.api_key(), "my_key");
2124 }
2125
2126 #[test]
2127 fn test_binance_spot_config_production_is_explicit() {
2128 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2129 assert!(!cfg.testnet);
2130 }
2131
2132 #[test]
2133 fn test_binance_spot_config_debug_redacts_credentials() {
2134 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2135 let debug = format!("{cfg:?}");
2136
2137 assert!(!debug.contains("my_key"), "api_key should be redacted");
2138 assert!(
2139 !debug.contains("my_secret"),
2140 "secret_key should be redacted"
2141 );
2142 assert!(debug.contains("testnet: true"));
2143 }
2144
2145 #[test]
2146 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2147 let cfg: BinanceSpotConfig = serde_json::from_str(
2148 r#"{
2149 "api_key": "my_key",
2150 "secret_key": "my_secret"
2151 }"#,
2152 )
2153 .unwrap();
2154
2155 assert!(cfg.testnet);
2156 assert_eq!(cfg.api_key(), "my_key");
2157 }
2158
2159 #[test]
2160 fn test_binance_spot_config_deserialize_testnet_true() {
2161 let cfg: BinanceSpotConfig = serde_json::from_str(
2162 r#"{
2163 "api_key": "my_key",
2164 "secret_key": "my_secret",
2165 "testnet": true
2166 }"#,
2167 )
2168 .unwrap();
2169
2170 assert!(cfg.testnet);
2171 }
2172
2173 #[test]
2174 fn test_binance_spot_config_deserialize_testnet_false() {
2175 let cfg: BinanceSpotConfig = serde_json::from_str(
2176 r#"{
2177 "api_key": "my_key",
2178 "secret_key": "my_secret",
2179 "testnet": false
2180 }"#,
2181 )
2182 .unwrap();
2183
2184 assert!(!cfg.testnet);
2185 }
2186
2187 #[test]
2188 #[serial_test::serial]
2189 fn test_binance_spot_config_from_env_defaults_to_testnet() {
2190 temp_env::with_vars(
2191 [
2192 ("BINANCE_API_KEY", Some("my_key")),
2193 ("BINANCE_SECRET_KEY", Some("my_secret")),
2194 ("BINANCE_TESTNET", None),
2195 ],
2196 || {
2197 let cfg = BinanceSpotConfig::from_env().unwrap();
2198 assert!(cfg.testnet);
2199 assert_eq!(cfg.api_key(), "my_key");
2200 },
2201 );
2202 }
2203
2204 #[test]
2205 #[serial_test::serial]
2206 fn test_binance_spot_config_from_env_accepts_explicit_production() {
2207 temp_env::with_vars(
2208 [
2209 ("BINANCE_API_KEY", Some("my_key")),
2210 ("BINANCE_SECRET_KEY", Some("my_secret")),
2211 ("BINANCE_TESTNET", Some("false")),
2212 ],
2213 || {
2214 let cfg = BinanceSpotConfig::from_env().unwrap();
2215 assert!(!cfg.testnet);
2216 },
2217 );
2218 }
2219
2220 #[test]
2221 #[serial_test::serial]
2222 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2223 temp_env::with_vars(
2224 [
2225 ("BINANCE_API_KEY", Some("my_key")),
2226 ("BINANCE_SECRET_KEY", Some("my_secret")),
2227 ("BINANCE_TESTNET", Some("maybe")),
2228 ],
2229 || {
2230 let err = BinanceSpotConfig::from_env().unwrap_err();
2231 assert!(
2232 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2233 );
2234 },
2235 );
2236 }
2237
2238 #[test]
2239 #[serial_test::serial]
2240 fn test_binance_spot_config_from_env_requires_credentials() {
2241 temp_env::with_vars(
2242 [
2243 ("BINANCE_API_KEY", None),
2244 ("BINANCE_SECRET_KEY", Some("my_secret")),
2245 ("BINANCE_TESTNET", None),
2246 ],
2247 || {
2248 let err = BinanceSpotConfig::from_env().unwrap_err();
2249 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2250 },
2251 );
2252 }
2253
2254 #[test]
2255 fn test_parse_side() {
2256 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2257 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2258 assert_eq!(parse_side("buy"), None);
2259 assert_eq!(parse_side("UNKNOWN"), None);
2260 }
2261
2262 #[test]
2263 fn test_parse_order_kind() {
2264 assert_eq!(parse_order_kind("MARKET"), Some(OrderKind::Market));
2265 assert_eq!(parse_order_kind("LIMIT"), Some(OrderKind::Limit));
2266 assert_eq!(parse_order_kind("LIMIT_MAKER"), Some(OrderKind::Limit));
2267 assert_eq!(parse_order_kind("STOP_LOSS"), None);
2268 assert_eq!(parse_order_kind("TAKE_PROFIT"), None);
2269 assert_eq!(parse_order_kind("STOP_LOSS_LIMIT"), Some(OrderKind::Limit));
2270 assert_eq!(
2271 parse_order_kind("TAKE_PROFIT_LIMIT"),
2272 Some(OrderKind::Limit)
2273 );
2274 assert_eq!(parse_order_kind("UNKNOWN_TYPE"), None);
2275 }
2276
2277 #[test]
2278 fn test_parse_time_in_force() {
2279 assert_eq!(
2280 parse_time_in_force("GTC"),
2281 TimeInForce::GoodUntilCancelled { post_only: false }
2282 );
2283 assert_eq!(
2284 parse_time_in_force("GTX"),
2285 TimeInForce::GoodUntilCancelled { post_only: true }
2286 );
2287 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2288 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2289 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2290 assert_eq!(
2292 parse_time_in_force("UNKNOWN"),
2293 TimeInForce::GoodUntilCancelled { post_only: false }
2294 );
2295 }
2296
2297 #[test]
2298 fn test_convert_order_kind_tif() {
2299 use rust_decimal::Decimal;
2300
2301 assert!(matches!(
2303 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2304 Some((OrderPlaceTypeEnum::Market, None))
2305 ));
2306 assert!(matches!(
2307 convert_order_kind_tif(
2308 OrderKind::Limit,
2309 TimeInForce::GoodUntilCancelled { post_only: false }
2310 ),
2311 Some((
2312 OrderPlaceTypeEnum::Limit,
2313 Some(OrderPlaceTimeInForceEnum::Gtc)
2314 ))
2315 ));
2316 assert!(matches!(
2317 convert_order_kind_tif(
2318 OrderKind::Limit,
2319 TimeInForce::GoodUntilCancelled { post_only: true }
2320 ),
2321 Some((OrderPlaceTypeEnum::LimitMaker, None))
2322 ));
2323 assert!(matches!(
2324 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2325 Some((
2326 OrderPlaceTypeEnum::Limit,
2327 Some(OrderPlaceTimeInForceEnum::Fok)
2328 ))
2329 ));
2330 assert!(matches!(
2331 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2332 Some((
2333 OrderPlaceTypeEnum::Limit,
2334 Some(OrderPlaceTimeInForceEnum::Ioc)
2335 ))
2336 ));
2337 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2340
2341 assert!(matches!(
2343 convert_order_kind_tif(
2344 OrderKind::Stop {
2345 trigger_price: Decimal::from(100)
2346 },
2347 TimeInForce::GoodUntilCancelled { post_only: false }
2348 ),
2349 Some((OrderPlaceTypeEnum::StopLoss, None))
2350 ));
2351 assert!(matches!(
2352 convert_order_kind_tif(
2353 OrderKind::StopLimit {
2354 trigger_price: Decimal::from(100)
2355 },
2356 TimeInForce::GoodUntilCancelled { post_only: false }
2357 ),
2358 Some((
2359 OrderPlaceTypeEnum::StopLossLimit,
2360 Some(OrderPlaceTimeInForceEnum::Gtc)
2361 ))
2362 ));
2363 assert!(matches!(
2364 convert_order_kind_tif(
2365 OrderKind::TakeProfit {
2366 trigger_price: Decimal::from(150)
2367 },
2368 TimeInForce::ImmediateOrCancel
2369 ),
2370 Some((OrderPlaceTypeEnum::TakeProfit, None))
2371 ));
2372 assert!(matches!(
2373 convert_order_kind_tif(
2374 OrderKind::TakeProfitLimit {
2375 trigger_price: Decimal::from(150)
2376 },
2377 TimeInForce::FillOrKill
2378 ),
2379 Some((
2380 OrderPlaceTypeEnum::TakeProfitLimit,
2381 Some(OrderPlaceTimeInForceEnum::Fok)
2382 ))
2383 ));
2384
2385 assert!(matches!(
2387 convert_order_kind_tif(
2388 OrderKind::TrailingStop {
2389 offset: Decimal::from(100),
2390 offset_type: TrailingOffsetType::BasisPoints,
2391 },
2392 TimeInForce::GoodUntilCancelled { post_only: false }
2393 ),
2394 Some((OrderPlaceTypeEnum::StopLoss, None))
2395 ));
2396 assert!(matches!(
2397 convert_order_kind_tif(
2398 OrderKind::TrailingStop {
2399 offset: Decimal::from(5),
2400 offset_type: TrailingOffsetType::Percentage,
2401 },
2402 TimeInForce::GoodUntilCancelled { post_only: false }
2403 ),
2404 Some((OrderPlaceTypeEnum::StopLoss, None))
2405 ));
2406
2407 assert!(
2409 convert_order_kind_tif(
2410 OrderKind::TrailingStop {
2411 offset: Decimal::from(10),
2412 offset_type: TrailingOffsetType::Absolute,
2413 },
2414 TimeInForce::GoodUntilCancelled { post_only: false }
2415 )
2416 .is_none()
2417 );
2418
2419 assert!(
2421 convert_order_kind_tif(
2422 OrderKind::TrailingStopLimit {
2423 offset: Decimal::from(100),
2424 offset_type: TrailingOffsetType::BasisPoints,
2425 limit_offset: Decimal::from(10),
2426 },
2427 TimeInForce::GoodUntilCancelled { post_only: false }
2428 )
2429 .is_none()
2430 );
2431 }
2432
2433 #[test]
2434 fn test_parse_binance_api_error() {
2435 let instrument = InstrumentNameExchange::new("BTCUSDT");
2436
2437 assert!(matches!(
2438 parse_binance_api_error("Insufficient balance".into(), &instrument),
2439 ApiError::BalanceInsufficient(_, _)
2440 ));
2441 assert!(matches!(
2442 parse_binance_api_error("Not enough funds".into(), &instrument),
2443 ApiError::BalanceInsufficient(_, _)
2444 ));
2445 assert_eq!(
2446 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2447 ApiError::RateLimit
2448 );
2449 assert_eq!(
2450 parse_binance_api_error("Error code -1015".into(), &instrument),
2451 ApiError::RateLimit
2452 );
2453 assert_eq!(
2455 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2456 ApiError::RateLimit
2457 );
2458 assert_eq!(
2460 parse_binance_api_error("Error code -2011".into(), &instrument),
2461 ApiError::OrderAlreadyCancelled
2462 );
2463 assert!(matches!(
2465 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2466 ApiError::OrderAlreadyCancelled
2467 ));
2468 assert!(matches!(
2470 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2471 ApiError::OrderAlreadyCancelled
2472 ));
2473 assert!(matches!(
2475 parse_binance_api_error("Unknown order encountered".into(), &instrument),
2476 ApiError::OrderRejected(_)
2477 ));
2478 assert!(matches!(
2479 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2480 ApiError::InstrumentInvalid(_, _)
2481 ));
2482 assert!(matches!(
2484 parse_binance_api_error(
2485 "Server-side response error (code -2010): Account has insufficient balance".into(),
2486 &instrument
2487 ),
2488 ApiError::BalanceInsufficient(_, _)
2489 ));
2490 assert!(matches!(
2491 parse_binance_api_error("Some other error".into(), &instrument),
2492 ApiError::OrderRejected(_)
2493 ));
2494 }
2495
2496 #[test]
2497 fn test_contains_error_code_suffix_guard() {
2498 assert!(
2500 !contains_error_code("-20130", "-2013"),
2501 "-20130 should not match -2013"
2502 );
2503 assert!(
2504 !contains_error_code("-20131", "-2013"),
2505 "-20131 should not match -2013"
2506 );
2507 assert!(contains_error_code("-2013", "-2013"), "exact match");
2509 assert!(
2510 contains_error_code("Error -2013: text", "-2013"),
2511 "match with trailing text"
2512 );
2513 }
2514
2515 #[test]
2516 fn test_contains_error_code_prefix_guard() {
2517 assert!(
2520 !contains_error_code("1-2013", "-2013"),
2521 "1-2013 should not match -2013"
2522 );
2523 assert!(
2524 !contains_error_code("error 1-2013 text", "-2013"),
2525 "embedded 1-2013 should not match"
2526 );
2527 assert!(
2529 contains_error_code("code=-2013,", "-2013"),
2530 "=-2013 prefix should match"
2531 );
2532 assert!(
2533 contains_error_code(" -2013 ", "-2013"),
2534 "space prefix should match"
2535 );
2536 }
2537
2538 #[test]
2539 fn test_contains_error_code_second_occurrence_valid() {
2540 assert!(
2543 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
2544 "second valid occurrence must match when first fails digit guard"
2545 );
2546 assert!(
2548 contains_error_code("-2013 or -20130", "-2013"),
2549 "first valid occurrence must match when second is a longer code"
2550 );
2551 }
2552
2553 #[test]
2554 fn test_dedup_cache() {
2555 let cache = new_dedup_cache();
2556 let key = DedupKey {
2557 instrument: SmolStr::from("BTCUSDT"),
2558 id: SmolStr::from("12345"),
2559 kind: DedupEventKind::Trade,
2560 };
2561
2562 assert!(!is_duplicate(&cache, key));
2564 let key = DedupKey {
2566 instrument: SmolStr::from("BTCUSDT"),
2567 id: SmolStr::from("12345"),
2568 kind: DedupEventKind::Trade,
2569 };
2570 assert!(is_duplicate(&cache, key));
2571
2572 let key2 = DedupKey {
2574 instrument: SmolStr::from("BTCUSDT"),
2575 id: SmolStr::from("12345"),
2576 kind: DedupEventKind::OrderState {
2577 filled_quantity: Decimal::ZERO,
2578 },
2579 };
2580 assert!(!is_duplicate(&cache, key2));
2581 }
2582
2583 #[test]
2587 fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
2588 let cache = new_dedup_cache();
2589 let state = |filled: &str| DedupKey {
2590 instrument: SmolStr::from("BTCUSDT"),
2591 id: SmolStr::from("12345"),
2592 kind: DedupEventKind::OrderState {
2593 filled_quantity: Decimal::from_str(filled).unwrap(),
2594 },
2595 };
2596
2597 assert!(!is_duplicate(&cache, state("0")), "ack is new");
2599 assert!(
2600 !is_duplicate(&cache, state("1")),
2601 "first fill is not the ack"
2602 );
2603 assert!(
2604 !is_duplicate(&cache, state("2")),
2605 "second fill is not the first"
2606 );
2607
2608 assert!(
2611 is_duplicate(&cache, state("0")),
2612 "replayed ack is a duplicate"
2613 );
2614 assert!(
2615 is_duplicate(&cache, state("2")),
2616 "replayed fill is a duplicate"
2617 );
2618
2619 assert!(
2621 is_duplicate(&cache, state("2.00")),
2622 "2.00 must be the same key as 2"
2623 );
2624 }
2625
2626 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
2633 use crate::client::dedup::dedup_key_from_event;
2634
2635 let cache = new_dedup_cache();
2636 let mut delivered = Vec::new();
2637 let mut buf = Vec::new();
2638 for frame in frames {
2639 let _ = convert_user_data_events(frame, &mut buf);
2640 for ev in buf.drain(..) {
2641 if let Some(key) = dedup_key_from_event(&ev)
2642 && is_duplicate(&cache, key)
2643 {
2644 continue;
2645 }
2646 delivered.push(ev);
2647 }
2648 }
2649 delivered
2650 }
2651
2652 const WS_NEW: &str = r#"{"e":"executionReport","s":"BTCUSDT","i":12345,"c":"client-1",
2653 "x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2","p":"100","z":"0",
2654 "T":1700000000000}"#;
2655
2656 const WS_PARTIAL_FILL: &str = r#"{"e":"executionReport","s":"BTCUSDT","i":12345,"c":"client-1",
2657 "x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY","o":"LIMIT","f":"GTC","q":"2","p":"100",
2658 "z":"1","l":"1","L":"100","t":555,"n":"0.1","N":"USDT","T":1700000001000}"#;
2659
2660 #[test]
2668 fn ws_fill_snapshot_survives_the_dedup_gate() {
2669 use crate::order::state::ActiveOrderState;
2670
2671 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
2672
2673 assert_eq!(
2674 delivered.len(),
2675 3,
2676 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
2677 );
2678
2679 let snapshots: Vec<Decimal> = delivered
2680 .iter()
2681 .filter_map(|ev| match &ev.kind {
2682 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
2683 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
2684 _ => None,
2685 },
2686 _ => None,
2687 })
2688 .collect();
2689
2690 assert_eq!(
2691 snapshots,
2692 vec![Decimal::ZERO, Decimal::ONE],
2693 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
2694 );
2695 }
2696
2697 #[test]
2700 fn ws_replayed_frame_is_still_deduplicated() {
2701 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
2702
2703 assert_eq!(
2704 delivered.len(),
2705 3,
2706 "re-delivering both frames must add nothing, got: {delivered:?}"
2707 );
2708 }
2709
2710 #[test]
2711 fn test_is_rate_limit_error() {
2712 assert!(is_rate_limit_error(&anyhow::anyhow!(
2714 "Too many requests. You are being rate-limited. Please slow down."
2715 )));
2716 assert!(is_rate_limit_error(&anyhow::anyhow!(
2718 "The IP address has been banned for exceeding rate limits. Contact support."
2719 )));
2720 assert!(is_rate_limit_error(&anyhow::anyhow!(
2722 "Error -1015: too many new orders"
2723 )));
2724 assert!(is_rate_limit_error(&anyhow::anyhow!(
2725 "Error -1003: too many requests"
2726 )));
2727 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
2729 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
2730 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
2731 assert!(
2733 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
2734 "-10150 should not match -1015"
2735 );
2736 assert!(
2737 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
2738 "-10030 should not match -1003"
2739 );
2740 }
2741
2742 #[test]
2743 fn test_is_api_rejection_error() {
2744 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
2746 code: -2010,
2747 message: "Account has insufficient balance for requested action.".into(),
2748 });
2749 assert!(
2750 is_api_rejection_error(&rejection),
2751 "ResponseError should be detected as API rejection"
2752 );
2753
2754 let transport = anyhow::anyhow!("connection reset by peer");
2756 assert!(
2757 !is_api_rejection_error(&transport),
2758 "plain transport error should not be detected as API rejection"
2759 );
2760
2761 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
2763 assert!(
2764 !is_api_rejection_error(&rate_limit),
2765 "rate-limit string error should not be detected as API rejection"
2766 );
2767 }
2768
2769 #[test]
2770 fn test_connectivity_error_detects_auth_failures() {
2771 let err = connectivity_error(anyhow::anyhow!("Error -1002: unauthorized"));
2773 assert!(
2774 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2775 "expected Unauthenticated for -1002, got {err:?}"
2776 );
2777
2778 let err = connectivity_error(anyhow::anyhow!("Error -2015: permission denied for action"));
2781 assert!(
2782 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2783 "expected Unauthenticated for -2015, got {err:?}"
2784 );
2785
2786 let err = connectivity_error(anyhow::anyhow!("invalid signature provided"));
2788 assert!(
2789 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2790 "expected Unauthenticated for 'invalid signature', got {err:?}"
2791 );
2792
2793 let err = connectivity_error(anyhow::anyhow!(
2795 "The signature for this request is not valid."
2796 ));
2797 assert!(
2798 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2799 "expected Unauthenticated for 'signature for this request is not valid', got {err:?}"
2800 );
2801
2802 let err = connectivity_error(anyhow::anyhow!("Invalid API-key format"));
2804 assert!(
2805 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
2806 "expected Unauthenticated for 'invalid api-key', got {err:?}"
2807 );
2808
2809 let err = connectivity_error(anyhow::anyhow!("connection timeout"));
2811 assert!(
2812 matches!(err, UnindexedClientError::Connectivity(_)),
2813 "expected Connectivity for timeout, got {err:?}"
2814 );
2815 }
2816
2817 #[test]
2818 fn test_dedup_key_from_event_trade_includes_instrument() {
2819 use crate::order::id::{OrderId, StrategyId};
2820 use crate::trade::{AssetFees, Trade, TradeId};
2821 use chrono::Utc;
2822 use rust_decimal::Decimal;
2823 use rustrade_instrument::Side;
2824
2825 let instrument = InstrumentNameExchange::new("BTCUSDT");
2826 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
2827 TradeId::new("9001"),
2828 OrderId::new("4242"),
2829 instrument.clone(),
2830 StrategyId::unknown(),
2831 Utc::now(),
2832 Side::Buy,
2833 Decimal::ZERO,
2834 Decimal::ZERO,
2835 None,
2836 AssetFees::new(
2837 AssetNameExchange::from("USDT"),
2838 Decimal::ZERO,
2839 Some(Decimal::ZERO),
2840 ),
2841 );
2842 let event =
2843 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
2844
2845 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
2846 assert_eq!(key.kind, DedupEventKind::Trade);
2847 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
2848 assert_eq!(
2849 key.instrument.as_str(),
2850 "BTCUSDT",
2851 "key.instrument should be the symbol"
2852 );
2853
2854 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
2856 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
2857 TradeId::new("9001"),
2858 OrderId::new("7777"),
2859 instrument2,
2860 StrategyId::unknown(),
2861 Utc::now(),
2862 Side::Buy,
2863 Decimal::ZERO,
2864 Decimal::ZERO,
2865 None,
2866 AssetFees::new(
2867 AssetNameExchange::from("USDT"),
2868 Decimal::ZERO,
2869 Some(Decimal::ZERO),
2870 ),
2871 );
2872 let event2 =
2873 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
2874 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
2875 assert_ne!(
2876 key, key2,
2877 "same trade ID on different symbols must produce distinct keys"
2878 );
2879 }
2880
2881 fn make_balance_inner(
2886 asset: &str,
2887 free: &str,
2888 locked: &str,
2889 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2890 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2891 a: Some(asset.to_string()),
2892 f: Some(free.to_string()),
2893 l: Some(locked.to_string()),
2894 }
2895 }
2896
2897 #[test]
2898 fn test_convert_account_position_happy_path() {
2899 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2900 u: Some(1_700_000_000_000),
2901 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
2902 ..Default::default()
2903 };
2904 let mut buf = Vec::new();
2905 convert_account_position(position, &mut buf);
2906
2907 assert_eq!(buf.len(), 1);
2908 match &buf[0].kind {
2909 AccountEventKind::BalanceStreamUpdate(snap) => {
2910 let update = &snap.0;
2911 assert_eq!(update.asset.as_ref(), "BTC");
2912 let expected_free = Decimal::from_str("1.5").unwrap();
2914 let expected_locked = Decimal::from_str("0.5").unwrap();
2915 assert_eq!(update.update.free, expected_free);
2916 assert_eq!(update.update.locked, expected_locked);
2917 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
2918 }
2919 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
2920 }
2921 }
2922
2923 #[test]
2924 fn test_convert_account_position_u_field_none_uses_now() {
2925 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2927 u: None,
2928 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
2929 ..Default::default()
2930 };
2931 let mut buf = Vec::new();
2932 convert_account_position(position, &mut buf);
2933 assert_eq!(buf.len(), 1);
2934 }
2935
2936 #[test]
2937 fn test_convert_account_position_missing_asset_name_skipped() {
2938 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2939 u: Some(1_700_000_000_000),
2940 b_uppercase: Some(vec![
2941 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2942 a: None, f: Some("1.0".to_string()),
2944 l: Some("0.0".to_string()),
2945 },
2946 make_balance_inner("USDT", "100.0", "0.0"), ]),
2948 ..Default::default()
2949 };
2950 let mut buf = Vec::new();
2951 convert_account_position(position, &mut buf);
2952 assert_eq!(buf.len(), 1);
2954 match &buf[0].kind {
2955 AccountEventKind::BalanceStreamUpdate(snap) => {
2956 assert_eq!(snap.0.asset.as_ref(), "USDT");
2957 }
2958 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
2959 }
2960 }
2961
2962 #[test]
2963 fn test_convert_account_position_unparseable_free_skipped() {
2964 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2965 u: Some(1_700_000_000_000),
2966 b_uppercase: Some(vec![
2967 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
2968 a: Some("BTC".to_string()),
2969 f: Some("not-a-number".to_string()),
2970 l: Some("0.0".to_string()),
2971 },
2972 make_balance_inner("ETH", "1.0", "0.0"), ]),
2974 ..Default::default()
2975 };
2976 let mut buf = Vec::new();
2977 convert_account_position(position, &mut buf);
2978 assert_eq!(buf.len(), 1);
2980 match &buf[0].kind {
2981 AccountEventKind::BalanceStreamUpdate(snap) => {
2982 assert_eq!(snap.0.asset.as_ref(), "ETH");
2983 }
2984 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
2985 }
2986 }
2987
2988 #[test]
2989 fn test_convert_account_position_empty_balances() {
2990 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
2991 u: Some(1_700_000_000_000),
2992 b_uppercase: Some(vec![]),
2993 ..Default::default()
2994 };
2995 let mut buf = Vec::new();
2996 convert_account_position(position, &mut buf);
2997 assert!(
2998 buf.is_empty(),
2999 "empty balance list should produce no events"
3000 );
3001 }
3002
3003 #[test]
3004 fn test_convert_account_position_b_field_none() {
3005 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3006 u: Some(1_700_000_000_000),
3007 b_uppercase: None, ..Default::default()
3009 };
3010 let mut buf = Vec::new();
3011 convert_account_position(position, &mut buf);
3012 assert!(buf.is_empty(), "None B field should produce no events");
3013 }
3014
3015 #[tokio::test]
3017 async fn test_exponential_backoff_exhaustion() {
3018 tokio::time::pause(); let mut backoff = ExponentialBackoff::new();
3020
3021 for i in 0..MAX_RECONNECT_ATTEMPTS {
3023 assert!(
3024 backoff.wait().await,
3025 "expected true on attempt {i} (before exhaustion)"
3026 );
3027 }
3028
3029 assert!(!backoff.wait().await, "expected false after max attempts");
3031 }
3032
3033 #[tokio::test]
3034 async fn test_exponential_backoff_reset() {
3035 tokio::time::pause();
3036 let mut backoff = ExponentialBackoff::new();
3037
3038 for _ in 0..MAX_RECONNECT_ATTEMPTS {
3039 backoff.wait().await;
3040 }
3041 assert!(!backoff.wait().await, "should be exhausted");
3042
3043 backoff.reset();
3044 assert!(backoff.wait().await, "should succeed again after reset");
3045 }
3046
3047 fn make_base_report() -> binance_sdk::spot::websocket_api::ExecutionReport {
3051 binance_sdk::spot::websocket_api::ExecutionReport {
3052 s: Some("BTCUSDT".to_string()),
3053 i: Some(12345),
3054 c: Some("client-1".to_string()),
3055 t_uppercase: Some(1_700_000_000_000),
3056 ..Default::default()
3057 }
3058 }
3059
3060 fn convert(
3062 report: binance_sdk::spot::websocket_api::ExecutionReport,
3063 ) -> Vec<UnindexedAccountEvent> {
3064 let mut buf = Vec::new();
3065 convert_execution_report(&report, ExchangeId::BinanceSpot, &mut buf);
3066 buf
3067 }
3068
3069 fn sole_event(mut events: Vec<UnindexedAccountEvent>) -> Option<UnindexedAccountEvent> {
3074 assert!(
3075 events.len() <= 1,
3076 "expected at most a single event, got: {events:?}"
3077 );
3078 events.pop()
3079 }
3080
3081 #[test]
3082 fn test_convert_execution_report_new() {
3083 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3084 x: Some("NEW".to_string()),
3085 s_uppercase: Some("BUY".to_string()),
3086 o: Some("LIMIT".to_string()),
3087 p: Some("50000.00".to_string()),
3088 q: Some("0.01".to_string()),
3089 f: Some("GTC".to_string()),
3090 z: Some("0".to_string()),
3091 ..make_base_report()
3092 };
3093
3094 let event = sole_event(convert(report)).expect("NEW event should produce Some");
3095 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3096 match &event.kind {
3097 AccountEventKind::OrderSnapshot(snap) => {
3098 let order = &snap.0;
3099 assert_eq!(order.side, Side::Buy);
3100 assert_eq!(order.kind, OrderKind::Limit);
3101 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3102 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3103 assert_eq!(order.key.cid.0.as_str(), "client-1");
3104 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3105 }
3106 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3107 }
3108 }
3109
3110 #[test]
3111 fn test_convert_execution_report_trade() {
3112 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3115 x: Some("TRADE".to_string()),
3116 s_uppercase: Some("BUY".to_string()),
3117 t: Some(9999),
3118 l_uppercase: Some("50000.00".to_string()),
3119 l: Some("0.01".to_string()),
3120 n: Some("0.000001".to_string()),
3121 ..make_base_report()
3122 };
3123
3124 let event = sole_event(convert(report)).expect("TRADE event should produce Some");
3125 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3126 match &event.kind {
3127 AccountEventKind::Trade(trade) => {
3128 assert_eq!(trade.side, Side::Buy);
3129 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3130 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3131 assert_eq!(trade.id.0.as_str(), "9999");
3132 assert_eq!(trade.order_id.0.as_str(), "12345");
3133 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3134 }
3135 other => panic!("TRADE should yield Trade, got {other:?}"),
3136 }
3137 }
3138
3139 fn trade_events(
3141 events: Vec<UnindexedAccountEvent>,
3142 ) -> (
3143 Trade<AssetNameExchange, InstrumentNameExchange>,
3144 crate::order::Order<
3145 ExchangeId,
3146 InstrumentNameExchange,
3147 OrderState<AssetNameExchange, InstrumentNameExchange>,
3148 >,
3149 ) {
3150 let [first, second]: [UnindexedAccountEvent; 2] = events
3151 .try_into()
3152 .expect("a fill report must produce an execution and an order snapshot");
3153 let AccountEventKind::Trade(trade) = first.kind else {
3154 panic!("first event must be the Trade, got {:?}", first.kind);
3155 };
3156 let AccountEventKind::OrderSnapshot(snap) = second.kind else {
3157 panic!(
3158 "second event must be the OrderSnapshot, got {:?}",
3159 second.kind
3160 );
3161 };
3162 (trade, snap.0)
3163 }
3164
3165 #[test]
3169 fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
3170 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3171 x: Some("TRADE".to_string()),
3172 x_uppercase: Some("PARTIALLY_FILLED".to_string()),
3173 s_uppercase: Some("BUY".to_string()),
3174 o: Some("LIMIT".to_string()),
3175 p: Some("50000.00".to_string()),
3176 q: Some("0.01".to_string()),
3177 f: Some("GTC".to_string()),
3178 t: Some(9999),
3179 l_uppercase: Some("50000.00".to_string()),
3180 l: Some("0.004".to_string()),
3181 z: Some("0.004".to_string()),
3182 ..make_base_report()
3183 };
3184
3185 let (trade, order) = trade_events(convert(report));
3186 assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
3187
3188 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3189 else {
3190 panic!("expected an Open snapshot, got {:?}", order.state);
3191 };
3192 assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
3193 assert_eq!(
3194 open.quantity_remaining(order.quantity),
3195 Decimal::from_str("0.006").unwrap()
3196 );
3197 }
3198
3199 #[test]
3202 fn a_filled_report_reports_nothing_left_to_fill() {
3203 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3204 x: Some("TRADE".to_string()),
3205 x_uppercase: Some("FILLED".to_string()),
3206 s_uppercase: Some("BUY".to_string()),
3207 o: Some("LIMIT".to_string()),
3208 p: Some("50000.00".to_string()),
3209 q: Some("0.01".to_string()),
3210 f: Some("GTC".to_string()),
3211 t: Some(10_000),
3212 l_uppercase: Some("50000.00".to_string()),
3213 l: Some("0.006".to_string()),
3214 z: Some("0.01".to_string()),
3215 ..make_base_report()
3216 };
3217
3218 let (_trade, order) = trade_events(convert(report));
3219 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3220 else {
3221 panic!("expected an Open snapshot, got {:?}", order.state);
3222 };
3223 assert!(
3224 open.quantity_remaining(order.quantity).is_zero(),
3225 "a completed order must report nothing left to fill so the engine retires it"
3226 );
3227 }
3228
3229 #[test]
3233 fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
3234 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
3235 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3236 x: Some("TRADE".to_string()),
3237 x_uppercase: Some(status.to_string()),
3238 s_uppercase: Some("BUY".to_string()),
3239 o: Some("LIMIT".to_string()),
3240 p: Some("50000.00".to_string()),
3241 q: Some("0.01".to_string()),
3242 f: Some("GTC".to_string()),
3243 t: Some(9999),
3244 l_uppercase: Some("50000.00".to_string()),
3245 l: Some("0.004".to_string()),
3246 z: Some("0.004".to_string()),
3247 ..make_base_report()
3248 };
3249 let events = convert(report);
3250 assert_eq!(
3251 events.len(),
3252 1,
3253 "status {status} must produce the execution alone, got: {events:?}"
3254 );
3255 assert!(
3256 matches!(events[0].kind, AccountEventKind::Trade(_)),
3257 "the execution must still be reported for status {status}"
3258 );
3259 }
3260 }
3261
3262 #[test]
3263 fn test_convert_execution_report_trade_missing_last_price() {
3264 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3266 x: Some("TRADE".to_string()),
3267 s_uppercase: Some("BUY".to_string()),
3268 t: Some(9999),
3269 l_uppercase: None, l: Some("0.01".to_string()),
3271 ..make_base_report()
3272 };
3273 assert!(
3274 sole_event(convert(report)).is_none(),
3275 "missing last price (L) should return None"
3276 );
3277 }
3278
3279 #[test]
3280 fn test_convert_execution_report_trade_missing_last_qty() {
3281 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3283 x: Some("TRADE".to_string()),
3284 s_uppercase: Some("BUY".to_string()),
3285 t: Some(9999),
3286 l_uppercase: Some("50000.00".to_string()),
3287 l: None, ..make_base_report()
3289 };
3290 assert!(
3291 sole_event(convert(report)).is_none(),
3292 "missing last qty (l) should return None"
3293 );
3294 }
3295
3296 #[test]
3297 fn test_convert_execution_report_canceled() {
3298 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3299 x: Some("CANCELED".to_string()),
3300 ..make_base_report()
3301 };
3302
3303 let event = sole_event(convert(report)).expect("CANCELED should produce Some");
3304 assert!(
3305 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3306 "CANCELED should yield OrderCancelled with Ok state"
3307 );
3308 }
3309
3310 #[test]
3311 fn test_convert_execution_report_expired() {
3312 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3313 x: Some("EXPIRED".to_string()),
3314 ..make_base_report()
3315 };
3316 let event = sole_event(convert(report)).expect("EXPIRED should produce Some");
3317 assert!(
3318 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3319 "EXPIRED should yield OrderCancelled with Ok state"
3320 );
3321 }
3322
3323 #[test]
3324 fn test_convert_execution_report_expired_in_match() {
3325 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3326 x: Some("EXPIRED_IN_MATCH".to_string()),
3327 ..make_base_report()
3328 };
3329 let event = sole_event(convert(report)).expect("EXPIRED_IN_MATCH should produce Some");
3330 assert!(
3331 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3332 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3333 );
3334 }
3335
3336 #[test]
3337 fn test_convert_execution_report_rejected() {
3338 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3339 x: Some("REJECTED".to_string()),
3340 r: Some("INSUFFICIENT_FUNDS".to_string()),
3341 ..make_base_report()
3342 };
3343
3344 let event = sole_event(convert(report)).expect("REJECTED should produce Some");
3345 assert!(
3346 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_err()),
3347 "REJECTED should yield OrderCancelled with Err state"
3348 );
3349 }
3350
3351 #[test]
3352 fn test_convert_execution_report_missing_exec_type() {
3353 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3355 s: Some("BTCUSDT".to_string()),
3356 ..Default::default()
3357 };
3358 assert!(
3359 sole_event(convert(report)).is_none(),
3360 "missing execution type should return None"
3361 );
3362 }
3363
3364 #[test]
3365 fn test_convert_execution_report_missing_symbol() {
3366 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3368 x: Some("NEW".to_string()),
3369 ..Default::default()
3370 };
3371 assert!(
3372 sole_event(convert(report)).is_none(),
3373 "missing symbol should return None"
3374 );
3375 }
3376
3377 #[test]
3378 fn test_convert_execution_report_missing_order_id() {
3379 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3381 x: Some("NEW".to_string()),
3382 s: Some("BTCUSDT".to_string()),
3383 i: None,
3384 ..Default::default()
3385 };
3386 assert!(
3387 sole_event(convert(report)).is_none(),
3388 "missing orderId should return None"
3389 );
3390 }
3391
3392 #[test]
3393 fn test_convert_execution_report_trade_missing_trade_id() {
3394 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3396 x: Some("TRADE".to_string()),
3397 t: None,
3398 ..make_base_report()
3399 };
3400 assert!(
3401 sole_event(convert(report)).is_none(),
3402 "TRADE event missing tradeId should return None"
3403 );
3404 }
3405
3406 #[test]
3407 fn test_convert_execution_report_replace_yields_cancelled() {
3408 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3409 x: Some("REPLACE".to_string()),
3410 ..make_base_report()
3411 };
3412 let event = sole_event(convert(report)).expect("REPLACE should produce Some");
3415 assert!(
3416 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3417 "REPLACE should yield OrderCancelled with Ok state"
3418 );
3419 }
3420
3421 fn make_balance(
3426 asset: &str,
3427 free: &str,
3428 locked: &str,
3429 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3430 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3431 asset: Some(asset.to_string()),
3432 free: Some(free.to_string()),
3433 locked: Some(locked.to_string()),
3434 }
3435 }
3436
3437 #[test]
3438 fn test_filter_balances_empty_assets_returns_all() {
3439 let balances = vec![
3440 make_balance("BTC", "1.0", "0.0"),
3441 make_balance("USDT", "500.0", "50.0"),
3442 ];
3443 let result = filter_and_convert_balances(balances, &[]);
3444 assert_eq!(result.len(), 2, "empty filter should return all balances");
3445 }
3446
3447 #[test]
3448 fn test_filter_balances_matching_asset_returned() {
3449 let balances = vec![
3450 make_balance("BTC", "1.5", "0.5"),
3451 make_balance("ETH", "10.0", "0.0"),
3452 ];
3453 let assets = vec![AssetNameExchange::new("BTC")];
3454 let result = filter_and_convert_balances(balances, &assets);
3455 assert_eq!(result.len(), 1);
3456 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
3457 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
3459 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
3460 }
3461
3462 #[test]
3463 fn test_filter_balances_non_matching_asset_filtered_out() {
3464 let balances = vec![
3465 make_balance("BTC", "1.0", "0.0"),
3466 make_balance("ETH", "2.0", "0.0"),
3467 ];
3468 let assets = vec![AssetNameExchange::new("USDT")];
3469 let result = filter_and_convert_balances(balances, &assets);
3470 assert!(
3471 result.is_empty(),
3472 "non-matching asset should be filtered out"
3473 );
3474 }
3475
3476 #[test]
3477 fn test_filter_balances_missing_asset_field_skipped() {
3478 let balances = vec![
3479 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3480 asset: None,
3481 free: Some("1.0".to_string()),
3482 locked: Some("0.0".to_string()),
3483 },
3484 make_balance("USDT", "100.0", "0.0"),
3485 ];
3486 let result = filter_and_convert_balances(balances, &[]);
3487 assert_eq!(
3488 result.len(),
3489 1,
3490 "entry with missing asset should be skipped"
3491 );
3492 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
3493 }
3494
3495 #[test]
3496 fn test_filter_balances_unparseable_free_skipped() {
3497 let balances = vec![
3498 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3499 asset: Some("BTC".to_string()),
3500 free: Some("not-a-number".to_string()),
3501 locked: Some("0.0".to_string()),
3502 },
3503 ];
3504 let result = filter_and_convert_balances(balances, &[]);
3505 assert!(
3506 result.is_empty(),
3507 "unparseable free field should be skipped"
3508 );
3509 }
3510
3511 #[test]
3512 fn test_filter_balances_zero_balance_included() {
3513 let balances = vec![
3516 make_balance("BTC", "0.00000000", "0.00000000"),
3517 make_balance("USDT", "100.0", "0.0"),
3518 ];
3519 let result = filter_and_convert_balances(balances, &[]);
3520 assert_eq!(result.len(), 2, "zero-balance entries must be included");
3521 let btc = result
3522 .iter()
3523 .find(|b| b.asset == AssetNameExchange::new("BTC"))
3524 .unwrap();
3525 assert_eq!(btc.balance.total, Decimal::ZERO);
3526 assert_eq!(btc.balance.free, Decimal::ZERO);
3527 }
3528
3529 #[test]
3530 fn test_filter_balances_duplicate_assets_in_response() {
3531 let balances = vec![
3535 make_balance("BTC", "1.0", "0.0"),
3536 make_balance("BTC", "2.0", "0.0"),
3537 ];
3538 let result = filter_and_convert_balances(balances, &[]);
3539 assert_eq!(
3540 result.len(),
3541 2,
3542 "duplicate asset entries produce two AssetBalance entries"
3543 );
3544 }
3545
3546 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
3551 binance_sdk::spot::rest_api::MyTradesResponseInner {
3552 id: Some(9001),
3553 order_id: Some(4242),
3554 price: Some("50000.00".to_string()),
3555 qty: Some("0.01".to_string()),
3556 commission: Some("0.05".to_string()),
3557 time: Some(1_700_000_000_000),
3558 is_buyer: Some(true),
3559 ..Default::default()
3560 }
3561 }
3562
3563 #[test]
3564 fn test_convert_my_trade_happy_path() {
3565 let instrument = InstrumentNameExchange::new("BTCUSDT");
3566 let trade =
3567 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
3568 assert_eq!(trade.instrument, instrument);
3569 assert_eq!(trade.side, Side::Buy);
3570 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3571 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3572 }
3573
3574 #[test]
3575 fn test_convert_my_trade_sell_side() {
3576 let instrument = InstrumentNameExchange::new("BTCUSDT");
3577 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3578 is_buyer: Some(false),
3579 ..make_base_trade()
3580 };
3581 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
3582 assert_eq!(trade.side, Side::Sell);
3583 }
3584
3585 #[test]
3586 fn test_convert_my_trade_missing_id_returns_none() {
3587 let instrument = InstrumentNameExchange::new("BTCUSDT");
3588 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3589 id: None,
3590 ..make_base_trade()
3591 };
3592 assert!(
3593 convert_my_trade(&t, &instrument).is_none(),
3594 "missing id should return None"
3595 );
3596 }
3597
3598 #[test]
3599 fn test_convert_my_trade_missing_order_id_returns_none() {
3600 let instrument = InstrumentNameExchange::new("BTCUSDT");
3601 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3602 order_id: None,
3603 ..make_base_trade()
3604 };
3605 assert!(
3606 convert_my_trade(&t, &instrument).is_none(),
3607 "missing orderId should return None"
3608 );
3609 }
3610
3611 #[test]
3612 fn test_convert_my_trade_missing_is_buyer_returns_none() {
3613 let instrument = InstrumentNameExchange::new("BTCUSDT");
3614 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3615 is_buyer: None,
3616 ..make_base_trade()
3617 };
3618 assert!(
3619 convert_my_trade(&t, &instrument).is_none(),
3620 "missing isBuyer should return None"
3621 );
3622 }
3623
3624 #[test]
3625 fn test_convert_my_trade_commission_none_defaults_to_zero() {
3626 let instrument = InstrumentNameExchange::new("BTCUSDT");
3627 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
3628 commission: None,
3629 ..make_base_trade()
3630 };
3631 let trade =
3632 convert_my_trade(&t, &instrument).expect("None commission should still convert");
3633 assert_eq!(trade.fees.fees, Decimal::ZERO);
3634 }
3635
3636 fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3641 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3642 order_id: Some(12345),
3643 client_order_id: Some("cid-abc".to_string()),
3644 side: Some("BUY".to_string()),
3645 r#type: Some("LIMIT".to_string()),
3646 price: Some("50000.00".to_string()),
3647 orig_qty: Some("0.01".to_string()),
3648 executed_qty: Some("0.0".to_string()),
3649 time_in_force: Some("GTC".to_string()),
3650 time: Some(1_700_000_000_000),
3651 status: Some("NEW".to_string()),
3652 ..Default::default()
3653 }
3654 }
3655
3656 #[test]
3657 fn test_convert_open_order_all_orders_response_converts_identically() {
3658 let instrument = InstrumentNameExchange::new("BTCUSDT");
3665 let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3666 order_id: Some(12345),
3667 client_order_id: Some("cid-abc".to_string()),
3668 side: Some("BUY".to_string()),
3669 r#type: Some("LIMIT".to_string()),
3670 price: Some("50000.00".to_string()),
3671 orig_qty: Some("0.01".to_string()),
3672 executed_qty: Some("0.0".to_string()),
3673 time_in_force: Some("GTC".to_string()),
3674 time: Some(1_700_000_000_000),
3675 status: Some("NEW".to_string()),
3676 ..Default::default()
3677 };
3678 let from_all_orders = convert_open_order(&all_orders, ExchangeId::BinanceSpot, &instrument)
3679 .expect("valid order should convert");
3680 let from_open_orders = convert_open_order(
3681 &make_base_open_order(),
3682 ExchangeId::BinanceSpot,
3683 &instrument,
3684 )
3685 .expect("valid order should convert");
3686 assert_eq!(from_all_orders, from_open_orders);
3687 }
3688
3689 #[test]
3695 fn test_convert_open_order_refuses_an_all_orders_row_that_is_not_live() {
3696 let instrument = InstrumentNameExchange::new("BTCUSDT");
3697 for status in [
3698 "CANCELED",
3699 "FILLED",
3700 "EXPIRED",
3701 "EXPIRED_IN_MATCH",
3702 "REJECTED",
3703 "PENDING_CANCEL",
3704 "SOME_FUTURE_STATUS",
3705 ] {
3706 let row = binance_sdk::spot::rest_api::AllOrdersResponseInner {
3707 order_id: Some(12345),
3708 client_order_id: Some("cid-abc".to_string()),
3709 side: Some("BUY".to_string()),
3710 r#type: Some("LIMIT".to_string()),
3711 price: Some("50000.00".to_string()),
3712 orig_qty: Some("0.01".to_string()),
3713 executed_qty: Some("0.004".to_string()),
3714 time_in_force: Some("GTC".to_string()),
3715 time: Some(1_700_000_000_000),
3716 status: Some(status.to_string()),
3717 ..Default::default()
3718 };
3719 assert_eq!(
3720 convert_open_order(&row, ExchangeId::BinanceSpot, &instrument),
3721 None,
3722 "{status}"
3723 );
3724 }
3725 }
3726
3727 #[test]
3728 fn test_convert_open_order_admits_every_live_status() {
3729 let instrument = InstrumentNameExchange::new("BTCUSDT");
3730 for status in ["NEW", "PARTIALLY_FILLED", "PENDING_NEW"] {
3731 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3732 status: Some(status.to_string()),
3733 ..make_base_open_order()
3734 };
3735 assert!(
3736 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_some(),
3737 "{status}"
3738 );
3739 }
3740 }
3741
3742 #[test]
3747 fn test_convert_open_order_listing_is_complete_only_when_every_row_survives_under_its_cid() {
3748 let instrument = InstrumentNameExchange::new("BTCUSDT");
3749 let second = || binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3750 order_id: Some(67890),
3751 client_order_id: Some("cid-def".to_string()),
3752 ..make_base_open_order()
3753 };
3754
3755 let listing = convert_open_order_listing(
3756 &[make_base_open_order(), second()],
3757 ExchangeId::BinanceSpot,
3758 &instrument,
3759 );
3760 assert_eq!(listing.orders.len(), 2);
3761 assert!(listing.complete);
3762
3763 let empty = convert_open_order_listing::<
3764 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
3765 >(&[], ExchangeId::BinanceSpot, &instrument);
3766 assert!(empty.orders.is_empty());
3767 assert!(empty.complete, "no open orders is a complete answer");
3768
3769 let dropped = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3770 side: None,
3771 ..second()
3772 };
3773 let listing = convert_open_order_listing(
3774 &[make_base_open_order(), dropped],
3775 ExchangeId::BinanceSpot,
3776 &instrument,
3777 );
3778 assert_eq!(listing.orders.len(), 1);
3779 assert!(!listing.complete, "a dropped row may be a live order");
3780
3781 let without_cid = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3782 client_order_id: None,
3783 ..second()
3784 };
3785 let listing = convert_open_order_listing(
3786 &[make_base_open_order(), without_cid],
3787 ExchangeId::BinanceSpot,
3788 &instrument,
3789 );
3790 assert_eq!(listing.orders.len(), 2, "the row is still converted");
3791 assert!(
3792 !listing.complete,
3793 "an order kept under its orderId cannot be found under the cid it was placed with"
3794 );
3795 }
3796
3797 #[test]
3798 fn test_convert_open_order_missing_status_returns_none() {
3799 let instrument = InstrumentNameExchange::new("BTCUSDT");
3800 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3801 status: None,
3802 ..make_base_open_order()
3803 };
3804 assert!(convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none());
3805 }
3806
3807 #[test]
3813 fn test_convert_open_order_prefers_update_time_over_creation_time() {
3814 let instrument = InstrumentNameExchange::new("BTCUSDT");
3815
3816 let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3817 update_time: Some(1_700_000_005_000),
3818 ..make_base_open_order()
3819 };
3820 let order = convert_open_order(&updated, ExchangeId::BinanceSpot, &instrument)
3821 .expect("valid order should convert");
3822 assert_eq!(
3823 order.state.time_exchange,
3824 Utc.timestamp_millis_opt(1_700_000_005_000)
3825 .single()
3826 .unwrap(),
3827 "update_time must win over time"
3828 );
3829
3830 let order = convert_open_order(
3833 &make_base_open_order(),
3834 ExchangeId::BinanceSpot,
3835 &instrument,
3836 )
3837 .expect("valid order should convert");
3838 assert_eq!(
3839 order.state.time_exchange,
3840 Utc.timestamp_millis_opt(1_700_000_000_000)
3841 .single()
3842 .unwrap(),
3843 "absent update_time falls back to time"
3844 );
3845 }
3846
3847 #[test]
3848 fn test_convert_open_order_happy_path() {
3849 let instrument = InstrumentNameExchange::new("BTCUSDT");
3850 let order = convert_open_order(
3851 &make_base_open_order(),
3852 ExchangeId::BinanceSpot,
3853 &instrument,
3854 )
3855 .expect("valid order should convert");
3856 assert_eq!(order.key.instrument, instrument);
3857 assert_eq!(order.side, Side::Buy);
3858 assert_eq!(order.kind, OrderKind::Limit);
3859 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3860 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3861 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
3862 }
3863
3864 #[test]
3865 fn test_convert_open_order_owned_symbol_recovers_instrument() {
3866 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3868 symbol: Some("ETHUSDT".to_string()),
3869 ..make_base_open_order()
3870 };
3871 let order = convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot)
3872 .expect("valid order should convert");
3873 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
3874 assert_eq!(order.side, Side::Buy);
3875 }
3876
3877 #[test]
3878 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
3879 let o = make_base_open_order();
3881 assert!(o.symbol.is_none());
3882 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceSpot).is_none());
3883 }
3884
3885 #[test]
3886 fn test_convert_open_order_missing_order_id_returns_none() {
3887 let instrument = InstrumentNameExchange::new("BTCUSDT");
3888 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3889 order_id: None,
3890 ..make_base_open_order()
3891 };
3892 assert!(
3893 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
3894 "missing orderId should return None"
3895 );
3896 }
3897
3898 #[test]
3899 fn test_convert_open_order_missing_side_returns_none() {
3900 let instrument = InstrumentNameExchange::new("BTCUSDT");
3901 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3902 side: None,
3903 ..make_base_open_order()
3904 };
3905 assert!(
3906 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
3907 "missing side should return None"
3908 );
3909 }
3910
3911 #[test]
3912 fn test_convert_open_order_missing_type_returns_none() {
3913 let instrument = InstrumentNameExchange::new("BTCUSDT");
3914 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3915 r#type: None,
3916 ..make_base_open_order()
3917 };
3918 assert!(
3919 convert_open_order(&o, ExchangeId::BinanceSpot, &instrument).is_none(),
3920 "missing type should return None"
3921 );
3922 }
3923
3924 #[test]
3925 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
3926 let instrument = InstrumentNameExchange::new("BTCUSDT");
3927 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3928 executed_qty: None,
3929 ..make_base_open_order()
3930 };
3931 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
3932 .expect("None executedQty should still convert");
3933 assert_eq!(
3934 order.state.filled_quantity,
3935 Decimal::ZERO,
3936 "None executedQty should default to zero"
3937 );
3938 }
3939
3940 #[test]
3941 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
3942 let instrument = InstrumentNameExchange::new("BTCUSDT");
3943 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
3944 executed_qty: Some("bad-value".to_string()),
3945 ..make_base_open_order()
3946 };
3947 let order = convert_open_order(&o, ExchangeId::BinanceSpot, &instrument)
3948 .expect("unparseable executedQty should still convert");
3949 assert_eq!(
3950 order.state.filled_quantity,
3951 Decimal::ZERO,
3952 "unparseable executedQty should default to zero"
3953 );
3954 }
3955
3956 #[test]
3961 fn test_dedup_key_from_event_non_open_states_return_none() {
3962 use crate::order::state::{
3963 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
3964 };
3965 use rustrade_integration::collection::snapshot::Snapshot;
3966
3967 let key = OrderKey::new(
3968 ExchangeId::BinanceSpot,
3969 InstrumentNameExchange::new("BTCUSDT"),
3970 StrategyId::unknown(),
3971 ClientOrderId::new("cid1"),
3972 );
3973
3974 let event = UnindexedAccountEvent::new(
3976 ExchangeId::BinanceSpot,
3977 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
3978 key.clone(),
3979 Side::Buy,
3980 None, Decimal::ZERO,
3982 OrderKind::Market,
3983 TimeInForce::ImmediateOrCancel,
3984 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
3985 ActiveOrderState::OpenInFlight(OpenInFlight),
3986 ),
3987 ))),
3988 );
3989 assert!(
3990 dedup_key_from_event(&event).is_none(),
3991 "OpenInFlight should return None — dedup not meaningful before exchange ack"
3992 );
3993
3994 let event = UnindexedAccountEvent::new(
3996 ExchangeId::BinanceSpot,
3997 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
3998 key.clone(),
3999 Side::Buy,
4000 None, Decimal::ZERO,
4002 OrderKind::Market,
4003 TimeInForce::ImmediateOrCancel,
4004 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4005 ActiveOrderState::CancelInFlight(CancelInFlight { order: None }),
4006 ),
4007 ))),
4008 );
4009 assert!(
4010 dedup_key_from_event(&event).is_none(),
4011 "CancelInFlight should return None"
4012 );
4013
4014 let event = UnindexedAccountEvent::new(
4016 ExchangeId::BinanceSpot,
4017 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4018 key,
4019 Side::Buy,
4020 None, Decimal::ONE,
4022 OrderKind::Market,
4023 TimeInForce::ImmediateOrCancel,
4024 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
4025 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
4026 OrderId::new("123"),
4027 Utc::now(),
4028 Decimal::ONE, None,
4030 )),
4031 ),
4032 ))),
4033 );
4034 assert!(
4035 dedup_key_from_event(&event).is_none(),
4036 "Inactive state should return None"
4037 );
4038 }
4039
4040 #[test]
4041 fn test_dedup_key_from_event_cancelled_error_returns_none() {
4042 let report = binance_sdk::spot::websocket_api::ExecutionReport {
4045 x: Some("REJECTED".to_string()),
4046 r: Some("INSUFFICIENT_FUNDS".to_string()),
4047 ..make_base_report()
4048 };
4049 let event = sole_event(convert(report)).expect("REJECTED report should produce Some");
4050 assert!(
4051 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
4052 "prerequisite: event is OrderCancelled with Err"
4053 );
4054 assert!(
4055 dedup_key_from_event(&event).is_none(),
4056 "OrderCancelled with Err state should return None"
4057 );
4058 }
4059
4060 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
4070 let mut value = serde_json::to_value(event).expect("event serializes to Value");
4071 value
4072 .as_object_mut()
4073 .expect("event serializes to a JSON object")
4074 .insert(
4075 "e".to_string(),
4076 serde_json::Value::String(event_type.to_string()),
4077 );
4078 serde_json::to_string(&value).expect("frame serializes")
4079 }
4080
4081 #[test]
4082 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
4083 let report = binance_sdk::spot::websocket_api::ExecutionReport {
4084 x: Some("NEW".to_string()),
4085 s_uppercase: Some("BUY".to_string()),
4086 o: Some("LIMIT".to_string()),
4087 p: Some("50000.00".to_string()),
4088 q: Some("0.01".to_string()),
4089 f: Some("GTC".to_string()),
4090 z: Some("0".to_string()),
4091 ..make_base_report()
4092 };
4093 let frame = user_data_frame("executionReport", &report);
4094 let mut buf = Vec::new();
4095 let terminated = convert_user_data_events(&frame, &mut buf);
4096 assert!(
4097 !terminated,
4098 "ExecutionReport should not signal stream termination"
4099 );
4100 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
4101 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
4102 }
4103
4104 #[test]
4105 fn test_convert_user_data_events_account_position_pushes_to_buf() {
4106 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4107 u: Some(1_700_000_000_000),
4108 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
4109 ..Default::default()
4110 };
4111 let frame = user_data_frame("outboundAccountPosition", &position);
4112 let mut buf = Vec::new();
4113 let terminated = convert_user_data_events(&frame, &mut buf);
4114 assert!(
4115 !terminated,
4116 "OutboundAccountPosition should not signal stream termination"
4117 );
4118 assert_eq!(
4119 buf.len(),
4120 1,
4121 "OutboundAccountPosition should push one balance event"
4122 );
4123 }
4124
4125 #[test]
4126 fn test_convert_user_data_events_balance_update_ignored() {
4127 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4128 ..Default::default()
4129 };
4130 let frame = user_data_frame("balanceUpdate", &update);
4131 let mut buf = Vec::new();
4132 let terminated = convert_user_data_events(&frame, &mut buf);
4133 assert!(
4134 !terminated,
4135 "BalanceUpdate should not signal stream termination"
4136 );
4137 assert!(buf.is_empty(), "BalanceUpdate should push no events");
4138 }
4139
4140 #[test]
4141 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4142 let frame = r#"{"e":"eventStreamTerminated","E":1700000000000}"#;
4144 let mut buf = Vec::new();
4145 let terminated = convert_user_data_events(frame, &mut buf);
4146 assert!(
4147 terminated,
4148 "eventStreamTerminated must signal stream termination"
4149 );
4150 assert!(
4151 buf.is_empty(),
4152 "eventStreamTerminated should push no events"
4153 );
4154 }
4155
4156 #[test]
4157 fn test_convert_user_data_events_unknown_event_ignored() {
4158 let frame = r#"{"e":"listStatus","E":1700000000000,"s":"BTCUSDT"}"#;
4161 let mut buf = Vec::new();
4162 let terminated = convert_user_data_events(frame, &mut buf);
4163 assert!(!terminated, "unknown event must not signal termination");
4164 assert!(buf.is_empty(), "unknown event should push no events");
4165 }
4166
4167 #[test]
4168 fn test_convert_user_data_events_non_user_data_frame_ignored() {
4169 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4171 let mut buf = Vec::new();
4172 let terminated = convert_user_data_events(frame, &mut buf);
4173 assert!(
4174 !terminated,
4175 "non-user-data frame must not signal termination"
4176 );
4177 assert!(buf.is_empty(), "non-user-data frame should push no events");
4178 }
4179
4180 #[tokio::test]
4185 async fn test_rate_limit_tracker_not_blocked_initially() {
4186 tokio::time::pause();
4187 let tracker = RateLimitTracker::new();
4188 tokio::time::timeout(
4190 std::time::Duration::from_millis(1),
4191 tracker.wait_if_blocked(),
4192 )
4193 .await
4194 .expect("wait_if_blocked should return immediately when not blocked");
4195 }
4196
4197 #[tokio::test]
4198 async fn test_rate_limit_tracker_blocks_until_deadline() {
4199 tokio::time::pause();
4200 let tracker = RateLimitTracker::new();
4201 let delay = Duration::from_secs(5);
4202 tracker.on_rate_limited(Some(delay));
4203
4204 assert!(
4206 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4207 .await
4208 .is_err(),
4209 "wait_if_blocked should block while cooldown is active"
4210 );
4211
4212 tokio::time::advance(delay + Duration::from_millis(1)).await;
4214 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4215 .await
4216 .expect("wait_if_blocked should return after cooldown expires");
4217 }
4218
4219 #[tokio::test]
4220 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
4221 tokio::time::pause();
4222 let tracker = RateLimitTracker::new();
4223
4224 tracker.on_rate_limited(Some(Duration::from_secs(5)));
4226 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4228
4229 tokio::time::advance(Duration::from_secs(6)).await;
4231 assert!(
4232 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4233 .await
4234 .is_err(),
4235 "cooldown should have been extended to 10s"
4236 );
4237
4238 tokio::time::advance(Duration::from_secs(5)).await;
4240 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4241 .await
4242 .expect("wait_if_blocked should return after extended cooldown expires");
4243 }
4244
4245 #[tokio::test]
4246 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
4247 tokio::time::pause();
4248 let tracker = RateLimitTracker::new();
4249
4250 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4252 tracker.on_rate_limited(Some(Duration::from_secs(2)));
4253
4254 tokio::time::advance(Duration::from_secs(3)).await;
4256 assert!(
4257 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4258 .await
4259 .is_err(),
4260 "shorter on_rate_limited must not shorten existing cooldown"
4261 );
4262 }
4263
4264 #[test]
4269 fn test_parse_binance_api_error_balance_insufficient_holds_instrument_name() {
4270 let instrument = InstrumentNameExchange::new("BTCUSDT");
4271 match parse_binance_api_error("Insufficient balance".into(), &instrument) {
4272 ApiError::BalanceInsufficient(asset_field, _) => {
4273 assert_eq!(
4278 asset_field.name().as_str(),
4279 "BTCUSDT",
4280 "BalanceInsufficient.0 holds the instrument name, not an asset name"
4281 );
4282 }
4283 other => panic!("expected BalanceInsufficient, got {other:?}"),
4284 }
4285 }
4286
4287 #[test]
4292 fn test_is_api_rejection_error_with_wrapped_error_chain() {
4293 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
4298 code: -2010,
4299 message: "insufficient balance".into(),
4300 });
4301 assert!(
4303 is_api_rejection_error(&raw),
4304 "unwrapped ResponseError at root must be detected"
4305 );
4306 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
4308 assert!(
4309 is_api_rejection_error(&wrapped),
4310 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
4311 );
4312 }
4313}