1use super::shared::{
35 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
36 CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
37 RateLimitTracker, SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, classify_order_kind_tif,
38 connectivity_error, dedup_key_from_event, is_api_rejection_error, is_duplicate,
39 is_rate_limit_error, new_dedup_cache, parse_binance_api_error, parse_order_kind, parse_side,
40 parse_time_in_force, rest_call_with_retry,
41};
42use crate::{
43 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, UnindexedAccountEvent,
44 UnindexedAccountSnapshot,
45 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
46 client::ExecutionClient,
47 emit_stream_terminated,
48 error::{
49 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
50 UnindexedOrderError,
51 },
52 order::{
53 Order, OrderKey, OrderKind, TimeInForce, TrailingOffsetType,
54 id::{ClientOrderId, OrderId, StrategyId},
55 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
56 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
57 },
58 parse_env_bool,
59 trade::{AssetFees, Trade, TradeId},
60};
61use binance_sdk::{
62 common::{
63 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
64 models::WebsocketEvent,
65 },
66 spot::{
67 SpotRestApi, SpotWsApi,
68 rest_api::{GetAccountParams, GetOpenOrdersParams, MyTradesParams, RestApi},
69 websocket_api::{
70 OrderCancelParams, OrderPlaceParams, OrderPlaceSideEnum, OrderPlaceTimeInForceEnum,
71 OrderPlaceTypeEnum, UserDataStreamSubscribeSignatureParams, WebsocketApi,
72 WebsocketApiHandle,
73 },
74 },
75};
76use chrono::{DateTime, TimeZone, Utc};
77use futures::stream::BoxStream;
78use rust_decimal::Decimal;
79use rustrade_instrument::{
80 Side,
81 asset::name::AssetNameExchange,
82 exchange::ExchangeId,
83 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
84};
85use serde::Deserialize;
86use smol_str::format_smolstr;
87use std::{
88 str::FromStr,
89 sync::{
90 Arc,
91 atomic::{AtomicBool, Ordering},
92 },
93 time::Duration,
94};
95use tokio::sync::{RwLock, mpsc, oneshot};
96use tracing::{debug, error, info, trace, warn};
97
98#[derive(Clone, Deserialize)]
105pub struct BinanceSpotConfig {
106 api_key: String,
109 secret_key: String,
110
111 #[serde(default = "default_testnet")]
113 pub testnet: bool,
114}
115
116fn default_testnet() -> bool {
122 true
123}
124
125impl std::fmt::Debug for BinanceSpotConfig {
127 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
128 f.debug_struct("BinanceSpotConfig")
129 .field("api_key", &"***")
130 .field("secret_key", &"***")
131 .field("testnet", &self.testnet)
132 .finish()
133 }
134}
135
136impl BinanceSpotConfig {
137 pub fn new(api_key: String, secret_key: String) -> Self {
140 Self::testnet(api_key, secret_key)
141 }
142
143 pub fn testnet(api_key: String, secret_key: String) -> Self {
145 Self {
146 api_key,
147 secret_key,
148 testnet: true,
149 }
150 }
151
152 pub fn production(api_key: String, secret_key: String) -> Self {
157 Self {
158 api_key,
159 secret_key,
160 testnet: false,
161 }
162 }
163
164 pub fn from_env() -> Result<Self, BinanceSpotConfigError> {
183 let api_key = match std::env::var("BINANCE_API_KEY") {
184 Ok(value) => value,
185 Err(std::env::VarError::NotPresent) => {
186 return Err(BinanceSpotConfigError::MissingApiKey);
187 }
188 Err(std::env::VarError::NotUnicode(_)) => {
189 return Err(BinanceSpotConfigError::InvalidApiKey);
190 }
191 };
192 let secret_key = match std::env::var("BINANCE_SECRET_KEY") {
193 Ok(value) => value,
194 Err(std::env::VarError::NotPresent) => {
195 return Err(BinanceSpotConfigError::MissingSecretKey);
196 }
197 Err(std::env::VarError::NotUnicode(_)) => {
198 return Err(BinanceSpotConfigError::InvalidSecretKey);
199 }
200 };
201 let testnet = match std::env::var("BINANCE_TESTNET") {
202 Ok(value) => {
203 parse_env_bool(&value).ok_or(BinanceSpotConfigError::InvalidTestnet(value))?
204 }
205 Err(std::env::VarError::NotPresent) => true,
206 Err(std::env::VarError::NotUnicode(value)) => {
209 return Err(BinanceSpotConfigError::InvalidTestnet(
210 value.to_string_lossy().into_owned(),
211 ));
212 }
213 };
214
215 if testnet {
216 Ok(Self::testnet(api_key, secret_key))
217 } else {
218 Ok(Self::production(api_key, secret_key))
219 }
220 }
221
222 pub fn api_key(&self) -> &str {
224 &self.api_key
225 }
226}
227
228#[derive(Debug, PartialEq, thiserror::Error)]
229pub enum BinanceSpotConfigError {
230 #[error("BINANCE_API_KEY environment variable not set")]
231 MissingApiKey,
232
233 #[error("BINANCE_API_KEY environment variable is not valid UTF-8")]
236 InvalidApiKey,
237
238 #[error("BINANCE_SECRET_KEY environment variable not set")]
239 MissingSecretKey,
240
241 #[error("BINANCE_SECRET_KEY environment variable is not valid UTF-8")]
242 InvalidSecretKey,
243
244 #[error("BINANCE_TESTNET must be true or false, got {0}")]
245 InvalidTestnet(String),
246}
247
248#[derive(Clone)]
257pub struct BinanceSpot {
258 config: Arc<BinanceSpotConfig>,
259 rest: Arc<RestApi>,
260 ws_handle: WebsocketApiHandle,
262 ws_api: Arc<RwLock<Option<WebsocketApi>>>,
268 rate_limiter: Arc<RateLimitTracker>,
270}
271
272impl std::fmt::Debug for BinanceSpot {
273 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274 f.debug_struct("BinanceSpot")
275 .field("testnet", &self.config.testnet)
276 .finish_non_exhaustive()
277 }
278}
279
280impl BinanceSpot {
281 #[allow(clippy::expect_used)] fn build_rest(config: &BinanceSpotConfig) -> Arc<RestApi> {
285 let rest_config = ConfigurationRestApi::builder()
286 .api_key(config.api_key.clone())
287 .api_secret(config.secret_key.clone())
288 .build()
289 .expect("failed to build Binance REST configuration");
290
291 Arc::new(if config.testnet {
292 SpotRestApi::testnet(rest_config)
293 } else {
294 SpotRestApi::production(rest_config)
295 })
296 }
297
298 #[allow(clippy::expect_used)] fn build_ws_handle(config: &BinanceSpotConfig) -> WebsocketApiHandle {
302 let ws_config = ConfigurationWebsocketApi::builder()
303 .api_key(config.api_key.clone())
304 .api_secret(config.secret_key.clone())
305 .build()
306 .expect("failed to build Binance WebSocket configuration");
307
308 if config.testnet {
309 SpotWsApi::testnet(ws_config)
310 } else {
311 SpotWsApi::production(ws_config)
312 }
313 }
314
315 async fn get_ws_api(&self) -> anyhow::Result<WebsocketApi> {
319 {
321 let guard = self.ws_api.read().await;
322 if let Some(ref ws) = *guard {
323 return Ok(ws.clone());
324 }
325 }
326 let mut guard = self.ws_api.write().await;
333 if let Some(ref ws) = *guard {
335 return Ok(ws.clone());
336 }
337 let ws = tokio::time::timeout(
338 Duration::from_secs(CONNECT_TIMEOUT_SECS),
339 self.ws_handle.connect(),
340 )
341 .await
342 .map_err(|_| {
343 anyhow::anyhow!("BinanceSpot WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
344 })??;
345 *guard = Some(ws.clone());
346 Ok(ws)
347 }
348
349 async fn clear_ws_api(&self) {
352 let ws = {
363 let mut guard = self.ws_api.write().await;
364 guard.take()
365 };
366 if let Some(ws) = ws {
367 match tokio::time::timeout(Duration::from_secs(5), ws.disconnect()).await {
368 Ok(Err(e)) => warn!(%e, "BinanceSpot failed to disconnect stale WS session"),
369 Err(_) => warn!("BinanceSpot WS disconnect timed out (5s)"),
370 Ok(Ok(())) => {}
371 }
372 }
373 }
374}
375
376async fn fetch_open_orders_for_instrument(
383 rest: Arc<RestApi>,
384 rate_limiter: Arc<RateLimitTracker>,
385 instrument: InstrumentNameExchange,
386) -> Result<
387 (
388 InstrumentNameExchange,
389 Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
390 ),
391 UnindexedClientError,
392> {
393 let symbol_str = instrument.name().to_string();
395 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
396 let sym = symbol_str.clone();
397 Box::pin(async move {
398 let params = GetOpenOrdersParams::builder().symbol(sym).build()?;
399 rest.get_open_orders(params).await
400 })
401 })
402 .await
403 .map_err(connectivity_error)?;
404
405 let orders_data = response
406 .data()
407 .await
408 .map_err(|e| connectivity_error(e.into()))?;
409
410 let orders = orders_data
411 .into_iter()
412 .filter_map(|o| convert_open_order(&o, &instrument))
413 .collect();
414
415 Ok((instrument, orders))
416}
417
418async fn fetch_all_open_orders(
423 rest: Arc<RestApi>,
424 rate_limiter: Arc<RateLimitTracker>,
425) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
426 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
427 Box::pin(async move {
428 let params = GetOpenOrdersParams::builder().build()?;
429 rest.get_open_orders(params).await
430 })
431 })
432 .await
433 .map_err(connectivity_error)?;
434
435 let orders_data = response
436 .data()
437 .await
438 .map_err(|e| connectivity_error(e.into()))?;
439
440 let orders = orders_data
441 .into_iter()
442 .filter_map(|o| convert_open_order_owned_symbol(&o))
443 .collect();
444
445 Ok(orders)
446}
447
448async fn paginate_my_trades(
456 rest: &Arc<RestApi>,
457 rate_limiter: &Arc<RateLimitTracker>,
458 instrument: &InstrumentNameExchange,
459 start_time_ms: i64,
460) -> Result<Vec<binance_sdk::spot::rest_api::MyTradesResponseInner>, UnindexedClientError> {
461 let symbol_str = instrument.name().to_string();
463 let mut all_pages = Vec::new();
468 let mut cursor: Option<i64> = None;
469 loop {
470 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, |rest| {
472 let sym = symbol_str.clone();
473 let stm = start_time_ms;
474 Box::pin(async move {
475 #[allow(clippy::cast_possible_truncation)]
477 let builder = MyTradesParams::builder(sym).limit(BINANCE_MAX_TRADES as i32);
478 let params = if let Some(id) = fid {
479 builder.from_id(id).build()?
480 } else {
481 builder.start_time(stm).build()?
482 };
483 rest.my_trades(params).await
484 })
485 })
486 .await
487 .map_err(connectivity_error)?;
488
489 let page = response
490 .data()
491 .await
492 .map_err(|e| connectivity_error(e.into()))?;
493
494 let page_len = page.len();
495 let last_id = page.last().and_then(|t| t.id);
496 all_pages.extend(page);
497
498 if page_len < BINANCE_MAX_TRADES {
499 break;
500 }
501 match last_id {
502 Some(id) => {
503 debug!(%instrument, "BinanceSpot paginate_my_trades: fetching next page ({page_len} results)");
504 match id.checked_add(1) {
505 Some(next) => cursor = Some(next),
506 None => break, }
508 }
509 None => {
510 warn!(%instrument, "BinanceSpot paginate_my_trades: trade missing ID, stopping pagination");
511 break;
512 }
513 }
514 }
515 Ok(all_pages)
516}
517
518impl ExecutionClient for BinanceSpot {
523 const EXCHANGE: ExchangeId = ExchangeId::BinanceSpot;
524
525 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
528 &[InstrumentKindDiscriminant::Spot];
529 type Config = BinanceSpotConfig;
530 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
531
532 fn new(config: Self::Config) -> Self {
537 let rest = Self::build_rest(&config);
538 let ws_handle = Self::build_ws_handle(&config);
539 Self {
540 config: Arc::new(config),
541 rest,
542 ws_handle,
543 ws_api: Arc::new(RwLock::new(None)),
544 rate_limiter: Arc::new(RateLimitTracker::new()),
545 }
546 }
547
548 async fn account_snapshot(
549 &self,
550 assets: &[AssetNameExchange],
551 instruments: &[InstrumentNameExchange],
552 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
553 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
555 Box::pin(async move {
556 let params = GetAccountParams::builder().build()?;
557 rest.get_account(params).await
558 })
559 })
560 .await
561 .map_err(connectivity_error)?;
562
563 let account = response
564 .data()
565 .await
566 .map_err(|e| connectivity_error(e.into()))?;
567
568 let balances = filter_and_convert_balances(account.balances.unwrap_or_default(), assets);
570
571 use futures::{StreamExt as _, TryStreamExt};
577 let instrument_snapshots: Vec<_> =
578 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
579 fetch_open_orders_for_instrument(
580 self.rest.clone(),
581 self.rate_limiter.clone(),
582 instrument,
583 )
584 }))
585 .buffer_unordered(8)
586 .map(|result| {
587 let (inst, orders) = result?;
588 let wrapped = orders
589 .into_iter()
590 .map(|o| Order {
591 key: o.key,
592 side: o.side,
593 price: o.price,
594 quantity: o.quantity,
595 kind: o.kind,
596 time_in_force: o.time_in_force,
597 state: OrderState::active(o.state),
598 })
599 .collect();
600 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
601 inst, wrapped, None, None,
602 ))
603 })
604 .try_collect()
605 .await?;
606
607 Ok(AccountSnapshot::new(
608 ExchangeId::BinanceSpot,
609 balances,
610 instrument_snapshots,
611 ))
612 }
613
614 async fn account_stream(
641 &self,
642 _assets: &[AssetNameExchange],
646 instruments: &[InstrumentNameExchange],
647 ) -> Result<Self::AccountStream, UnindexedClientError> {
648 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
663 let dedup = new_dedup_cache();
664 let ws_handle = self.ws_handle.clone();
665 let rest = self.rest.clone();
666 let rate_limiter = self.rate_limiter.clone();
667 let instruments = instruments.to_vec();
668 debug_assert!(
672 instruments.iter().all(|i| i.name().len() <= 23),
673 "instrument name exceeds SmolStr inline capacity: {:?}",
674 instruments.iter().find(|i| i.name().len() > 23)
675 );
676
677 let initial_ws = ws_handle.connect().await.map_err(|e| {
681 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
682 })?;
683
684 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
686 .build()
687 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
688
689 match initial_ws
690 .user_data_stream_subscribe_signature(params)
691 .await
692 {
693 Ok(_) => {}
694 Err(e) => {
695 match tokio::time::timeout(Duration::from_secs(5), initial_ws.disconnect()).await {
700 Ok(Err(de)) => {
701 warn!(%de, "BinanceSpot failed to disconnect WS after subscribe failure")
702 }
703 Err(_) => {
704 warn!("BinanceSpot WS disconnect timed out (5s) after subscribe failure")
705 }
706 Ok(Ok(())) => {}
707 }
708 return Err(UnindexedClientError::Internal(e.to_string()));
709 }
710 }
711
712 let cm_handle = tokio::spawn(connection_manager(
724 tx,
725 dedup,
726 ws_handle,
727 rest,
728 rate_limiter,
729 instruments,
730 Some(initial_ws),
731 ));
732
733 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
737 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
738 Ok(futures::StreamExt::boxed(guarded_stream))
739 }
740
741 async fn cancel_order(
742 &self,
743 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
744 ) -> Option<UnindexedOrderResponseCancel> {
745 let instrument = request.key.instrument.clone();
746 let key = OrderKey {
747 exchange: request.key.exchange,
748 instrument: instrument.clone(),
749 strategy: request.key.strategy.clone(),
750 cid: request.key.cid.clone(),
751 };
752
753 let ws = match self.get_ws_api().await {
754 Ok(ws) => ws,
755 Err(e) => {
756 return Some(UnindexedOrderResponseCancel {
757 key,
758 state: Err(UnindexedOrderError::Connectivity(
759 ConnectivityError::Socket(format!("{e:#}")),
760 )),
761 });
762 }
763 };
764
765 let mut params_builder =
768 OrderCancelParams::builder(request.key.instrument.name().to_string());
769
770 if let Some(ref order_id) = request.state.id {
772 if let Ok(id) = order_id.0.parse::<i64>() {
773 params_builder = params_builder.order_id(id);
774 } else {
775 error!(
780 order_id = %order_id.0,
781 "BinanceSpot cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
782 );
783 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
784 }
785 } else {
786 params_builder = params_builder.orig_client_order_id(request.key.cid.0.to_string());
787 }
788
789 let params = match params_builder.build() {
790 Ok(p) => p,
791 Err(e) => {
792 error!(%e, "BinanceSpot failed to build cancel order params");
793 return Some(UnindexedOrderResponseCancel {
794 key,
795 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
796 e.to_string(),
797 ))),
798 });
799 }
800 };
801
802 match ws.order_cancel(params).await {
803 Ok(response) => match response.data() {
804 Ok(data) => {
805 let time_exchange = data
806 .transact_time
807 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
808 .unwrap_or_else(Utc::now);
809
810 let exchange_order_id = match data.order_id {
811 Some(id) => OrderId(format_smolstr!("{id}")),
812 None => {
813 error!("BinanceSpot cancel response missing orderId");
814 return Some(UnindexedOrderResponseCancel {
815 key,
816 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
817 "cancel response missing orderId".into(),
818 ))),
819 });
820 }
821 };
822
823 let filled_qty = data
824 .executed_qty
825 .as_deref()
826 .and_then(|q| Decimal::from_str(q).ok())
827 .unwrap_or(Decimal::ZERO);
828
829 Some(UnindexedOrderResponseCancel {
830 key,
831 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
832 })
833 }
834 Err(e) => {
835 Some(UnindexedOrderResponseCancel {
837 key,
838 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
839 e.to_string(),
840 ))),
841 })
842 }
843 },
844 Err(e) => {
845 if is_api_rejection_error(&e) {
852 let api_err = parse_binance_api_error(e.to_string(), &instrument);
854 Some(UnindexedOrderResponseCancel {
859 key,
860 state: Err(UnindexedOrderError::from(api_err)),
861 })
862 } else if is_rate_limit_error(&e) {
863 self.rate_limiter.on_rate_limited(None);
865 Some(UnindexedOrderResponseCancel {
866 key,
867 state: Err(UnindexedOrderError::from(ApiError::RateLimit)),
868 })
869 } else {
870 self.clear_ws_api().await;
873 Some(UnindexedOrderResponseCancel {
874 key,
875 state: Err(UnindexedOrderError::Connectivity(
876 ConnectivityError::Socket(format!("{e:#}")),
877 )),
878 })
879 }
880 }
881 }
882 }
883
884 async fn open_order(
885 &self,
886 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
887 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
888 let instrument = request.key.instrument.clone();
889 let side = request.state.side;
890 let price = request.state.price;
891 let quantity = request.state.quantity;
892 let kind = request.state.kind;
893 let time_in_force = request.state.time_in_force;
894 let cid = request.key.cid.clone();
895
896 let order_key = OrderKey::new(
897 ExchangeId::BinanceSpot,
898 instrument.clone(),
899 request.key.strategy.clone(),
900 cid.clone(),
901 );
902
903 let ws = match self.get_ws_api().await {
904 Ok(ws) => ws,
905 Err(e) => {
906 return Some(Order {
907 key: order_key,
908 side,
909 price,
910 quantity,
911 kind,
912 time_in_force,
913 state: OrderState::inactive(OrderError::Connectivity(
914 ConnectivityError::Socket(format!("{e:#}")),
915 )),
916 });
917 }
918 };
919
920 let binance_side = match side {
921 Side::Buy => OrderPlaceSideEnum::Buy,
922 Side::Sell => OrderPlaceSideEnum::Sell,
923 };
924
925 let (binance_type, binance_tif) = match convert_order_kind_tif(kind, time_in_force) {
926 Some(converted) => converted,
927 None => {
928 return Some(Order {
929 key: order_key,
930 side,
931 price,
932 quantity,
933 kind,
934 time_in_force,
935 state: OrderState::inactive(OrderError::UnsupportedOrderType(format!(
936 "Binance Spot does not yet support OrderKind::{kind:?}"
937 ))),
938 });
939 }
940 };
941
942 let mut params_builder =
949 OrderPlaceParams::builder(instrument.name().to_string(), binance_side, binance_type)
950 .quantity(quantity)
951 .new_client_order_id(cid.0.to_string());
952
953 match kind {
956 OrderKind::Limit => {
957 params_builder = params_builder.price(price);
958 }
959 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
960 params_builder = params_builder.stop_price(trigger_price);
961 }
962 OrderKind::StopLimit { trigger_price }
963 | OrderKind::TakeProfitLimit { trigger_price } => {
964 params_builder = params_builder.price(price).stop_price(trigger_price);
965 }
966 OrderKind::TrailingStop {
967 offset,
968 offset_type,
969 } => {
970 let basis_points: i32 = match offset_type {
972 TrailingOffsetType::BasisPoints => {
973 let Ok(bp) = i32::try_from(offset) else {
974 return Some(Order {
975 key: order_key,
976 side,
977 price,
978 quantity,
979 kind,
980 time_in_force,
981 state: OrderState::inactive(OrderError::UnsupportedOrderType(
982 format!(
983 "TrailingStop basis-point offset {offset} overflows i32 \
984 (Binance trailingDelta filter typically caps at 2000)"
985 ),
986 )),
987 });
988 };
989 bp
990 }
991 TrailingOffsetType::Percentage => {
992 let Ok(bp) = i32::try_from(offset * Decimal::from(100)) else {
993 return Some(Order {
994 key: order_key,
995 side,
996 price,
997 quantity,
998 kind,
999 time_in_force,
1000 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1001 format!(
1002 "TrailingStop percentage offset {offset} overflows i32 \
1003 after scaling to basis points"
1004 ),
1005 )),
1006 });
1007 };
1008 bp
1009 }
1010 TrailingOffsetType::Absolute => {
1011 return Some(Order {
1015 key: order_key,
1016 side,
1017 price,
1018 quantity,
1019 kind,
1020 time_in_force,
1021 state: OrderState::inactive(OrderError::UnsupportedOrderType(
1022 "TrailingStop with Absolute offset is not supported by Binance; \
1023 convert to basis points: (absolute / price) * 10000"
1024 .into(),
1025 )),
1026 });
1027 }
1028 };
1029 params_builder = params_builder.trailing_delta(basis_points);
1030 }
1031 _ => {}
1035 }
1036
1037 if let Some(tif) = binance_tif {
1038 params_builder = params_builder.time_in_force(tif);
1039 }
1040
1041 let params = match params_builder.build() {
1042 Ok(p) => p,
1043 Err(e) => {
1044 error!(%e, "BinanceSpot failed to build new order params");
1045 return Some(Order {
1046 key: order_key,
1047 side,
1048 price,
1049 quantity,
1050 kind,
1051 time_in_force,
1052 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1053 e.to_string(),
1054 ))),
1055 });
1056 }
1057 };
1058
1059 match ws.order_place(params).await {
1060 Ok(response) => match response.data() {
1061 Ok(data) => {
1062 let time_exchange = data
1063 .transact_time
1064 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1065 .unwrap_or_else(Utc::now);
1066
1067 let exchange_order_id = match data.order_id {
1068 Some(id) => OrderId(format_smolstr!("{id}")),
1069 None => {
1070 error!("BinanceSpot open_order response missing orderId");
1071 return Some(Order {
1072 key: order_key,
1073 side,
1074 price,
1075 quantity,
1076 kind,
1077 time_in_force,
1078 state: OrderState::inactive(OrderError::Rejected(
1079 ApiError::OrderRejected(
1080 "open_order response missing orderId".into(),
1081 ),
1082 )),
1083 });
1084 }
1085 };
1086
1087 let filled_qty = data
1088 .executed_qty
1089 .as_deref()
1090 .and_then(|q| Decimal::from_str(q).ok())
1091 .unwrap_or(Decimal::ZERO);
1092
1093 let state = if filled_qty >= quantity {
1094 OrderState::fully_filled(Filled::new(
1096 exchange_order_id,
1097 time_exchange,
1098 filled_qty,
1099 None, ))
1101 } else {
1102 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
1104 };
1105
1106 Some(Order {
1107 key: order_key,
1108 side,
1109 price,
1110 quantity,
1111 kind,
1112 time_in_force,
1113 state,
1114 })
1115 }
1116 Err(e) => {
1117 Some(Order {
1119 key: order_key,
1120 side,
1121 price,
1122 quantity,
1123 kind,
1124 time_in_force,
1125 state: OrderState::inactive(OrderError::Rejected(ApiError::OrderRejected(
1126 e.to_string(),
1127 ))),
1128 })
1129 }
1130 },
1131 Err(e) => {
1132 if is_api_rejection_error(&e) {
1139 let api_err = parse_binance_api_error(e.to_string(), &instrument);
1141 Some(Order {
1146 key: order_key,
1147 side,
1148 price,
1149 quantity,
1150 kind,
1151 time_in_force,
1152 state: OrderState::inactive(OrderError::from(api_err)),
1153 })
1154 } else if is_rate_limit_error(&e) {
1155 self.rate_limiter.on_rate_limited(None);
1157 Some(Order {
1158 key: order_key,
1159 side,
1160 price,
1161 quantity,
1162 kind,
1163 time_in_force,
1164 state: OrderState::inactive(OrderError::from(ApiError::RateLimit)),
1165 })
1166 } else {
1167 self.clear_ws_api().await;
1170 Some(Order {
1171 key: order_key,
1172 side,
1173 price,
1174 quantity,
1175 kind,
1176 time_in_force,
1177 state: OrderState::inactive(OrderError::Connectivity(
1178 ConnectivityError::Socket(format!("{e:#}")),
1179 )),
1180 })
1181 }
1182 }
1183 }
1184 }
1185
1186 async fn fetch_balances(
1187 &self,
1188 assets: &[AssetNameExchange],
1189 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1190 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
1191 Box::pin(async move {
1192 let params = GetAccountParams::builder().build()?;
1193 rest.get_account(params).await
1194 })
1195 })
1196 .await
1197 .map_err(connectivity_error)?;
1198
1199 let account = response
1200 .data()
1201 .await
1202 .map_err(|e| connectivity_error(e.into()))?;
1203
1204 Ok(filter_and_convert_balances(
1205 account.balances.unwrap_or_default(),
1206 assets,
1207 ))
1208 }
1209
1210 async fn fetch_open_orders(
1215 &self,
1216 instruments: &[InstrumentNameExchange],
1217 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1218 if instruments.is_empty() {
1221 return fetch_all_open_orders(self.rest.clone(), self.rate_limiter.clone()).await;
1222 }
1223 use futures::{StreamExt as _, TryStreamExt as _};
1228 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1229 fetch_open_orders_for_instrument(
1230 self.rest.clone(),
1231 self.rate_limiter.clone(),
1232 instrument,
1233 )
1234 }))
1235 .buffer_unordered(8)
1236 .try_fold(Vec::with_capacity(instruments.len()), |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, orders)| async move {
1237 acc.extend(orders);
1238 Ok(acc)
1239 })
1240 .await
1241 }
1242
1243 #[allow(clippy::redundant_iter_cloned)]
1251 async fn fetch_trades(
1252 &self,
1253 time_since: DateTime<Utc>,
1254 instruments: &[InstrumentNameExchange],
1255 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1256 use futures::StreamExt;
1257
1258 if instruments.is_empty() {
1259 debug!(
1260 "BinanceSpot fetch_trades called with empty instruments slice — returning empty result"
1261 );
1262 return Ok(Vec::new());
1263 }
1264 let start_time_ms = time_since.timestamp_millis();
1265 let mut all_trades = Vec::new();
1268
1269 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1273 let rest = self.rest.clone();
1274 let rate_limiter = self.rate_limiter.clone();
1275 async move {
1276 let pages = paginate_my_trades(&rest, &rate_limiter, &inst, start_time_ms).await?;
1277 Ok::<_, UnindexedClientError>((inst, pages))
1278 }
1279 }))
1280 .buffer_unordered(8);
1281 while let Some(result) = stream.next().await {
1282 let (instrument, trades_data) = result?;
1283 for t in trades_data {
1284 if let Some(trade) = convert_my_trade(&t, &instrument) {
1285 all_trades.push(trade);
1286 }
1287 }
1288 }
1289
1290 Ok(all_trades)
1291 }
1292}
1293
1294async fn connect_and_subscribe(ws_handle: &WebsocketApiHandle) -> anyhow::Result<WebsocketApi> {
1301 let ws = ws_handle.connect().await?;
1302 #[allow(clippy::expect_used)] let params = UserDataStreamSubscribeSignatureParams::builder()
1304 .build()
1305 .expect("UserDataStreamSubscribeSignatureParams has no required fields");
1306 match ws.user_data_stream_subscribe_signature(params).await {
1307 Ok(_) => Ok(ws),
1308 Err(e) => {
1309 warn!(%e, "BinanceSpot WS subscribe failed, cleaning up connection");
1310 let ws_cleanup = ws;
1311 tokio::spawn(async move {
1321 match tokio::time::timeout(Duration::from_secs(5), ws_cleanup.disconnect()).await {
1322 Ok(Err(dc_err)) => warn!(%dc_err, "BinanceSpot WS cleanup disconnect failed"),
1323 Err(_) => warn!("BinanceSpot WS cleanup disconnect timed out (5s)"),
1324 Ok(Ok(())) => {}
1325 }
1326 });
1327 Err(e)
1328 }
1329 }
1330}
1331
1332#[allow(clippy::cognitive_complexity)]
1354async fn connection_manager(
1355 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1356 dedup: SharedDedupCache,
1357 ws_handle: WebsocketApiHandle,
1358 rest: Arc<RestApi>,
1359 rate_limiter: Arc<RateLimitTracker>,
1360 instruments: Vec<InstrumentNameExchange>,
1361 initial_ws: Option<WebsocketApi>,
1362) {
1363 let mut backoff = ExponentialBackoff::new();
1364 let mut disconnect_time: Option<DateTime<Utc>> = None;
1365 let mut current_ws = initial_ws;
1366
1367 loop {
1368 let ws = match current_ws.take() {
1370 Some(ws) => ws,
1371 None => match connect_and_subscribe(&ws_handle).await {
1372 Ok(ws) => ws,
1378 Err(e) => {
1379 error!(%e, "BinanceSpot WS connect/subscribe failed");
1380 if !backoff.wait().await {
1381 error!("BinanceSpot max reconnect attempts exhausted");
1382 emit_stream_terminated(
1384 &tx,
1385 ExchangeId::BinanceSpot,
1386 StreamTerminationReason::ReconnectBudgetExhausted {
1387 attempts: backoff.attempts(),
1388 last_error: e.to_string(),
1389 },
1390 );
1391 break;
1392 }
1393 continue;
1394 }
1395 },
1396 };
1397 info!("BinanceSpot account_stream connected and subscribed");
1398
1399 let (signal_tx, signal_rx) = oneshot::channel::<()>();
1405 let mut signal_tx_opt = Some(signal_tx);
1406 let heartbeat_flag = Arc::new(AtomicBool::new(true));
1410 let hb_callback = heartbeat_flag.clone();
1411 let dedup_callback = dedup.clone();
1412 let mut event_tx = Some(tx.clone());
1416 let mut event_buf = Vec::with_capacity(32);
1420
1421 let subscription = ws.subscribe_on_ws_events(move |event| {
1428 let Some(ref sender) = event_tx else { return };
1429 match event {
1430 WebsocketEvent::Message(json_str) => {
1431 hb_callback.store(true, Ordering::Release);
1434 let stream_terminated = convert_user_data_events(&json_str, &mut event_buf);
1438 for ev in event_buf.drain(..) {
1439 if let Some(key) = dedup_key_from_event(&ev)
1441 && is_duplicate(&dedup_callback, key)
1442 {
1443 trace!("BinanceSpot dedup: skipping duplicate event");
1444 continue;
1445 }
1446 if sender.send(ev).is_err() {
1447 warn!("BinanceSpot account_stream receiver dropped, suppressing further sends");
1448 event_tx.take();
1449 if let Some(s) = signal_tx_opt.take() {
1450 let _ = s.send(());
1451 }
1452 return;
1453 }
1454 }
1455 if stream_terminated {
1458 event_tx.take();
1459 if let Some(s) = signal_tx_opt.take() {
1460 let _ = s.send(());
1461 }
1462 }
1463 }
1464 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1465 hb_callback.store(true, Ordering::Release);
1468 }
1469 WebsocketEvent::Error(e) => {
1470 warn!(%e, "BinanceSpot WebSocket error, will attempt reconnect");
1474 event_tx.take();
1475 if let Some(s) = signal_tx_opt.take() {
1476 let _ = s.send(());
1477 }
1478 }
1479 WebsocketEvent::Close(code, reason) => {
1480 warn!(code, %reason, "BinanceSpot WebSocket closed");
1481 event_tx.take();
1482 if let Some(s) = signal_tx_opt.take() {
1483 let _ = s.send(());
1484 }
1485 }
1486 _ => {}
1487 }
1488 });
1489
1490 if let Some(dt) = disconnect_time.take() {
1499 match tokio::time::timeout(
1500 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
1501 recover_fills(&rest, &rate_limiter, &instruments, dt, &tx, &dedup),
1502 )
1503 .await
1504 {
1505 Ok(()) => {}
1506 Err(_) => {
1507 warn!(
1512 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
1513 "BinanceSpot fill recovery timed out — remaining instruments not queried, some fills may be missing"
1514 );
1515 }
1516 }
1517 }
1518
1519 enum DisconnectReason {
1521 Signal,
1522 HeartbeatTimeout,
1523 ConsumerDropped,
1524 }
1525 let reason = {
1526 let mut signal_rx = signal_rx;
1527 loop {
1528 tokio::select! {
1529 _ = tx.closed() => {
1530 debug!("BinanceSpot account_stream consumer dropped, terminating");
1531 break DisconnectReason::ConsumerDropped;
1532 }
1533 _ = &mut signal_rx => {
1534 warn!("BinanceSpot WS disconnected, will attempt reconnect");
1535 break DisconnectReason::Signal;
1536 }
1537 _ = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
1538 if heartbeat_flag.swap(false, Ordering::AcqRel) {
1545 backoff.reset();
1549 continue;
1550 }
1551 warn!("BinanceSpot heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
1552 break DisconnectReason::HeartbeatTimeout;
1553 }
1554 }
1555 }
1556 };
1557 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
1558
1559 if should_reconnect {
1566 disconnect_time = Some(match reason {
1567 DisconnectReason::HeartbeatTimeout => {
1568 Utc::now()
1569 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
1570 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
1571 }
1572 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
1573 });
1574 }
1575
1576 subscription.unsubscribe();
1584 if let Err(e) = ws.disconnect().await {
1587 warn!(%e, "BinanceSpot failed to disconnect WebSocket");
1588 }
1589
1590 if !should_reconnect || tx.is_closed() {
1591 debug!("BinanceSpot connection manager exiting");
1594 break;
1595 }
1596 if !backoff.wait().await {
1597 error!("BinanceSpot max reconnect attempts exhausted, stream terminating");
1598 let last_error = match reason {
1601 DisconnectReason::HeartbeatTimeout => {
1602 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
1603 }
1604 _ => "WebSocket disconnected (server close/error)".to_string(),
1605 };
1606 emit_stream_terminated(
1607 &tx,
1608 ExchangeId::BinanceSpot,
1609 StreamTerminationReason::ReconnectBudgetExhausted {
1610 attempts: backoff.attempts(),
1611 last_error,
1612 },
1613 );
1614 break;
1615 }
1616 }
1617}
1618
1619#[allow(clippy::redundant_iter_cloned)]
1632async fn recover_fills(
1633 rest: &Arc<RestApi>,
1634 rate_limiter: &Arc<RateLimitTracker>,
1635 instruments: &[InstrumentNameExchange],
1636 disconnect_time: DateTime<Utc>,
1637 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1638 dedup: &SharedDedupCache,
1639) {
1640 use futures::StreamExt;
1641
1642 if instruments.is_empty() {
1643 debug!(
1644 "BinanceSpot recover_fills called with empty instruments slice — no fills will be recovered"
1645 );
1646 return;
1647 }
1648 info!(
1649 since = %disconnect_time,
1650 instruments = instruments.len(),
1651 "BinanceSpot recovering fills after reconnect"
1652 );
1653
1654 let start_time_ms = disconnect_time.timestamp_millis();
1655 let mut recovered = 0u32;
1656 let mut duplicates = 0u32;
1657 let mut failed_instruments = 0u32;
1658
1659 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1666 let rest = rest.clone();
1667 let rl = rate_limiter.clone();
1668 async move {
1669 let raw = match paginate_my_trades(&rest, &rl, &inst, start_time_ms).await {
1670 Ok(pages) => pages,
1671 Err(e) => {
1672 warn!(%e, %inst, "BinanceSpot fill recovery: REST request failed");
1673 return None;
1674 }
1675 };
1676 let trades: Vec<_> = raw
1677 .into_iter()
1678 .filter_map(|t| convert_my_trade(&t, &inst))
1679 .collect();
1680 Some(trades)
1681 }
1682 }))
1683 .buffer_unordered(8);
1684 while let Some(result) = stream.next().await {
1685 let trades = match result {
1686 Some(t) => t,
1687 None => {
1688 failed_instruments += 1;
1689 continue;
1690 }
1691 };
1692 for trade in trades {
1693 let event =
1696 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
1697 if let Some(key) = dedup_key_from_event(&event)
1700 && is_duplicate(dedup, key)
1701 {
1702 duplicates += 1;
1703 continue;
1704 }
1705 if tx.send(event).is_err() {
1706 debug!("BinanceSpot fill recovery: consumer dropped during recovery");
1710 return;
1711 }
1712 recovered += 1;
1713 }
1714 }
1715
1716 if failed_instruments > 0 {
1717 error!(
1718 recovered,
1719 duplicates,
1720 failed_instruments,
1721 "BinanceSpot fill recovery complete with failures — some fills may be permanently missed"
1722 );
1723 } else {
1724 info!(recovered, duplicates, "BinanceSpot fill recovery complete");
1725 }
1726}
1727
1728fn convert_balance_entry(
1742 b: binance_sdk::spot::rest_api::GetAccountResponseBalancesInner,
1743 now: chrono::DateTime<Utc>,
1744) -> Option<AssetBalance<AssetNameExchange>> {
1745 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
1746 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1747 Some(v) => v,
1748 None => {
1749 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'free' field");
1750 return None;
1751 }
1752 };
1753 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1754 Some(v) => v,
1755 None => {
1756 warn!(%asset_name, "BinanceSpot balance missing/unparseable 'locked' field");
1757 return None;
1758 }
1759 };
1760 Some(AssetBalance::new(
1761 asset_name,
1762 Balance::new(free + locked, free),
1763 now,
1764 ))
1765}
1766
1767fn filter_and_convert_balances(
1768 balances: Vec<binance_sdk::spot::rest_api::GetAccountResponseBalancesInner>,
1769 assets: &[AssetNameExchange],
1770) -> Vec<AssetBalance<AssetNameExchange>> {
1771 let now = Utc::now();
1772 if assets.is_empty() {
1774 return balances
1775 .into_iter()
1776 .filter_map(|b| convert_balance_entry(b, now))
1777 .collect();
1778 }
1779 if assets.len() <= 16 {
1782 return balances
1783 .into_iter()
1784 .filter_map(|b| {
1785 let asset_name_str = b.asset.as_deref()?;
1786 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
1787 return None;
1788 }
1789 convert_balance_entry(b, now)
1790 })
1791 .collect();
1792 }
1793 use std::collections::HashSet;
1794 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
1795 balances
1796 .into_iter()
1797 .filter_map(|b| {
1798 let asset_name_str = b.asset.as_deref()?;
1799 if !asset_set.contains(asset_name_str) {
1800 return None;
1801 }
1802 convert_balance_entry(b, now)
1803 })
1804 .collect()
1805}
1806
1807trait SpotOrderFields {
1816 fn order_id(&self) -> Option<i64>;
1817 fn client_order_id(&self) -> Option<&str>;
1818 fn side(&self) -> Option<&str>;
1819 fn price(&self) -> Option<&str>;
1820 fn orig_qty(&self) -> Option<&str>;
1821 fn executed_qty(&self) -> Option<&str>;
1822 fn order_type(&self) -> Option<&str>;
1823 fn time_in_force(&self) -> Option<&str>;
1824 fn time(&self) -> Option<i64>;
1825 fn update_time(&self) -> Option<i64>;
1828 fn symbol(&self) -> Option<&str>;
1829}
1830
1831macro_rules! impl_spot_order_fields {
1836 ($($t:ty),* $(,)?) => {
1837 $(
1838 impl SpotOrderFields for $t {
1839 fn order_id(&self) -> Option<i64> { self.order_id }
1840 fn client_order_id(&self) -> Option<&str> { self.client_order_id.as_deref() }
1841 fn side(&self) -> Option<&str> { self.side.as_deref() }
1842 fn price(&self) -> Option<&str> { self.price.as_deref() }
1843 fn orig_qty(&self) -> Option<&str> { self.orig_qty.as_deref() }
1844 fn executed_qty(&self) -> Option<&str> { self.executed_qty.as_deref() }
1845 fn order_type(&self) -> Option<&str> { self.r#type.as_deref() }
1846 fn time_in_force(&self) -> Option<&str> { self.time_in_force.as_deref() }
1847 fn time(&self) -> Option<i64> { self.time }
1848 fn update_time(&self) -> Option<i64> { self.update_time }
1849 fn symbol(&self) -> Option<&str> { self.symbol.as_deref() }
1850 }
1851 )*
1852 };
1853}
1854
1855impl_spot_order_fields!(
1856 binance_sdk::spot::rest_api::AllOrdersResponseInner,
1857 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner,
1858);
1859
1860fn convert_open_order<T: SpotOrderFields>(
1862 o: &T,
1863 instrument: &InstrumentNameExchange,
1864) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
1865 let order_id_raw = match o.order_id() {
1866 Some(id) => id,
1867 None => {
1868 warn!(%instrument, "BinanceSpot open order missing orderId");
1869 return None;
1870 }
1871 };
1872 let order_id = OrderId(format_smolstr!("{}", order_id_raw));
1873 if o.client_order_id().is_none() {
1874 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing clientOrderId, using orderId as fallback — order may not reconcile with engine state");
1875 }
1876 let cid = ClientOrderId::new(
1877 o.client_order_id()
1878 .unwrap_or(&format_smolstr!("{}", order_id_raw)),
1879 );
1880 let side = match o.side() {
1881 Some(s) => parse_side(s)?,
1883 None => {
1884 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing side");
1885 return None;
1886 }
1887 };
1888 let price = o.price().and_then(|s| Decimal::from_str(s).ok());
1889 let quantity = match o.orig_qty().and_then(|s| Decimal::from_str(s).ok()) {
1890 Some(v) => v,
1891 None => {
1892 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing/unparseable origQty");
1893 return None;
1894 }
1895 };
1896 let filled_qty = match o.executed_qty() {
1897 Some(s) => match Decimal::from_str(s) {
1898 Ok(v) => v,
1899 Err(_) => {
1900 warn!(%instrument, order_id = %order_id_raw, executed_qty = s, "BinanceSpot open order unparseable executedQty, defaulting to 0");
1901 Decimal::ZERO
1902 }
1903 },
1904 None => Decimal::ZERO,
1905 };
1906 let kind = match o.order_type() {
1907 Some(t) => parse_order_kind(t)?,
1909 None => {
1910 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing type");
1911 return None;
1912 }
1913 };
1914 let time_in_force = parse_time_in_force(o.time_in_force().unwrap_or("GTC"));
1915 let time_exchange = match o
1921 .update_time()
1922 .or_else(|| o.time())
1923 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1924 {
1925 Some(ts) => ts,
1926 None => {
1927 warn!(%instrument, order_id = %order_id_raw, "BinanceSpot open order missing/unparseable time, using now");
1928 Utc::now()
1929 }
1930 };
1931
1932 Some(Order {
1933 key: OrderKey::new(
1934 ExchangeId::BinanceSpot,
1935 instrument.clone(),
1936 StrategyId::unknown(),
1939 cid,
1940 ),
1941 side,
1942 price,
1943 quantity,
1944 kind,
1945 time_in_force,
1946 state: Open::new(order_id, time_exchange, filled_qty),
1947 })
1948}
1949
1950fn convert_open_order_owned_symbol<T: SpotOrderFields>(
1955 o: &T,
1956) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
1957 let instrument = match o.symbol() {
1958 Some(s) => InstrumentNameExchange::new(s),
1959 None => {
1960 warn!("BinanceSpot open order missing symbol in return-all query, dropping order");
1961 return None;
1962 }
1963 };
1964 convert_open_order(o, &instrument)
1965}
1966
1967fn convert_my_trade(
1969 t: &binance_sdk::spot::rest_api::MyTradesResponseInner,
1970 instrument: &InstrumentNameExchange,
1971) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
1972 let trade_id_raw = match t.id {
1973 Some(id) => id,
1974 None => {
1975 warn!(%instrument, "BinanceSpot trade missing id");
1976 return None;
1977 }
1978 };
1979 let trade_id = TradeId(format_smolstr!("{}", trade_id_raw));
1980 let order_id = match t.order_id {
1981 Some(id) => OrderId(format_smolstr!("{}", id)),
1982 None => {
1983 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing orderId");
1984 return None;
1985 }
1986 };
1987 let side = match t.is_buyer {
1988 Some(is_buyer) => {
1989 if is_buyer {
1990 Side::Buy
1991 } else {
1992 Side::Sell
1993 }
1994 }
1995 None => {
1996 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing isBuyer");
1997 return None;
1998 }
1999 };
2000 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2001 Some(v) => v,
2002 None => {
2003 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable price");
2004 return None;
2005 }
2006 };
2007 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2008 Some(v) => v,
2009 None => {
2010 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable qty");
2011 return None;
2012 }
2013 };
2014 let commission = t
2015 .commission
2016 .as_deref()
2017 .and_then(|s| Decimal::from_str(s).ok())
2018 .unwrap_or(Decimal::ZERO);
2019 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
2020 Some(ts) => ts,
2021 None => {
2022 warn!(%instrument, trade_id = %trade_id_raw, "BinanceSpot trade missing/unparseable time, using now");
2023 Utc::now()
2024 }
2025 };
2026
2027 let fee_asset = t
2032 .commission_asset
2033 .as_deref()
2034 .map(AssetNameExchange::from)
2035 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2036
2037 Some(Trade::new(
2038 trade_id,
2039 order_id,
2040 instrument.clone(),
2041 StrategyId::unknown(), time_exchange,
2043 side,
2044 price,
2045 quantity,
2046 None,
2048 AssetFees::new(fee_asset, commission, None),
2049 ))
2050}
2051
2052fn convert_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2069 #[derive(Deserialize)]
2074 struct EventTag<'a> {
2075 #[serde(borrow, default)]
2076 e: Option<&'a str>,
2077 }
2078
2079 let event_type = serde_json::from_str::<EventTag<'_>>(frame)
2080 .ok()
2081 .and_then(|tag| tag.e)
2082 .unwrap_or_default();
2083 match event_type {
2084 "executionReport" => {
2085 match serde_json::from_str::<binance_sdk::spot::websocket_api::ExecutionReport>(frame) {
2088 Ok(report) => {
2089 buf.extend(convert_execution_report(report).into_iter().flatten());
2090 }
2091 Err(e) => {
2092 warn!(error = %e, "BinanceSpot: undeserializable executionReport, dropping")
2093 }
2094 }
2095 false
2096 }
2097 "outboundAccountPosition" => {
2098 match serde_json::from_str::<binance_sdk::spot::websocket_api::OutboundAccountPosition>(
2099 frame,
2100 ) {
2101 Ok(position) => convert_account_position(position, buf),
2102 Err(e) => {
2103 warn!(error = %e, "BinanceSpot: undeserializable outboundAccountPosition, dropping")
2104 }
2105 }
2106 false
2107 }
2108 "balanceUpdate" => {
2109 false
2116 }
2117 "eventStreamTerminated" => {
2118 warn!("BinanceSpot user data stream terminated by exchange, signalling reconnect");
2122 true
2123 }
2124 _ => {
2127 trace!(event_type, "BinanceSpot ignoring unhandled user data event");
2128 false
2129 }
2130 }
2131}
2132
2133fn trade_order_is_live(status: &str) -> bool {
2143 matches!(status, "PARTIALLY_FILLED" | "FILLED")
2144}
2145
2146#[allow(clippy::cognitive_complexity)]
2166fn convert_execution_report(
2167 report: binance_sdk::spot::websocket_api::ExecutionReport,
2168) -> [Option<UnindexedAccountEvent>; 2] {
2169 let exec_type = match report.x.as_deref() {
2170 Some(t) => t,
2171 None => {
2172 warn!("BinanceSpot executionReport missing execution type (x), dropping");
2173 return [None, None];
2174 }
2175 };
2176 let symbol = match report.s.as_deref() {
2177 Some(s) => InstrumentNameExchange::new(s),
2178 None => {
2179 warn!("BinanceSpot executionReport missing symbol (s), dropping");
2180 return [None, None];
2181 }
2182 };
2183 let order_id = match report.i {
2186 Some(id) => OrderId(format_smolstr!("{id}")),
2187 None => {
2188 warn!(%symbol, "BinanceSpot executionReport missing orderId (i), dropping");
2189 return [None, None];
2190 }
2191 };
2192 let cid = match report.c.as_deref() {
2193 Some(c) => ClientOrderId::new(c),
2194 None => ClientOrderId::new(order_id.0.as_str()),
2195 };
2196
2197 let time_exchange = match report
2199 .t_uppercase
2200 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2201 {
2202 Some(t) => t,
2203 None => {
2204 warn!(%symbol, "BinanceSpot executionReport missing/unparseable transaction time (T), using now");
2205 Utc::now()
2206 }
2207 };
2208
2209 match exec_type {
2210 "NEW" => [
2211 convert_new_order(&report, symbol, cid, order_id, time_exchange),
2212 None,
2213 ],
2214 "TRADE" => {
2215 let trade_id = match report.t {
2217 Some(id) => TradeId(format_smolstr!("{id}")),
2218 None => {
2219 warn!(%symbol, "BinanceSpot TRADE event missing trade ID (t), dropping");
2220 return [None, None];
2221 }
2222 };
2223 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
2224 Some(s) => s,
2225 None => {
2226 warn!(%symbol, "BinanceSpot TRADE event missing/unknown side (S), dropping");
2227 return [None, None];
2228 }
2229 };
2230 let last_price = match report.l_uppercase.as_deref() {
2231 Some(s) => match Decimal::from_str(s) {
2232 Ok(v) => v,
2233 Err(e) => {
2234 warn!(%symbol, error = %e, raw = s, "BinanceSpot TRADE event unparseable last price (L), dropping fill");
2235 return [None, None];
2236 }
2237 },
2238 None => {
2239 warn!(%symbol, "BinanceSpot TRADE event missing last price (L), dropping fill");
2240 return [None, None];
2241 }
2242 };
2243 let last_qty = match report.l.as_deref() {
2244 Some(s) => match Decimal::from_str(s) {
2245 Ok(v) => v,
2246 Err(e) => {
2247 warn!(%symbol, error = %e, raw = s, "BinanceSpot TRADE event unparseable last qty (l), dropping fill");
2248 return [None, None];
2249 }
2250 },
2251 None => {
2252 warn!(%symbol, "BinanceSpot TRADE event missing last qty (l), dropping fill");
2253 return [None, None];
2254 }
2255 };
2256 let commission = match Decimal::from_str(report.n.as_deref().unwrap_or("0")) {
2258 Ok(v) => v,
2259 Err(e) => {
2260 warn!(%symbol, error = %e, "BinanceSpot TRADE event unparseable commission (n), defaulting to 0");
2261 Decimal::ZERO
2262 }
2263 };
2264
2265 let fee_asset = report
2269 .n_uppercase
2270 .as_deref()
2271 .map(AssetNameExchange::from)
2272 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
2273
2274 let order_filled_quantity = report.z.as_deref().and_then(|s| Decimal::from_str(s).ok());
2277
2278 let trade = Trade::new(
2279 trade_id,
2280 order_id.clone(),
2281 symbol.clone(),
2282 StrategyId::unknown(), time_exchange,
2284 side,
2285 last_price,
2286 last_qty,
2287 order_filled_quantity,
2288 AssetFees::new(fee_asset, commission, None),
2289 );
2290 let trade_event =
2291 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
2292
2293 let order_status = report.x_uppercase.as_deref().unwrap_or_default();
2299 let snapshot_event = if trade_order_is_live(order_status) {
2300 convert_new_order(&report, symbol, cid, order_id, time_exchange)
2301 } else {
2302 trace!(
2303 %symbol,
2304 status = order_status,
2305 "BinanceSpot TRADE for an order the exchange no longer reports as working — \
2306 emitting the execution without an order snapshot"
2307 );
2308 None
2309 };
2310
2311 [Some(trade_event), snapshot_event]
2312 }
2313 "CANCELED" | "EXPIRED" | "EXPIRED_IN_MATCH" => {
2314 let filled_qty = report
2315 .z
2316 .as_deref()
2317 .and_then(|s| Decimal::from_str(s).ok())
2318 .unwrap_or(Decimal::ZERO);
2319 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
2320 let response = UnindexedOrderResponseCancel {
2321 key: OrderKey::new(
2322 ExchangeId::BinanceSpot,
2323 symbol,
2324 StrategyId::unknown(), cid,
2326 ),
2327 state: Ok(cancelled),
2328 };
2329
2330 [
2331 Some(UnindexedAccountEvent::new(
2332 ExchangeId::BinanceSpot,
2333 AccountEventKind::OrderCancelled(response),
2334 )),
2335 None,
2336 ]
2337 }
2338 "REJECTED" => {
2339 let reject_reason = report.r.unwrap_or_else(|| "unknown".to_string());
2343 warn!(
2344 %symbol, %order_id, reason = %reject_reason,
2345 "BinanceSpot order REJECTED by matching engine"
2346 );
2347 let response = UnindexedOrderResponseCancel {
2348 key: OrderKey::new(ExchangeId::BinanceSpot, symbol, StrategyId::unknown(), cid),
2349 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
2350 reject_reason,
2351 ))),
2352 };
2353
2354 [
2355 Some(UnindexedAccountEvent::new(
2356 ExchangeId::BinanceSpot,
2357 AccountEventKind::OrderCancelled(response),
2358 )),
2359 None,
2360 ]
2361 }
2362 "REPLACE" => {
2363 let filled_qty = report
2370 .z
2371 .as_deref()
2372 .and_then(|s| Decimal::from_str(s).ok())
2373 .unwrap_or(Decimal::ZERO);
2374 let cancelled = Cancelled::new(order_id, time_exchange, filled_qty);
2375 let response = UnindexedOrderResponseCancel {
2376 key: OrderKey::new(ExchangeId::BinanceSpot, symbol, StrategyId::unknown(), cid),
2377 state: Ok(cancelled),
2378 };
2379 [
2380 Some(UnindexedAccountEvent::new(
2381 ExchangeId::BinanceSpot,
2382 AccountEventKind::OrderCancelled(response),
2383 )),
2384 None,
2385 ]
2386 }
2387 _ => {
2388 trace!(exec_type, "BinanceSpot ignoring execution type");
2391 [None, None]
2392 }
2393 }
2394}
2395
2396fn convert_new_order(
2398 report: &binance_sdk::spot::websocket_api::ExecutionReport,
2399 symbol: InstrumentNameExchange,
2400 cid: ClientOrderId,
2401 order_id: OrderId,
2402 time_exchange: DateTime<Utc>,
2403) -> Option<UnindexedAccountEvent> {
2404 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
2405 Some(s) => s,
2406 None => {
2407 warn!(%symbol, "BinanceSpot NEW event missing/unknown side (S), dropping");
2408 return None;
2409 }
2410 };
2411 let kind = parse_order_kind(report.o.as_deref().unwrap_or("LIMIT"))?;
2412 let price: Option<Decimal> = match (report.p.as_deref(), &kind) {
2413 (Some(p), _) => match Decimal::from_str(p) {
2414 Ok(v) if !v.is_zero() => Some(v),
2415 Ok(_) => {
2416 if matches!(
2420 kind,
2421 OrderKind::Limit
2422 | OrderKind::StopLimit { .. }
2423 | OrderKind::TakeProfitLimit { .. }
2424 | OrderKind::TrailingStopLimit { .. }
2425 ) {
2426 trace!(%symbol, %kind, "BinanceSpot NEW event has zero price (p) on limit-type order, treating as no limit price");
2427 }
2428 None
2429 }
2430 Err(e) => {
2431 warn!(%symbol, price = p, error = %e, "BinanceSpot NEW event unparseable price (p), dropping");
2432 return None;
2433 }
2434 },
2435 (
2436 None,
2437 OrderKind::Market
2438 | OrderKind::Stop { .. }
2439 | OrderKind::TakeProfit { .. }
2440 | OrderKind::TrailingStop { .. },
2441 ) => {
2442 None
2444 }
2445 (
2446 None,
2447 OrderKind::Limit
2448 | OrderKind::StopLimit { .. }
2449 | OrderKind::TakeProfitLimit { .. }
2450 | OrderKind::TrailingStopLimit { .. },
2451 ) => {
2452 warn!(%symbol, "BinanceSpot NEW limit-type order missing price (p), dropping");
2453 return None;
2454 }
2455 };
2456 let quantity = match report.q.as_deref() {
2457 Some(q) => match Decimal::from_str(q) {
2458 Ok(v) => v,
2459 Err(e) => {
2460 warn!(%symbol, qty = q, error = %e, "BinanceSpot NEW event unparseable quantity (q), dropping");
2461 return None;
2462 }
2463 },
2464 None => {
2465 warn!(%symbol, "BinanceSpot NEW order missing quantity (q), dropping");
2466 return None;
2467 }
2468 };
2469 let time_in_force = parse_time_in_force(report.f.as_deref().unwrap_or("GTC"));
2470 let filled_qty = report
2473 .z
2474 .as_deref()
2475 .and_then(|s| Decimal::from_str(s).ok())
2476 .unwrap_or(Decimal::ZERO);
2477
2478 let order = Order {
2479 key: OrderKey::new(
2480 ExchangeId::BinanceSpot,
2481 symbol,
2482 StrategyId::unknown(), cid,
2484 ),
2485 side,
2486 price,
2487 quantity,
2488 kind,
2489 time_in_force,
2490 state: OrderState::active(Open::new(order_id, time_exchange, filled_qty)),
2491 };
2492
2493 Some(UnindexedAccountEvent::new(
2494 ExchangeId::BinanceSpot,
2495 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot::new(
2496 order,
2497 )),
2498 ))
2499}
2500
2501fn convert_account_position(
2506 position: binance_sdk::spot::websocket_api::OutboundAccountPosition,
2507 buf: &mut Vec<UnindexedAccountEvent>,
2508) {
2509 let time_exchange = position
2512 .u
2513 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2514 .unwrap_or_else(Utc::now);
2515
2516 for b in position.b_uppercase.unwrap_or_default() {
2517 let asset = match b.a {
2518 Some(a) => AssetNameExchange::new(a),
2519 None => {
2520 warn!("BinanceSpot account position entry missing asset name");
2521 continue;
2522 }
2523 };
2524 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2525 Some(v) => v,
2526 None => {
2527 warn!(%asset, "BinanceSpot account position missing/unparseable 'free' field");
2528 continue;
2529 }
2530 };
2531 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2532 Some(v) => v,
2533 None => {
2534 warn!(%asset, "BinanceSpot account position missing/unparseable 'locked' field");
2535 continue;
2536 }
2537 };
2538 let update =
2543 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2544 buf.push(UnindexedAccountEvent::new(
2545 ExchangeId::BinanceSpot,
2546 AccountEventKind::BalanceStreamUpdate(
2547 rustrade_integration::collection::snapshot::Snapshot::new(update),
2548 ),
2549 ));
2550 }
2551}
2552
2553fn convert_order_kind_tif(
2580 kind: OrderKind,
2581 tif: TimeInForce,
2582) -> Option<(OrderPlaceTypeEnum, Option<OrderPlaceTimeInForceEnum>)> {
2583 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
2587 let type_enum = match binance_type {
2588 BinanceOrderType::Market => OrderPlaceTypeEnum::Market,
2589 BinanceOrderType::Limit => OrderPlaceTypeEnum::Limit,
2590 BinanceOrderType::LimitMaker => OrderPlaceTypeEnum::LimitMaker,
2591 BinanceOrderType::StopLoss => OrderPlaceTypeEnum::StopLoss,
2592 BinanceOrderType::StopLossLimit => OrderPlaceTypeEnum::StopLossLimit,
2593 BinanceOrderType::TakeProfit => OrderPlaceTypeEnum::TakeProfit,
2594 BinanceOrderType::TakeProfitLimit => OrderPlaceTypeEnum::TakeProfitLimit,
2595 };
2596 let tif_enum = binance_tif.map(|t| match t {
2597 BinanceTimeInForce::Gtc => OrderPlaceTimeInForceEnum::Gtc,
2598 BinanceTimeInForce::Ioc => OrderPlaceTimeInForceEnum::Ioc,
2599 BinanceTimeInForce::Fok => OrderPlaceTimeInForceEnum::Fok,
2600 });
2601 Some((type_enum, tif_enum))
2602}
2603
2604#[cfg(test)]
2605#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
2607 use super::*;
2608 use crate::client::binance::shared::*;
2609 use crate::client::dedup::{DedupEventKind, DedupKey};
2610 use crate::order::TrailingOffsetType;
2611 use binance_sdk::common::errors::WebsocketError;
2612 use smol_str::SmolStr;
2613
2614 #[test]
2615 fn test_binance_spot_config_new_uses_testnet_by_default() {
2616 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2617 assert!(cfg.testnet);
2618 assert_eq!(cfg.api_key(), "my_key");
2619 }
2620
2621 #[test]
2622 fn test_binance_spot_config_production_is_explicit() {
2623 let cfg = BinanceSpotConfig::production("my_key".into(), "my_secret".into());
2624 assert!(!cfg.testnet);
2625 }
2626
2627 #[test]
2628 fn test_binance_spot_config_debug_redacts_credentials() {
2629 let cfg = BinanceSpotConfig::new("my_key".into(), "my_secret".into());
2630 let debug = format!("{cfg:?}");
2631
2632 assert!(!debug.contains("my_key"), "api_key should be redacted");
2633 assert!(
2634 !debug.contains("my_secret"),
2635 "secret_key should be redacted"
2636 );
2637 assert!(debug.contains("testnet: true"));
2638 }
2639
2640 #[test]
2641 fn test_binance_spot_config_deserialize_omitted_testnet_defaults_to_testnet() {
2642 let cfg: BinanceSpotConfig = serde_json::from_str(
2643 r#"{
2644 "api_key": "my_key",
2645 "secret_key": "my_secret"
2646 }"#,
2647 )
2648 .unwrap();
2649
2650 assert!(cfg.testnet);
2651 assert_eq!(cfg.api_key(), "my_key");
2652 }
2653
2654 #[test]
2655 fn test_binance_spot_config_deserialize_testnet_true() {
2656 let cfg: BinanceSpotConfig = serde_json::from_str(
2657 r#"{
2658 "api_key": "my_key",
2659 "secret_key": "my_secret",
2660 "testnet": true
2661 }"#,
2662 )
2663 .unwrap();
2664
2665 assert!(cfg.testnet);
2666 }
2667
2668 #[test]
2669 fn test_binance_spot_config_deserialize_testnet_false() {
2670 let cfg: BinanceSpotConfig = serde_json::from_str(
2671 r#"{
2672 "api_key": "my_key",
2673 "secret_key": "my_secret",
2674 "testnet": false
2675 }"#,
2676 )
2677 .unwrap();
2678
2679 assert!(!cfg.testnet);
2680 }
2681
2682 #[test]
2683 #[serial_test::serial]
2684 fn test_binance_spot_config_from_env_defaults_to_testnet() {
2685 temp_env::with_vars(
2686 [
2687 ("BINANCE_API_KEY", Some("my_key")),
2688 ("BINANCE_SECRET_KEY", Some("my_secret")),
2689 ("BINANCE_TESTNET", None),
2690 ],
2691 || {
2692 let cfg = BinanceSpotConfig::from_env().unwrap();
2693 assert!(cfg.testnet);
2694 assert_eq!(cfg.api_key(), "my_key");
2695 },
2696 );
2697 }
2698
2699 #[test]
2700 #[serial_test::serial]
2701 fn test_binance_spot_config_from_env_accepts_explicit_production() {
2702 temp_env::with_vars(
2703 [
2704 ("BINANCE_API_KEY", Some("my_key")),
2705 ("BINANCE_SECRET_KEY", Some("my_secret")),
2706 ("BINANCE_TESTNET", Some("false")),
2707 ],
2708 || {
2709 let cfg = BinanceSpotConfig::from_env().unwrap();
2710 assert!(!cfg.testnet);
2711 },
2712 );
2713 }
2714
2715 #[test]
2716 #[serial_test::serial]
2717 fn test_binance_spot_config_from_env_rejects_invalid_testnet() {
2718 temp_env::with_vars(
2719 [
2720 ("BINANCE_API_KEY", Some("my_key")),
2721 ("BINANCE_SECRET_KEY", Some("my_secret")),
2722 ("BINANCE_TESTNET", Some("maybe")),
2723 ],
2724 || {
2725 let err = BinanceSpotConfig::from_env().unwrap_err();
2726 assert!(
2727 matches!(err, BinanceSpotConfigError::InvalidTestnet(value) if value == "maybe")
2728 );
2729 },
2730 );
2731 }
2732
2733 #[test]
2734 #[serial_test::serial]
2735 fn test_binance_spot_config_from_env_requires_credentials() {
2736 temp_env::with_vars(
2737 [
2738 ("BINANCE_API_KEY", None),
2739 ("BINANCE_SECRET_KEY", Some("my_secret")),
2740 ("BINANCE_TESTNET", None),
2741 ],
2742 || {
2743 let err = BinanceSpotConfig::from_env().unwrap_err();
2744 assert!(matches!(err, BinanceSpotConfigError::MissingApiKey));
2745 },
2746 );
2747 }
2748
2749 #[test]
2750 fn test_parse_side() {
2751 assert_eq!(parse_side("BUY"), Some(Side::Buy));
2752 assert_eq!(parse_side("SELL"), Some(Side::Sell));
2753 assert_eq!(parse_side("buy"), None);
2754 assert_eq!(parse_side("UNKNOWN"), None);
2755 }
2756
2757 #[test]
2758 fn test_parse_order_kind() {
2759 assert_eq!(parse_order_kind("MARKET"), Some(OrderKind::Market));
2760 assert_eq!(parse_order_kind("LIMIT"), Some(OrderKind::Limit));
2761 assert_eq!(parse_order_kind("LIMIT_MAKER"), Some(OrderKind::Limit));
2762 assert_eq!(parse_order_kind("STOP_LOSS"), None);
2763 assert_eq!(parse_order_kind("TAKE_PROFIT"), None);
2764 assert_eq!(parse_order_kind("STOP_LOSS_LIMIT"), Some(OrderKind::Limit));
2765 assert_eq!(
2766 parse_order_kind("TAKE_PROFIT_LIMIT"),
2767 Some(OrderKind::Limit)
2768 );
2769 assert_eq!(parse_order_kind("UNKNOWN_TYPE"), None);
2770 }
2771
2772 #[test]
2773 fn test_parse_time_in_force() {
2774 assert_eq!(
2775 parse_time_in_force("GTC"),
2776 TimeInForce::GoodUntilCancelled { post_only: false }
2777 );
2778 assert_eq!(
2779 parse_time_in_force("GTX"),
2780 TimeInForce::GoodUntilCancelled { post_only: true }
2781 );
2782 assert_eq!(parse_time_in_force("IOC"), TimeInForce::ImmediateOrCancel);
2783 assert_eq!(parse_time_in_force("FOK"), TimeInForce::FillOrKill);
2784 assert_eq!(parse_time_in_force("GTD"), TimeInForce::GoodUntilEndOfDay);
2785 assert_eq!(
2787 parse_time_in_force("UNKNOWN"),
2788 TimeInForce::GoodUntilCancelled { post_only: false }
2789 );
2790 }
2791
2792 #[test]
2793 fn test_convert_order_kind_tif() {
2794 use rust_decimal::Decimal;
2795
2796 assert!(matches!(
2798 convert_order_kind_tif(OrderKind::Market, TimeInForce::ImmediateOrCancel),
2799 Some((OrderPlaceTypeEnum::Market, None))
2800 ));
2801 assert!(matches!(
2802 convert_order_kind_tif(
2803 OrderKind::Limit,
2804 TimeInForce::GoodUntilCancelled { post_only: false }
2805 ),
2806 Some((
2807 OrderPlaceTypeEnum::Limit,
2808 Some(OrderPlaceTimeInForceEnum::Gtc)
2809 ))
2810 ));
2811 assert!(matches!(
2812 convert_order_kind_tif(
2813 OrderKind::Limit,
2814 TimeInForce::GoodUntilCancelled { post_only: true }
2815 ),
2816 Some((OrderPlaceTypeEnum::LimitMaker, None))
2817 ));
2818 assert!(matches!(
2819 convert_order_kind_tif(OrderKind::Limit, TimeInForce::FillOrKill),
2820 Some((
2821 OrderPlaceTypeEnum::Limit,
2822 Some(OrderPlaceTimeInForceEnum::Fok)
2823 ))
2824 ));
2825 assert!(matches!(
2826 convert_order_kind_tif(OrderKind::Limit, TimeInForce::ImmediateOrCancel),
2827 Some((
2828 OrderPlaceTypeEnum::Limit,
2829 Some(OrderPlaceTimeInForceEnum::Ioc)
2830 ))
2831 ));
2832 assert!(convert_order_kind_tif(OrderKind::Limit, TimeInForce::GoodUntilEndOfDay).is_none());
2835
2836 assert!(matches!(
2838 convert_order_kind_tif(
2839 OrderKind::Stop {
2840 trigger_price: Decimal::from(100)
2841 },
2842 TimeInForce::GoodUntilCancelled { post_only: false }
2843 ),
2844 Some((OrderPlaceTypeEnum::StopLoss, None))
2845 ));
2846 assert!(matches!(
2847 convert_order_kind_tif(
2848 OrderKind::StopLimit {
2849 trigger_price: Decimal::from(100)
2850 },
2851 TimeInForce::GoodUntilCancelled { post_only: false }
2852 ),
2853 Some((
2854 OrderPlaceTypeEnum::StopLossLimit,
2855 Some(OrderPlaceTimeInForceEnum::Gtc)
2856 ))
2857 ));
2858 assert!(matches!(
2859 convert_order_kind_tif(
2860 OrderKind::TakeProfit {
2861 trigger_price: Decimal::from(150)
2862 },
2863 TimeInForce::ImmediateOrCancel
2864 ),
2865 Some((OrderPlaceTypeEnum::TakeProfit, None))
2866 ));
2867 assert!(matches!(
2868 convert_order_kind_tif(
2869 OrderKind::TakeProfitLimit {
2870 trigger_price: Decimal::from(150)
2871 },
2872 TimeInForce::FillOrKill
2873 ),
2874 Some((
2875 OrderPlaceTypeEnum::TakeProfitLimit,
2876 Some(OrderPlaceTimeInForceEnum::Fok)
2877 ))
2878 ));
2879
2880 assert!(matches!(
2882 convert_order_kind_tif(
2883 OrderKind::TrailingStop {
2884 offset: Decimal::from(100),
2885 offset_type: TrailingOffsetType::BasisPoints,
2886 },
2887 TimeInForce::GoodUntilCancelled { post_only: false }
2888 ),
2889 Some((OrderPlaceTypeEnum::StopLoss, None))
2890 ));
2891 assert!(matches!(
2892 convert_order_kind_tif(
2893 OrderKind::TrailingStop {
2894 offset: Decimal::from(5),
2895 offset_type: TrailingOffsetType::Percentage,
2896 },
2897 TimeInForce::GoodUntilCancelled { post_only: false }
2898 ),
2899 Some((OrderPlaceTypeEnum::StopLoss, None))
2900 ));
2901
2902 assert!(
2904 convert_order_kind_tif(
2905 OrderKind::TrailingStop {
2906 offset: Decimal::from(10),
2907 offset_type: TrailingOffsetType::Absolute,
2908 },
2909 TimeInForce::GoodUntilCancelled { post_only: false }
2910 )
2911 .is_none()
2912 );
2913
2914 assert!(
2916 convert_order_kind_tif(
2917 OrderKind::TrailingStopLimit {
2918 offset: Decimal::from(100),
2919 offset_type: TrailingOffsetType::BasisPoints,
2920 limit_offset: Decimal::from(10),
2921 },
2922 TimeInForce::GoodUntilCancelled { post_only: false }
2923 )
2924 .is_none()
2925 );
2926 }
2927
2928 #[test]
2929 fn test_parse_binance_api_error() {
2930 let instrument = InstrumentNameExchange::new("BTCUSDT");
2931
2932 assert!(matches!(
2933 parse_binance_api_error("Insufficient balance".into(), &instrument),
2934 ApiError::BalanceInsufficient(_, _)
2935 ));
2936 assert!(matches!(
2937 parse_binance_api_error("Not enough funds".into(), &instrument),
2938 ApiError::BalanceInsufficient(_, _)
2939 ));
2940 assert_eq!(
2941 parse_binance_api_error("Rate limit exceeded".into(), &instrument),
2942 ApiError::RateLimit
2943 );
2944 assert_eq!(
2945 parse_binance_api_error("Error code -1015".into(), &instrument),
2946 ApiError::RateLimit
2947 );
2948 assert_eq!(
2950 parse_binance_api_error("Error -1003: too many requests".into(), &instrument),
2951 ApiError::RateLimit
2952 );
2953 assert_eq!(
2955 parse_binance_api_error("Error code -2011".into(), &instrument),
2956 ApiError::OrderAlreadyCancelled
2957 );
2958 assert!(matches!(
2960 parse_binance_api_error("Unknown order sent -2013".into(), &instrument),
2961 ApiError::OrderAlreadyCancelled
2962 ));
2963 assert!(matches!(
2965 parse_binance_api_error("Order does not exist -2013".into(), &instrument),
2966 ApiError::OrderAlreadyCancelled
2967 ));
2968 assert!(matches!(
2970 parse_binance_api_error("Unknown order encountered".into(), &instrument),
2971 ApiError::OrderRejected(_)
2972 ));
2973 assert!(matches!(
2974 parse_binance_api_error("Invalid symbol -1121".into(), &instrument),
2975 ApiError::InstrumentInvalid(_, _)
2976 ));
2977 assert!(matches!(
2979 parse_binance_api_error(
2980 "Server-side response error (code -2010): Account has insufficient balance".into(),
2981 &instrument
2982 ),
2983 ApiError::BalanceInsufficient(_, _)
2984 ));
2985 assert!(matches!(
2986 parse_binance_api_error("Some other error".into(), &instrument),
2987 ApiError::OrderRejected(_)
2988 ));
2989 }
2990
2991 #[test]
2992 fn test_contains_error_code_suffix_guard() {
2993 assert!(
2995 !contains_error_code("-20130", "-2013"),
2996 "-20130 should not match -2013"
2997 );
2998 assert!(
2999 !contains_error_code("-20131", "-2013"),
3000 "-20131 should not match -2013"
3001 );
3002 assert!(contains_error_code("-2013", "-2013"), "exact match");
3004 assert!(
3005 contains_error_code("Error -2013: text", "-2013"),
3006 "match with trailing text"
3007 );
3008 }
3009
3010 #[test]
3011 fn test_contains_error_code_prefix_guard() {
3012 assert!(
3015 !contains_error_code("1-2013", "-2013"),
3016 "1-2013 should not match -2013"
3017 );
3018 assert!(
3019 !contains_error_code("error 1-2013 text", "-2013"),
3020 "embedded 1-2013 should not match"
3021 );
3022 assert!(
3024 contains_error_code("code=-2013,", "-2013"),
3025 "=-2013 prefix should match"
3026 );
3027 assert!(
3028 contains_error_code(" -2013 ", "-2013"),
3029 "space prefix should match"
3030 );
3031 }
3032
3033 #[test]
3034 fn test_contains_error_code_second_occurrence_valid() {
3035 assert!(
3038 contains_error_code("response code -20130 or -2013: rate limit", "-2013"),
3039 "second valid occurrence must match when first fails digit guard"
3040 );
3041 assert!(
3043 contains_error_code("-2013 or -20130", "-2013"),
3044 "first valid occurrence must match when second is a longer code"
3045 );
3046 }
3047
3048 #[test]
3049 fn test_dedup_cache() {
3050 let cache = new_dedup_cache();
3051 let key = DedupKey {
3052 instrument: SmolStr::from("BTCUSDT"),
3053 id: SmolStr::from("12345"),
3054 kind: DedupEventKind::Trade,
3055 };
3056
3057 assert!(!is_duplicate(&cache, key));
3059 let key = DedupKey {
3061 instrument: SmolStr::from("BTCUSDT"),
3062 id: SmolStr::from("12345"),
3063 kind: DedupEventKind::Trade,
3064 };
3065 assert!(is_duplicate(&cache, key));
3066
3067 let key2 = DedupKey {
3069 instrument: SmolStr::from("BTCUSDT"),
3070 id: SmolStr::from("12345"),
3071 kind: DedupEventKind::OrderState {
3072 filled_quantity: Decimal::ZERO,
3073 },
3074 };
3075 assert!(!is_duplicate(&cache, key2));
3076 }
3077
3078 #[test]
3082 fn dedup_distinguishes_order_states_and_still_collapses_a_replay() {
3083 let cache = new_dedup_cache();
3084 let state = |filled: &str| DedupKey {
3085 instrument: SmolStr::from("BTCUSDT"),
3086 id: SmolStr::from("12345"),
3087 kind: DedupEventKind::OrderState {
3088 filled_quantity: Decimal::from_str(filled).unwrap(),
3089 },
3090 };
3091
3092 assert!(!is_duplicate(&cache, state("0")), "ack is new");
3094 assert!(
3095 !is_duplicate(&cache, state("1")),
3096 "first fill is not the ack"
3097 );
3098 assert!(
3099 !is_duplicate(&cache, state("2")),
3100 "second fill is not the first"
3101 );
3102
3103 assert!(
3106 is_duplicate(&cache, state("0")),
3107 "replayed ack is a duplicate"
3108 );
3109 assert!(
3110 is_duplicate(&cache, state("2")),
3111 "replayed fill is a duplicate"
3112 );
3113
3114 assert!(
3116 is_duplicate(&cache, state("2.00")),
3117 "2.00 must be the same key as 2"
3118 );
3119 }
3120
3121 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
3128 use crate::client::dedup::dedup_key_from_event;
3129
3130 let cache = new_dedup_cache();
3131 let mut delivered = Vec::new();
3132 let mut buf = Vec::new();
3133 for frame in frames {
3134 let _ = convert_user_data_events(frame, &mut buf);
3135 for ev in buf.drain(..) {
3136 if let Some(key) = dedup_key_from_event(&ev)
3137 && is_duplicate(&cache, key)
3138 {
3139 continue;
3140 }
3141 delivered.push(ev);
3142 }
3143 }
3144 delivered
3145 }
3146
3147 const WS_NEW: &str = r#"{"e":"executionReport","s":"BTCUSDT","i":12345,"c":"client-1",
3148 "x":"NEW","X":"NEW","S":"BUY","o":"LIMIT","f":"GTC","q":"2","p":"100","z":"0",
3149 "T":1700000000000}"#;
3150
3151 const WS_PARTIAL_FILL: &str = r#"{"e":"executionReport","s":"BTCUSDT","i":12345,"c":"client-1",
3152 "x":"TRADE","X":"PARTIALLY_FILLED","S":"BUY","o":"LIMIT","f":"GTC","q":"2","p":"100",
3153 "z":"1","l":"1","L":"100","t":555,"n":"0.1","N":"USDT","T":1700000001000}"#;
3154
3155 #[test]
3163 fn ws_fill_snapshot_survives_the_dedup_gate() {
3164 use crate::order::state::ActiveOrderState;
3165
3166 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL]);
3167
3168 assert_eq!(
3169 delivered.len(),
3170 3,
3171 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
3172 );
3173
3174 let snapshots: Vec<Decimal> = delivered
3175 .iter()
3176 .filter_map(|ev| match &ev.kind {
3177 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
3178 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
3179 _ => None,
3180 },
3181 _ => None,
3182 })
3183 .collect();
3184
3185 assert_eq!(
3186 snapshots,
3187 vec![Decimal::ZERO, Decimal::ONE],
3188 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
3189 );
3190 }
3191
3192 #[test]
3195 fn ws_replayed_frame_is_still_deduplicated() {
3196 let delivered = drive_ws_pipeline(&[WS_NEW, WS_PARTIAL_FILL, WS_NEW, WS_PARTIAL_FILL]);
3197
3198 assert_eq!(
3199 delivered.len(),
3200 3,
3201 "re-delivering both frames must add nothing, got: {delivered:?}"
3202 );
3203 }
3204
3205 #[test]
3206 fn test_is_rate_limit_error() {
3207 assert!(is_rate_limit_error(&anyhow::anyhow!(
3209 "Too many requests. You are being rate-limited. Please slow down."
3210 )));
3211 assert!(is_rate_limit_error(&anyhow::anyhow!(
3213 "The IP address has been banned for exceeding rate limits. Contact support."
3214 )));
3215 assert!(is_rate_limit_error(&anyhow::anyhow!(
3217 "Error -1015: too many new orders"
3218 )));
3219 assert!(is_rate_limit_error(&anyhow::anyhow!(
3220 "Error -1003: too many requests"
3221 )));
3222 assert!(!is_rate_limit_error(&anyhow::anyhow!("order 4290 failed")));
3224 assert!(!is_rate_limit_error(&anyhow::anyhow!("connection timeout")));
3225 assert!(!is_rate_limit_error(&anyhow::anyhow!("unknown error")));
3226 assert!(
3228 !is_rate_limit_error(&anyhow::anyhow!("Error -10150: some other error")),
3229 "-10150 should not match -1015"
3230 );
3231 assert!(
3232 !is_rate_limit_error(&anyhow::anyhow!("Error -10030: some other error")),
3233 "-10030 should not match -1003"
3234 );
3235 }
3236
3237 #[test]
3238 fn test_is_api_rejection_error() {
3239 let rejection = anyhow::anyhow!(WebsocketError::ResponseError {
3241 code: -2010,
3242 message: "Account has insufficient balance for requested action.".into(),
3243 });
3244 assert!(
3245 is_api_rejection_error(&rejection),
3246 "ResponseError should be detected as API rejection"
3247 );
3248
3249 let transport = anyhow::anyhow!("connection reset by peer");
3251 assert!(
3252 !is_api_rejection_error(&transport),
3253 "plain transport error should not be detected as API rejection"
3254 );
3255
3256 let rate_limit = anyhow::anyhow!("Too many requests. You are being rate-limited.");
3258 assert!(
3259 !is_api_rejection_error(&rate_limit),
3260 "rate-limit string error should not be detected as API rejection"
3261 );
3262 }
3263
3264 #[test]
3265 fn test_connectivity_error_detects_auth_failures() {
3266 let err = connectivity_error(anyhow::anyhow!("Error -1002: unauthorized"));
3268 assert!(
3269 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3270 "expected Unauthenticated for -1002, got {err:?}"
3271 );
3272
3273 let err = connectivity_error(anyhow::anyhow!("Error -2015: permission denied for action"));
3276 assert!(
3277 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3278 "expected Unauthenticated for -2015, got {err:?}"
3279 );
3280
3281 let err = connectivity_error(anyhow::anyhow!("invalid signature provided"));
3283 assert!(
3284 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3285 "expected Unauthenticated for 'invalid signature', got {err:?}"
3286 );
3287
3288 let err = connectivity_error(anyhow::anyhow!(
3290 "The signature for this request is not valid."
3291 ));
3292 assert!(
3293 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3294 "expected Unauthenticated for 'signature for this request is not valid', got {err:?}"
3295 );
3296
3297 let err = connectivity_error(anyhow::anyhow!("Invalid API-key format"));
3299 assert!(
3300 matches!(err, UnindexedClientError::Api(ApiError::Unauthenticated(_))),
3301 "expected Unauthenticated for 'invalid api-key', got {err:?}"
3302 );
3303
3304 let err = connectivity_error(anyhow::anyhow!("connection timeout"));
3306 assert!(
3307 matches!(err, UnindexedClientError::Connectivity(_)),
3308 "expected Connectivity for timeout, got {err:?}"
3309 );
3310 }
3311
3312 #[test]
3313 fn test_dedup_key_from_event_trade_includes_instrument() {
3314 use crate::order::id::{OrderId, StrategyId};
3315 use crate::trade::{AssetFees, Trade, TradeId};
3316 use chrono::Utc;
3317 use rust_decimal::Decimal;
3318 use rustrade_instrument::Side;
3319
3320 let instrument = InstrumentNameExchange::new("BTCUSDT");
3321 let trade = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3322 TradeId::new("9001"),
3323 OrderId::new("4242"),
3324 instrument.clone(),
3325 StrategyId::unknown(),
3326 Utc::now(),
3327 Side::Buy,
3328 Decimal::ZERO,
3329 Decimal::ZERO,
3330 None,
3331 AssetFees::new(
3332 AssetNameExchange::from("USDT"),
3333 Decimal::ZERO,
3334 Some(Decimal::ZERO),
3335 ),
3336 );
3337 let event =
3338 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade));
3339
3340 let key = dedup_key_from_event(&event).expect("Trade should produce a DedupKey");
3341 assert_eq!(key.kind, DedupEventKind::Trade);
3342 assert_eq!(key.id.as_str(), "9001", "key.id should be the trade ID");
3343 assert_eq!(
3344 key.instrument.as_str(),
3345 "BTCUSDT",
3346 "key.instrument should be the symbol"
3347 );
3348
3349 let instrument2 = InstrumentNameExchange::new("ETHUSDT");
3351 let trade2 = Trade::<AssetNameExchange, InstrumentNameExchange>::new(
3352 TradeId::new("9001"),
3353 OrderId::new("7777"),
3354 instrument2,
3355 StrategyId::unknown(),
3356 Utc::now(),
3357 Side::Buy,
3358 Decimal::ZERO,
3359 Decimal::ZERO,
3360 None,
3361 AssetFees::new(
3362 AssetNameExchange::from("USDT"),
3363 Decimal::ZERO,
3364 Some(Decimal::ZERO),
3365 ),
3366 );
3367 let event2 =
3368 UnindexedAccountEvent::new(ExchangeId::BinanceSpot, AccountEventKind::Trade(trade2));
3369 let key2 = dedup_key_from_event(&event2).expect("Trade should produce a DedupKey");
3370 assert_ne!(
3371 key, key2,
3372 "same trade ID on different symbols must produce distinct keys"
3373 );
3374 }
3375
3376 fn make_balance_inner(
3381 asset: &str,
3382 free: &str,
3383 locked: &str,
3384 ) -> binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3385 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3386 a: Some(asset.to_string()),
3387 f: Some(free.to_string()),
3388 l: Some(locked.to_string()),
3389 }
3390 }
3391
3392 #[test]
3393 fn test_convert_account_position_happy_path() {
3394 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3395 u: Some(1_700_000_000_000),
3396 b_uppercase: Some(vec![make_balance_inner("BTC", "1.5", "0.5")]),
3397 ..Default::default()
3398 };
3399 let mut buf = Vec::new();
3400 convert_account_position(position, &mut buf);
3401
3402 assert_eq!(buf.len(), 1);
3403 match &buf[0].kind {
3404 AccountEventKind::BalanceStreamUpdate(snap) => {
3405 let update = &snap.0;
3406 assert_eq!(update.asset.as_ref(), "BTC");
3407 let expected_free = Decimal::from_str("1.5").unwrap();
3409 let expected_locked = Decimal::from_str("0.5").unwrap();
3410 assert_eq!(update.update.free, expected_free);
3411 assert_eq!(update.update.locked, expected_locked);
3412 assert_eq!(update.update.total(), Decimal::from_str("2.0").unwrap());
3413 }
3414 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3415 }
3416 }
3417
3418 #[test]
3419 fn test_convert_account_position_u_field_none_uses_now() {
3420 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3422 u: None,
3423 b_uppercase: Some(vec![make_balance_inner("ETH", "2.0", "0.0")]),
3424 ..Default::default()
3425 };
3426 let mut buf = Vec::new();
3427 convert_account_position(position, &mut buf);
3428 assert_eq!(buf.len(), 1);
3429 }
3430
3431 #[test]
3432 fn test_convert_account_position_missing_asset_name_skipped() {
3433 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3434 u: Some(1_700_000_000_000),
3435 b_uppercase: Some(vec![
3436 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3437 a: None, f: Some("1.0".to_string()),
3439 l: Some("0.0".to_string()),
3440 },
3441 make_balance_inner("USDT", "100.0", "0.0"), ]),
3443 ..Default::default()
3444 };
3445 let mut buf = Vec::new();
3446 convert_account_position(position, &mut buf);
3447 assert_eq!(buf.len(), 1);
3449 match &buf[0].kind {
3450 AccountEventKind::BalanceStreamUpdate(snap) => {
3451 assert_eq!(snap.0.asset.as_ref(), "USDT");
3452 }
3453 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3454 }
3455 }
3456
3457 #[test]
3458 fn test_convert_account_position_unparseable_free_skipped() {
3459 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3460 u: Some(1_700_000_000_000),
3461 b_uppercase: Some(vec![
3462 binance_sdk::spot::websocket_api::OutboundAccountPositionBInner {
3463 a: Some("BTC".to_string()),
3464 f: Some("not-a-number".to_string()),
3465 l: Some("0.0".to_string()),
3466 },
3467 make_balance_inner("ETH", "1.0", "0.0"), ]),
3469 ..Default::default()
3470 };
3471 let mut buf = Vec::new();
3472 convert_account_position(position, &mut buf);
3473 assert_eq!(buf.len(), 1);
3475 match &buf[0].kind {
3476 AccountEventKind::BalanceStreamUpdate(snap) => {
3477 assert_eq!(snap.0.asset.as_ref(), "ETH");
3478 }
3479 other => panic!("expected BalanceStreamUpdate, got {:?}", other),
3480 }
3481 }
3482
3483 #[test]
3484 fn test_convert_account_position_empty_balances() {
3485 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3486 u: Some(1_700_000_000_000),
3487 b_uppercase: Some(vec![]),
3488 ..Default::default()
3489 };
3490 let mut buf = Vec::new();
3491 convert_account_position(position, &mut buf);
3492 assert!(
3493 buf.is_empty(),
3494 "empty balance list should produce no events"
3495 );
3496 }
3497
3498 #[test]
3499 fn test_convert_account_position_b_field_none() {
3500 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
3501 u: Some(1_700_000_000_000),
3502 b_uppercase: None, ..Default::default()
3504 };
3505 let mut buf = Vec::new();
3506 convert_account_position(position, &mut buf);
3507 assert!(buf.is_empty(), "None B field should produce no events");
3508 }
3509
3510 #[tokio::test]
3512 async fn test_exponential_backoff_exhaustion() {
3513 tokio::time::pause(); let mut backoff = ExponentialBackoff::new();
3515
3516 for i in 0..MAX_RECONNECT_ATTEMPTS {
3518 assert!(
3519 backoff.wait().await,
3520 "expected true on attempt {i} (before exhaustion)"
3521 );
3522 }
3523
3524 assert!(!backoff.wait().await, "expected false after max attempts");
3526 }
3527
3528 #[tokio::test]
3529 async fn test_exponential_backoff_reset() {
3530 tokio::time::pause();
3531 let mut backoff = ExponentialBackoff::new();
3532
3533 for _ in 0..MAX_RECONNECT_ATTEMPTS {
3534 backoff.wait().await;
3535 }
3536 assert!(!backoff.wait().await, "should be exhausted");
3537
3538 backoff.reset();
3539 assert!(backoff.wait().await, "should succeed again after reset");
3540 }
3541
3542 fn make_base_report() -> binance_sdk::spot::websocket_api::ExecutionReport {
3546 binance_sdk::spot::websocket_api::ExecutionReport {
3547 s: Some("BTCUSDT".to_string()),
3548 i: Some(12345),
3549 c: Some("client-1".to_string()),
3550 t_uppercase: Some(1_700_000_000_000),
3551 ..Default::default()
3552 }
3553 }
3554
3555 fn sole_event(events: [Option<UnindexedAccountEvent>; 2]) -> Option<UnindexedAccountEvent> {
3560 let [first, second] = events;
3561 assert!(
3562 second.is_none(),
3563 "expected a single event, got a second: {second:?}"
3564 );
3565 first
3566 }
3567
3568 #[test]
3569 fn test_convert_execution_report_new() {
3570 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3571 x: Some("NEW".to_string()),
3572 s_uppercase: Some("BUY".to_string()),
3573 o: Some("LIMIT".to_string()),
3574 p: Some("50000.00".to_string()),
3575 q: Some("0.01".to_string()),
3576 f: Some("GTC".to_string()),
3577 z: Some("0".to_string()),
3578 ..make_base_report()
3579 };
3580
3581 let event =
3582 sole_event(convert_execution_report(report)).expect("NEW event should produce Some");
3583 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3584 match &event.kind {
3585 AccountEventKind::OrderSnapshot(snap) => {
3586 let order = &snap.0;
3587 assert_eq!(order.side, Side::Buy);
3588 assert_eq!(order.kind, OrderKind::Limit);
3589 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
3590 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
3591 assert_eq!(order.key.cid.0.as_str(), "client-1");
3592 assert_eq!(order.key.instrument.name().as_str(), "BTCUSDT");
3593 }
3594 other => panic!("NEW should yield OrderSnapshot, got {other:?}"),
3595 }
3596 }
3597
3598 #[test]
3599 fn test_convert_execution_report_trade() {
3600 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3603 x: Some("TRADE".to_string()),
3604 s_uppercase: Some("BUY".to_string()),
3605 t: Some(9999),
3606 l_uppercase: Some("50000.00".to_string()),
3607 l: Some("0.01".to_string()),
3608 n: Some("0.000001".to_string()),
3609 ..make_base_report()
3610 };
3611
3612 let event =
3613 sole_event(convert_execution_report(report)).expect("TRADE event should produce Some");
3614 assert_eq!(event.exchange, ExchangeId::BinanceSpot);
3615 match &event.kind {
3616 AccountEventKind::Trade(trade) => {
3617 assert_eq!(trade.side, Side::Buy);
3618 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
3619 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
3620 assert_eq!(trade.id.0.as_str(), "9999");
3621 assert_eq!(trade.order_id.0.as_str(), "12345");
3622 assert_eq!(trade.instrument.name().as_str(), "BTCUSDT");
3623 }
3624 other => panic!("TRADE should yield Trade, got {other:?}"),
3625 }
3626 }
3627
3628 fn trade_events(
3630 events: [Option<UnindexedAccountEvent>; 2],
3631 ) -> (
3632 Trade<AssetNameExchange, InstrumentNameExchange>,
3633 crate::order::Order<
3634 ExchangeId,
3635 InstrumentNameExchange,
3636 OrderState<AssetNameExchange, InstrumentNameExchange>,
3637 >,
3638 ) {
3639 let [first, second] = events;
3640 let first = first.expect("a fill report must produce an execution");
3641 let second = second.expect("a fill report must produce an order snapshot");
3642 let AccountEventKind::Trade(trade) = first.kind else {
3643 panic!("first event must be the Trade, got {:?}", first.kind);
3644 };
3645 let AccountEventKind::OrderSnapshot(snap) = second.kind else {
3646 panic!(
3647 "second event must be the OrderSnapshot, got {:?}",
3648 second.kind
3649 );
3650 };
3651 (trade, snap.0)
3652 }
3653
3654 #[test]
3658 fn a_partially_filled_report_carries_the_cumulative_filled_quantity() {
3659 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3660 x: Some("TRADE".to_string()),
3661 x_uppercase: Some("PARTIALLY_FILLED".to_string()),
3662 s_uppercase: Some("BUY".to_string()),
3663 o: Some("LIMIT".to_string()),
3664 p: Some("50000.00".to_string()),
3665 q: Some("0.01".to_string()),
3666 f: Some("GTC".to_string()),
3667 t: Some(9999),
3668 l_uppercase: Some("50000.00".to_string()),
3669 l: Some("0.004".to_string()),
3670 z: Some("0.004".to_string()),
3671 ..make_base_report()
3672 };
3673
3674 let (trade, order) = trade_events(convert_execution_report(report));
3675 assert_eq!(trade.quantity, Decimal::from_str("0.004").unwrap());
3676
3677 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3678 else {
3679 panic!("expected an Open snapshot, got {:?}", order.state);
3680 };
3681 assert_eq!(open.filled_quantity, Decimal::from_str("0.004").unwrap());
3682 assert_eq!(
3683 open.quantity_remaining(order.quantity),
3684 Decimal::from_str("0.006").unwrap()
3685 );
3686 }
3687
3688 #[test]
3691 fn a_filled_report_reports_nothing_left_to_fill() {
3692 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3693 x: Some("TRADE".to_string()),
3694 x_uppercase: Some("FILLED".to_string()),
3695 s_uppercase: Some("BUY".to_string()),
3696 o: Some("LIMIT".to_string()),
3697 p: Some("50000.00".to_string()),
3698 q: Some("0.01".to_string()),
3699 f: Some("GTC".to_string()),
3700 t: Some(10_000),
3701 l_uppercase: Some("50000.00".to_string()),
3702 l: Some("0.006".to_string()),
3703 z: Some("0.01".to_string()),
3704 ..make_base_report()
3705 };
3706
3707 let (_trade, order) = trade_events(convert_execution_report(report));
3708 let OrderState::Active(crate::order::state::ActiveOrderState::Open(open)) = order.state
3709 else {
3710 panic!("expected an Open snapshot, got {:?}", order.state);
3711 };
3712 assert!(
3713 open.quantity_remaining(order.quantity).is_zero(),
3714 "a completed order must report nothing left to fill so the engine retires it"
3715 );
3716 }
3717
3718 #[test]
3722 fn a_trade_for_an_order_no_longer_working_emits_the_execution_without_a_snapshot() {
3723 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
3724 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3725 x: Some("TRADE".to_string()),
3726 x_uppercase: Some(status.to_string()),
3727 s_uppercase: Some("BUY".to_string()),
3728 o: Some("LIMIT".to_string()),
3729 p: Some("50000.00".to_string()),
3730 q: Some("0.01".to_string()),
3731 f: Some("GTC".to_string()),
3732 t: Some(9999),
3733 l_uppercase: Some("50000.00".to_string()),
3734 l: Some("0.004".to_string()),
3735 z: Some("0.004".to_string()),
3736 ..make_base_report()
3737 };
3738 let [trade, snapshot] = convert_execution_report(report);
3739 assert!(
3740 matches!(trade.map(|e| e.kind), Some(AccountEventKind::Trade(_))),
3741 "the execution must still be reported for status {status}"
3742 );
3743 assert!(
3744 snapshot.is_none(),
3745 "status {status} must not produce an order snapshot"
3746 );
3747 }
3748 }
3749
3750 #[test]
3751 fn test_convert_execution_report_trade_missing_last_price() {
3752 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3754 x: Some("TRADE".to_string()),
3755 s_uppercase: Some("BUY".to_string()),
3756 t: Some(9999),
3757 l_uppercase: None, l: Some("0.01".to_string()),
3759 ..make_base_report()
3760 };
3761 assert!(
3762 sole_event(convert_execution_report(report)).is_none(),
3763 "missing last price (L) should return None"
3764 );
3765 }
3766
3767 #[test]
3768 fn test_convert_execution_report_trade_missing_last_qty() {
3769 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3771 x: Some("TRADE".to_string()),
3772 s_uppercase: Some("BUY".to_string()),
3773 t: Some(9999),
3774 l_uppercase: Some("50000.00".to_string()),
3775 l: None, ..make_base_report()
3777 };
3778 assert!(
3779 sole_event(convert_execution_report(report)).is_none(),
3780 "missing last qty (l) should return None"
3781 );
3782 }
3783
3784 #[test]
3785 fn test_convert_execution_report_canceled() {
3786 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3787 x: Some("CANCELED".to_string()),
3788 ..make_base_report()
3789 };
3790
3791 let event =
3792 sole_event(convert_execution_report(report)).expect("CANCELED should produce Some");
3793 assert!(
3794 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3795 "CANCELED should yield OrderCancelled with Ok state"
3796 );
3797 }
3798
3799 #[test]
3800 fn test_convert_execution_report_expired() {
3801 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3802 x: Some("EXPIRED".to_string()),
3803 ..make_base_report()
3804 };
3805 let event =
3806 sole_event(convert_execution_report(report)).expect("EXPIRED should produce Some");
3807 assert!(
3808 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3809 "EXPIRED should yield OrderCancelled with Ok state"
3810 );
3811 }
3812
3813 #[test]
3814 fn test_convert_execution_report_expired_in_match() {
3815 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3816 x: Some("EXPIRED_IN_MATCH".to_string()),
3817 ..make_base_report()
3818 };
3819 let event = sole_event(convert_execution_report(report))
3820 .expect("EXPIRED_IN_MATCH should produce Some");
3821 assert!(
3822 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3823 "EXPIRED_IN_MATCH should yield OrderCancelled with Ok state"
3824 );
3825 }
3826
3827 #[test]
3828 fn test_convert_execution_report_rejected() {
3829 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3830 x: Some("REJECTED".to_string()),
3831 r: Some("INSUFFICIENT_FUNDS".to_string()),
3832 ..make_base_report()
3833 };
3834
3835 let event =
3836 sole_event(convert_execution_report(report)).expect("REJECTED should produce Some");
3837 assert!(
3838 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_err()),
3839 "REJECTED should yield OrderCancelled with Err state"
3840 );
3841 }
3842
3843 #[test]
3844 fn test_convert_execution_report_missing_exec_type() {
3845 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3847 s: Some("BTCUSDT".to_string()),
3848 ..Default::default()
3849 };
3850 assert!(
3851 sole_event(convert_execution_report(report)).is_none(),
3852 "missing execution type should return None"
3853 );
3854 }
3855
3856 #[test]
3857 fn test_convert_execution_report_missing_symbol() {
3858 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3860 x: Some("NEW".to_string()),
3861 ..Default::default()
3862 };
3863 assert!(
3864 sole_event(convert_execution_report(report)).is_none(),
3865 "missing symbol should return None"
3866 );
3867 }
3868
3869 #[test]
3870 fn test_convert_execution_report_missing_order_id() {
3871 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3873 x: Some("NEW".to_string()),
3874 s: Some("BTCUSDT".to_string()),
3875 i: None,
3876 ..Default::default()
3877 };
3878 assert!(
3879 sole_event(convert_execution_report(report)).is_none(),
3880 "missing orderId should return None"
3881 );
3882 }
3883
3884 #[test]
3885 fn test_convert_execution_report_trade_missing_trade_id() {
3886 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3888 x: Some("TRADE".to_string()),
3889 t: None,
3890 ..make_base_report()
3891 };
3892 assert!(
3893 sole_event(convert_execution_report(report)).is_none(),
3894 "TRADE event missing tradeId should return None"
3895 );
3896 }
3897
3898 #[test]
3899 fn test_convert_execution_report_replace_yields_cancelled() {
3900 let report = binance_sdk::spot::websocket_api::ExecutionReport {
3901 x: Some("REPLACE".to_string()),
3902 ..make_base_report()
3903 };
3904 let event =
3907 sole_event(convert_execution_report(report)).expect("REPLACE should produce Some");
3908 assert!(
3909 matches!(event.kind, AccountEventKind::OrderCancelled(ref r) if r.state.is_ok()),
3910 "REPLACE should yield OrderCancelled with Ok state"
3911 );
3912 }
3913
3914 fn make_balance(
3919 asset: &str,
3920 free: &str,
3921 locked: &str,
3922 ) -> binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3923 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3924 asset: Some(asset.to_string()),
3925 free: Some(free.to_string()),
3926 locked: Some(locked.to_string()),
3927 }
3928 }
3929
3930 #[test]
3931 fn test_filter_balances_empty_assets_returns_all() {
3932 let balances = vec![
3933 make_balance("BTC", "1.0", "0.0"),
3934 make_balance("USDT", "500.0", "50.0"),
3935 ];
3936 let result = filter_and_convert_balances(balances, &[]);
3937 assert_eq!(result.len(), 2, "empty filter should return all balances");
3938 }
3939
3940 #[test]
3941 fn test_filter_balances_matching_asset_returned() {
3942 let balances = vec![
3943 make_balance("BTC", "1.5", "0.5"),
3944 make_balance("ETH", "10.0", "0.0"),
3945 ];
3946 let assets = vec![AssetNameExchange::new("BTC")];
3947 let result = filter_and_convert_balances(balances, &assets);
3948 assert_eq!(result.len(), 1);
3949 assert_eq!(result[0].asset, AssetNameExchange::new("BTC"));
3950 assert_eq!(result[0].balance.total, Decimal::from_str("2.0").unwrap());
3952 assert_eq!(result[0].balance.free, Decimal::from_str("1.5").unwrap());
3953 }
3954
3955 #[test]
3956 fn test_filter_balances_non_matching_asset_filtered_out() {
3957 let balances = vec![
3958 make_balance("BTC", "1.0", "0.0"),
3959 make_balance("ETH", "2.0", "0.0"),
3960 ];
3961 let assets = vec![AssetNameExchange::new("USDT")];
3962 let result = filter_and_convert_balances(balances, &assets);
3963 assert!(
3964 result.is_empty(),
3965 "non-matching asset should be filtered out"
3966 );
3967 }
3968
3969 #[test]
3970 fn test_filter_balances_missing_asset_field_skipped() {
3971 let balances = vec![
3972 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3973 asset: None,
3974 free: Some("1.0".to_string()),
3975 locked: Some("0.0".to_string()),
3976 },
3977 make_balance("USDT", "100.0", "0.0"),
3978 ];
3979 let result = filter_and_convert_balances(balances, &[]);
3980 assert_eq!(
3981 result.len(),
3982 1,
3983 "entry with missing asset should be skipped"
3984 );
3985 assert_eq!(result[0].asset, AssetNameExchange::new("USDT"));
3986 }
3987
3988 #[test]
3989 fn test_filter_balances_unparseable_free_skipped() {
3990 let balances = vec![
3991 binance_sdk::spot::rest_api::GetAccountResponseBalancesInner {
3992 asset: Some("BTC".to_string()),
3993 free: Some("not-a-number".to_string()),
3994 locked: Some("0.0".to_string()),
3995 },
3996 ];
3997 let result = filter_and_convert_balances(balances, &[]);
3998 assert!(
3999 result.is_empty(),
4000 "unparseable free field should be skipped"
4001 );
4002 }
4003
4004 #[test]
4005 fn test_filter_balances_zero_balance_included() {
4006 let balances = vec![
4009 make_balance("BTC", "0.00000000", "0.00000000"),
4010 make_balance("USDT", "100.0", "0.0"),
4011 ];
4012 let result = filter_and_convert_balances(balances, &[]);
4013 assert_eq!(result.len(), 2, "zero-balance entries must be included");
4014 let btc = result
4015 .iter()
4016 .find(|b| b.asset == AssetNameExchange::new("BTC"))
4017 .unwrap();
4018 assert_eq!(btc.balance.total, Decimal::ZERO);
4019 assert_eq!(btc.balance.free, Decimal::ZERO);
4020 }
4021
4022 #[test]
4023 fn test_filter_balances_duplicate_assets_in_response() {
4024 let balances = vec![
4028 make_balance("BTC", "1.0", "0.0"),
4029 make_balance("BTC", "2.0", "0.0"),
4030 ];
4031 let result = filter_and_convert_balances(balances, &[]);
4032 assert_eq!(
4033 result.len(),
4034 2,
4035 "duplicate asset entries produce two AssetBalance entries"
4036 );
4037 }
4038
4039 fn make_base_trade() -> binance_sdk::spot::rest_api::MyTradesResponseInner {
4044 binance_sdk::spot::rest_api::MyTradesResponseInner {
4045 id: Some(9001),
4046 order_id: Some(4242),
4047 price: Some("50000.00".to_string()),
4048 qty: Some("0.01".to_string()),
4049 commission: Some("0.05".to_string()),
4050 time: Some(1_700_000_000_000),
4051 is_buyer: Some(true),
4052 ..Default::default()
4053 }
4054 }
4055
4056 #[test]
4057 fn test_convert_my_trade_happy_path() {
4058 let instrument = InstrumentNameExchange::new("BTCUSDT");
4059 let trade =
4060 convert_my_trade(&make_base_trade(), &instrument).expect("valid trade should convert");
4061 assert_eq!(trade.instrument, instrument);
4062 assert_eq!(trade.side, Side::Buy);
4063 assert_eq!(trade.price, Decimal::from_str("50000.00").unwrap());
4064 assert_eq!(trade.quantity, Decimal::from_str("0.01").unwrap());
4065 }
4066
4067 #[test]
4068 fn test_convert_my_trade_sell_side() {
4069 let instrument = InstrumentNameExchange::new("BTCUSDT");
4070 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4071 is_buyer: Some(false),
4072 ..make_base_trade()
4073 };
4074 let trade = convert_my_trade(&t, &instrument).expect("sell-side trade should convert");
4075 assert_eq!(trade.side, Side::Sell);
4076 }
4077
4078 #[test]
4079 fn test_convert_my_trade_missing_id_returns_none() {
4080 let instrument = InstrumentNameExchange::new("BTCUSDT");
4081 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4082 id: None,
4083 ..make_base_trade()
4084 };
4085 assert!(
4086 convert_my_trade(&t, &instrument).is_none(),
4087 "missing id should return None"
4088 );
4089 }
4090
4091 #[test]
4092 fn test_convert_my_trade_missing_order_id_returns_none() {
4093 let instrument = InstrumentNameExchange::new("BTCUSDT");
4094 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4095 order_id: None,
4096 ..make_base_trade()
4097 };
4098 assert!(
4099 convert_my_trade(&t, &instrument).is_none(),
4100 "missing orderId should return None"
4101 );
4102 }
4103
4104 #[test]
4105 fn test_convert_my_trade_missing_is_buyer_returns_none() {
4106 let instrument = InstrumentNameExchange::new("BTCUSDT");
4107 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4108 is_buyer: None,
4109 ..make_base_trade()
4110 };
4111 assert!(
4112 convert_my_trade(&t, &instrument).is_none(),
4113 "missing isBuyer should return None"
4114 );
4115 }
4116
4117 #[test]
4118 fn test_convert_my_trade_commission_none_defaults_to_zero() {
4119 let instrument = InstrumentNameExchange::new("BTCUSDT");
4120 let t = binance_sdk::spot::rest_api::MyTradesResponseInner {
4121 commission: None,
4122 ..make_base_trade()
4123 };
4124 let trade =
4125 convert_my_trade(&t, &instrument).expect("None commission should still convert");
4126 assert_eq!(trade.fees.fees, Decimal::ZERO);
4127 }
4128
4129 fn make_base_open_order() -> binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4134 binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4135 order_id: Some(12345),
4136 client_order_id: Some("cid-abc".to_string()),
4137 side: Some("BUY".to_string()),
4138 r#type: Some("LIMIT".to_string()),
4139 price: Some("50000.00".to_string()),
4140 orig_qty: Some("0.01".to_string()),
4141 executed_qty: Some("0.0".to_string()),
4142 time_in_force: Some("GTC".to_string()),
4143 time: Some(1_700_000_000_000),
4144 ..Default::default()
4145 }
4146 }
4147
4148 #[test]
4149 fn test_convert_open_order_all_orders_response_converts_identically() {
4150 let instrument = InstrumentNameExchange::new("BTCUSDT");
4155 let all_orders = binance_sdk::spot::rest_api::AllOrdersResponseInner {
4156 order_id: Some(12345),
4157 client_order_id: Some("cid-abc".to_string()),
4158 side: Some("BUY".to_string()),
4159 r#type: Some("LIMIT".to_string()),
4160 price: Some("50000.00".to_string()),
4161 orig_qty: Some("0.01".to_string()),
4162 executed_qty: Some("0.0".to_string()),
4163 time_in_force: Some("GTC".to_string()),
4164 time: Some(1_700_000_000_000),
4165 ..Default::default()
4166 };
4167 let from_all_orders =
4168 convert_open_order(&all_orders, &instrument).expect("valid order should convert");
4169 let from_open_orders = convert_open_order(&make_base_open_order(), &instrument)
4170 .expect("valid order should convert");
4171 assert_eq!(from_all_orders, from_open_orders);
4172 }
4173
4174 #[test]
4180 fn test_convert_open_order_prefers_update_time_over_creation_time() {
4181 let instrument = InstrumentNameExchange::new("BTCUSDT");
4182
4183 let updated = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4184 update_time: Some(1_700_000_005_000),
4185 ..make_base_open_order()
4186 };
4187 let order = convert_open_order(&updated, &instrument).expect("valid order should convert");
4188 assert_eq!(
4189 order.state.time_exchange,
4190 Utc.timestamp_millis_opt(1_700_000_005_000)
4191 .single()
4192 .unwrap(),
4193 "update_time must win over time"
4194 );
4195
4196 let order = convert_open_order(&make_base_open_order(), &instrument)
4199 .expect("valid order should convert");
4200 assert_eq!(
4201 order.state.time_exchange,
4202 Utc.timestamp_millis_opt(1_700_000_000_000)
4203 .single()
4204 .unwrap(),
4205 "absent update_time falls back to time"
4206 );
4207 }
4208
4209 #[test]
4210 fn test_convert_open_order_happy_path() {
4211 let instrument = InstrumentNameExchange::new("BTCUSDT");
4212 let order = convert_open_order(&make_base_open_order(), &instrument)
4213 .expect("valid order should convert");
4214 assert_eq!(order.key.instrument, instrument);
4215 assert_eq!(order.side, Side::Buy);
4216 assert_eq!(order.kind, OrderKind::Limit);
4217 assert_eq!(order.price, Some(Decimal::from_str("50000.00").unwrap()));
4218 assert_eq!(order.quantity, Decimal::from_str("0.01").unwrap());
4219 assert_eq!(order.state.filled_quantity, Decimal::ZERO);
4220 }
4221
4222 #[test]
4223 fn test_convert_open_order_owned_symbol_recovers_instrument() {
4224 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4226 symbol: Some("ETHUSDT".to_string()),
4227 ..make_base_open_order()
4228 };
4229 let order = convert_open_order_owned_symbol(&o).expect("valid order should convert");
4230 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4231 assert_eq!(order.side, Side::Buy);
4232 }
4233
4234 #[test]
4235 fn test_convert_open_order_owned_symbol_missing_symbol_returns_none() {
4236 let o = make_base_open_order();
4238 assert!(o.symbol.is_none());
4239 assert!(convert_open_order_owned_symbol(&o).is_none());
4240 }
4241
4242 #[test]
4243 fn test_convert_open_order_missing_order_id_returns_none() {
4244 let instrument = InstrumentNameExchange::new("BTCUSDT");
4245 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4246 order_id: None,
4247 ..make_base_open_order()
4248 };
4249 assert!(
4250 convert_open_order(&o, &instrument).is_none(),
4251 "missing orderId should return None"
4252 );
4253 }
4254
4255 #[test]
4256 fn test_convert_open_order_missing_side_returns_none() {
4257 let instrument = InstrumentNameExchange::new("BTCUSDT");
4258 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4259 side: None,
4260 ..make_base_open_order()
4261 };
4262 assert!(
4263 convert_open_order(&o, &instrument).is_none(),
4264 "missing side should return None"
4265 );
4266 }
4267
4268 #[test]
4269 fn test_convert_open_order_missing_type_returns_none() {
4270 let instrument = InstrumentNameExchange::new("BTCUSDT");
4271 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4272 r#type: None,
4273 ..make_base_open_order()
4274 };
4275 assert!(
4276 convert_open_order(&o, &instrument).is_none(),
4277 "missing type should return None"
4278 );
4279 }
4280
4281 #[test]
4282 fn test_convert_open_order_executed_qty_none_defaults_to_zero() {
4283 let instrument = InstrumentNameExchange::new("BTCUSDT");
4284 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4285 executed_qty: None,
4286 ..make_base_open_order()
4287 };
4288 let order =
4289 convert_open_order(&o, &instrument).expect("None executedQty should still convert");
4290 assert_eq!(
4291 order.state.filled_quantity,
4292 Decimal::ZERO,
4293 "None executedQty should default to zero"
4294 );
4295 }
4296
4297 #[test]
4298 fn test_convert_open_order_executed_qty_unparseable_defaults_to_zero() {
4299 let instrument = InstrumentNameExchange::new("BTCUSDT");
4300 let o = binance_sdk::spot::rest_api::GetOpenOrdersResponseInner {
4301 executed_qty: Some("bad-value".to_string()),
4302 ..make_base_open_order()
4303 };
4304 let order = convert_open_order(&o, &instrument)
4305 .expect("unparseable executedQty should still convert");
4306 assert_eq!(
4307 order.state.filled_quantity,
4308 Decimal::ZERO,
4309 "unparseable executedQty should default to zero"
4310 );
4311 }
4312
4313 #[test]
4318 fn test_dedup_key_from_event_non_open_states_return_none() {
4319 use crate::order::state::{
4320 ActiveOrderState, CancelInFlight, InactiveOrderState, OpenInFlight,
4321 };
4322 use rustrade_integration::collection::snapshot::Snapshot;
4323
4324 let key = OrderKey::new(
4325 ExchangeId::BinanceSpot,
4326 InstrumentNameExchange::new("BTCUSDT"),
4327 StrategyId::unknown(),
4328 ClientOrderId::new("cid1"),
4329 );
4330
4331 let event = UnindexedAccountEvent::new(
4333 ExchangeId::BinanceSpot,
4334 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4335 key.clone(),
4336 Side::Buy,
4337 None, Decimal::ZERO,
4339 OrderKind::Market,
4340 TimeInForce::ImmediateOrCancel,
4341 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4342 ActiveOrderState::OpenInFlight(OpenInFlight),
4343 ),
4344 ))),
4345 );
4346 assert!(
4347 dedup_key_from_event(&event).is_none(),
4348 "OpenInFlight should return None — dedup not meaningful before exchange ack"
4349 );
4350
4351 let event = UnindexedAccountEvent::new(
4353 ExchangeId::BinanceSpot,
4354 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4355 key.clone(),
4356 Side::Buy,
4357 None, Decimal::ZERO,
4359 OrderKind::Market,
4360 TimeInForce::ImmediateOrCancel,
4361 OrderState::<AssetNameExchange, InstrumentNameExchange>::Active(
4362 ActiveOrderState::CancelInFlight(CancelInFlight { order: None }),
4363 ),
4364 ))),
4365 );
4366 assert!(
4367 dedup_key_from_event(&event).is_none(),
4368 "CancelInFlight should return None"
4369 );
4370
4371 let event = UnindexedAccountEvent::new(
4373 ExchangeId::BinanceSpot,
4374 AccountEventKind::OrderSnapshot(Snapshot(Order::new(
4375 key,
4376 Side::Buy,
4377 None, Decimal::ONE,
4379 OrderKind::Market,
4380 TimeInForce::ImmediateOrCancel,
4381 OrderState::<AssetNameExchange, InstrumentNameExchange>::Inactive(
4382 InactiveOrderState::FullyFilled(crate::order::state::Filled::new(
4383 OrderId::new("123"),
4384 Utc::now(),
4385 Decimal::ONE, None,
4387 )),
4388 ),
4389 ))),
4390 );
4391 assert!(
4392 dedup_key_from_event(&event).is_none(),
4393 "Inactive state should return None"
4394 );
4395 }
4396
4397 #[test]
4398 fn test_dedup_key_from_event_cancelled_error_returns_none() {
4399 let report = binance_sdk::spot::websocket_api::ExecutionReport {
4402 x: Some("REJECTED".to_string()),
4403 r: Some("INSUFFICIENT_FUNDS".to_string()),
4404 ..make_base_report()
4405 };
4406 let event = sole_event(convert_execution_report(report))
4407 .expect("REJECTED report should produce Some");
4408 assert!(
4409 matches!(&event.kind, AccountEventKind::OrderCancelled(r) if r.state.is_err()),
4410 "prerequisite: event is OrderCancelled with Err"
4411 );
4412 assert!(
4413 dedup_key_from_event(&event).is_none(),
4414 "OrderCancelled with Err state should return None"
4415 );
4416 }
4417
4418 fn user_data_frame<T: serde::Serialize>(event_type: &str, event: &T) -> String {
4428 let mut value = serde_json::to_value(event).expect("event serializes to Value");
4429 value
4430 .as_object_mut()
4431 .expect("event serializes to a JSON object")
4432 .insert(
4433 "e".to_string(),
4434 serde_json::Value::String(event_type.to_string()),
4435 );
4436 serde_json::to_string(&value).expect("frame serializes")
4437 }
4438
4439 #[test]
4440 fn test_convert_user_data_events_execution_report_pushes_to_buf() {
4441 let report = binance_sdk::spot::websocket_api::ExecutionReport {
4442 x: Some("NEW".to_string()),
4443 s_uppercase: Some("BUY".to_string()),
4444 o: Some("LIMIT".to_string()),
4445 p: Some("50000.00".to_string()),
4446 q: Some("0.01".to_string()),
4447 f: Some("GTC".to_string()),
4448 z: Some("0".to_string()),
4449 ..make_base_report()
4450 };
4451 let frame = user_data_frame("executionReport", &report);
4452 let mut buf = Vec::new();
4453 let terminated = convert_user_data_events(&frame, &mut buf);
4454 assert!(
4455 !terminated,
4456 "ExecutionReport should not signal stream termination"
4457 );
4458 assert_eq!(buf.len(), 1, "ExecutionReport should push one event");
4459 assert!(matches!(buf[0].kind, AccountEventKind::OrderSnapshot(_)));
4460 }
4461
4462 #[test]
4463 fn test_convert_user_data_events_account_position_pushes_to_buf() {
4464 let position = binance_sdk::spot::websocket_api::OutboundAccountPosition {
4465 u: Some(1_700_000_000_000),
4466 b_uppercase: Some(vec![make_balance_inner("BTC", "1.0", "0.0")]),
4467 ..Default::default()
4468 };
4469 let frame = user_data_frame("outboundAccountPosition", &position);
4470 let mut buf = Vec::new();
4471 let terminated = convert_user_data_events(&frame, &mut buf);
4472 assert!(
4473 !terminated,
4474 "OutboundAccountPosition should not signal stream termination"
4475 );
4476 assert_eq!(
4477 buf.len(),
4478 1,
4479 "OutboundAccountPosition should push one balance event"
4480 );
4481 }
4482
4483 #[test]
4484 fn test_convert_user_data_events_balance_update_ignored() {
4485 let update = binance_sdk::spot::websocket_api::BalanceUpdate {
4486 ..Default::default()
4487 };
4488 let frame = user_data_frame("balanceUpdate", &update);
4489 let mut buf = Vec::new();
4490 let terminated = convert_user_data_events(&frame, &mut buf);
4491 assert!(
4492 !terminated,
4493 "BalanceUpdate should not signal stream termination"
4494 );
4495 assert!(buf.is_empty(), "BalanceUpdate should push no events");
4496 }
4497
4498 #[test]
4499 fn test_convert_user_data_events_stream_terminated_signals_reconnect() {
4500 let frame = r#"{"e":"eventStreamTerminated","E":1700000000000}"#;
4502 let mut buf = Vec::new();
4503 let terminated = convert_user_data_events(frame, &mut buf);
4504 assert!(
4505 terminated,
4506 "eventStreamTerminated must signal stream termination"
4507 );
4508 assert!(
4509 buf.is_empty(),
4510 "eventStreamTerminated should push no events"
4511 );
4512 }
4513
4514 #[test]
4515 fn test_convert_user_data_events_unknown_event_ignored() {
4516 let frame = r#"{"e":"listStatus","E":1700000000000,"s":"BTCUSDT"}"#;
4519 let mut buf = Vec::new();
4520 let terminated = convert_user_data_events(frame, &mut buf);
4521 assert!(!terminated, "unknown event must not signal termination");
4522 assert!(buf.is_empty(), "unknown event should push no events");
4523 }
4524
4525 #[test]
4526 fn test_convert_user_data_events_non_user_data_frame_ignored() {
4527 let frame = r#"{"id":"abc","status":200,"result":[]}"#;
4529 let mut buf = Vec::new();
4530 let terminated = convert_user_data_events(frame, &mut buf);
4531 assert!(
4532 !terminated,
4533 "non-user-data frame must not signal termination"
4534 );
4535 assert!(buf.is_empty(), "non-user-data frame should push no events");
4536 }
4537
4538 #[tokio::test]
4543 async fn test_rate_limit_tracker_not_blocked_initially() {
4544 tokio::time::pause();
4545 let tracker = RateLimitTracker::new();
4546 tokio::time::timeout(
4548 std::time::Duration::from_millis(1),
4549 tracker.wait_if_blocked(),
4550 )
4551 .await
4552 .expect("wait_if_blocked should return immediately when not blocked");
4553 }
4554
4555 #[tokio::test]
4556 async fn test_rate_limit_tracker_blocks_until_deadline() {
4557 tokio::time::pause();
4558 let tracker = RateLimitTracker::new();
4559 let delay = Duration::from_secs(5);
4560 tracker.on_rate_limited(Some(delay));
4561
4562 assert!(
4564 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4565 .await
4566 .is_err(),
4567 "wait_if_blocked should block while cooldown is active"
4568 );
4569
4570 tokio::time::advance(delay + Duration::from_millis(1)).await;
4572 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4573 .await
4574 .expect("wait_if_blocked should return after cooldown expires");
4575 }
4576
4577 #[tokio::test]
4578 async fn test_rate_limit_tracker_cooldown_extends_to_max() {
4579 tokio::time::pause();
4580 let tracker = RateLimitTracker::new();
4581
4582 tracker.on_rate_limited(Some(Duration::from_secs(5)));
4584 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4586
4587 tokio::time::advance(Duration::from_secs(6)).await;
4589 assert!(
4590 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4591 .await
4592 .is_err(),
4593 "cooldown should have been extended to 10s"
4594 );
4595
4596 tokio::time::advance(Duration::from_secs(5)).await;
4598 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4599 .await
4600 .expect("wait_if_blocked should return after extended cooldown expires");
4601 }
4602
4603 #[tokio::test]
4604 async fn test_rate_limit_tracker_shorter_cooldown_does_not_shorten() {
4605 tokio::time::pause();
4606 let tracker = RateLimitTracker::new();
4607
4608 tracker.on_rate_limited(Some(Duration::from_secs(10)));
4610 tracker.on_rate_limited(Some(Duration::from_secs(2)));
4611
4612 tokio::time::advance(Duration::from_secs(3)).await;
4614 assert!(
4615 tokio::time::timeout(Duration::from_millis(1), tracker.wait_if_blocked())
4616 .await
4617 .is_err(),
4618 "shorter on_rate_limited must not shorten existing cooldown"
4619 );
4620 }
4621
4622 #[test]
4627 fn test_parse_binance_api_error_balance_insufficient_holds_instrument_name() {
4628 let instrument = InstrumentNameExchange::new("BTCUSDT");
4629 match parse_binance_api_error("Insufficient balance".into(), &instrument) {
4630 ApiError::BalanceInsufficient(asset_field, _) => {
4631 assert_eq!(
4636 asset_field.name().as_str(),
4637 "BTCUSDT",
4638 "BalanceInsufficient.0 holds the instrument name, not an asset name"
4639 );
4640 }
4641 other => panic!("expected BalanceInsufficient, got {other:?}"),
4642 }
4643 }
4644
4645 #[test]
4650 fn test_is_api_rejection_error_with_wrapped_error_chain() {
4651 let raw = anyhow::anyhow!(WebsocketError::ResponseError {
4656 code: -2010,
4657 message: "insufficient balance".into(),
4658 });
4659 assert!(
4661 is_api_rejection_error(&raw),
4662 "unwrapped ResponseError at root must be detected"
4663 );
4664 let wrapped = raw.context("outer context (e.g. SDK adds context layer)");
4666 assert!(
4667 is_api_rejection_error(&wrapped),
4668 "context-wrapped ResponseError must still be detected — anyhow::downcast_ref searches the full chain"
4669 );
4670 }
4671}