1use super::shared::{
51 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
52 CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
53 MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing, RateLimitTracker, RequestKind,
54 SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, UnrecoveredFills, UserDataFrame, WeightPool,
55 classify_order_kind_tif, classify_rest_order_error, classify_rest_query_error,
56 convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
57 dedup_key_from_event, drop_after, gap_failed, gap_time, is_duplicate, log_unrecognised_frame,
58 new_dedup_cache, parse_user_data_frame, recovered_order_totals, response_decode_error,
59 rest_call_with_retry,
60};
61use crate::{
62 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, InstrumentBalanceUpdate,
63 IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent, UnindexedAccountSnapshot,
64 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
65 client::ExecutionClient,
66 emit_stream_terminated,
67 error::{
68 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
69 },
70 order::{
71 Order, OrderKey, OrderKind, TimeInForce,
72 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
73 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
74 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
75 },
76 position::PositionReport,
77 trade::{AssetFees, Trade, TradeId},
78};
79use binance_sdk::{
80 common::{
81 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
82 constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
83 models::WebsocketEvent,
84 websocket::{
85 SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
86 WebsocketMessageSendOptions,
87 },
88 },
89 margin_trading::{
90 MarginTradingRestApi,
91 rest_api::{
92 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
93 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
94 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
95 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
96 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
97 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
98 QueryIsolatedMarginAccountInfoParams,
99 QueryIsolatedMarginAccountInfoResponseAssetsInner,
100 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
101 QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
102 QueryMarginAccountsTradeListResponseInner, RestApi,
103 },
104 websocket_streams::{
105 ExecutionReport, MarginLevelStatusChange, OutboundAccountPosition, UserLiabilityChange,
106 },
107 },
108};
109use chrono::{DateTime, TimeZone, Utc};
110use futures::stream::BoxStream;
111use rust_decimal::Decimal;
112use rustrade_instrument::{
113 Side,
114 asset::name::AssetNameExchange,
115 exchange::ExchangeId,
116 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
117};
118use serde::{Deserialize, Serialize};
119use smol_str::{SmolStr, format_smolstr};
120use std::{
121 collections::{BTreeMap, HashMap},
122 str::FromStr,
123 sync::{
124 Arc, Mutex,
125 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
126 },
127 time::Duration,
128};
129use tokio::sync::{mpsc, oneshot};
130use tracing::{debug, error, info, trace, warn};
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
150#[serde(rename_all = "snake_case")]
151pub enum MarginSideEffect {
152 #[default]
160 AutoBorrowRepay,
161 NoBorrow,
165}
166
167impl MarginSideEffect {
168 pub fn as_binance_str(self) -> &'static str {
170 match self {
171 MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
172 MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
173 }
174 }
175
176 fn as_sdk_enum(self) -> MarginAccountNewOrderSideEffectTypeEnum {
182 match self {
183 MarginSideEffect::AutoBorrowRepay => {
184 MarginAccountNewOrderSideEffectTypeEnum::AutoBorrowRepay
185 }
186 MarginSideEffect::NoBorrow => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
187 }
188 }
189}
190
191#[derive(Clone, Deserialize)]
201pub struct BinanceMarginConfig {
202 api_key: String,
205 secret_key: String,
206 pub testnet: bool,
212 #[serde(default)]
219 pub is_isolated: bool,
220 #[serde(default)]
231 pub isolated_symbols: Vec<InstrumentNameExchange>,
232 #[serde(default)]
236 pub side_effect: MarginSideEffect,
237}
238
239impl std::fmt::Debug for BinanceMarginConfig {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 f.debug_struct("BinanceMarginConfig")
243 .field("api_key", &"***")
244 .field("secret_key", &"***")
245 .field("testnet", &self.testnet)
246 .field("is_isolated", &self.is_isolated)
247 .field("isolated_symbols", &self.isolated_symbols)
248 .field("side_effect", &self.side_effect)
249 .finish()
250 }
251}
252
253impl BinanceMarginConfig {
254 pub fn new(
264 api_key: String,
265 secret_key: String,
266 testnet: bool,
267 is_isolated: bool,
268 isolated_symbols: Vec<InstrumentNameExchange>,
269 side_effect: MarginSideEffect,
270 ) -> Self {
271 Self {
272 api_key,
273 secret_key,
274 testnet,
275 is_isolated,
276 isolated_symbols,
277 side_effect,
278 }
279 }
280
281 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
288 Self::new(
289 api_key,
290 secret_key,
291 false,
292 false,
293 Vec::new(),
294 MarginSideEffect::default(),
295 )
296 }
297
298 pub fn isolated(
306 api_key: String,
307 secret_key: String,
308 symbols: Vec<InstrumentNameExchange>,
309 ) -> Self {
310 Self::new(
311 api_key,
312 secret_key,
313 false,
314 true,
315 symbols,
316 MarginSideEffect::default(),
317 )
318 }
319
320 pub fn api_key(&self) -> &str {
322 &self.api_key
323 }
324}
325
326#[derive(Clone)]
394pub struct BinanceMargin {
395 config: Arc<BinanceMarginConfig>,
396 rest: Arc<RestApi>,
398 rest_config: Arc<ConfigurationRestApi>,
402 ws_config: ConfigurationWebsocketApi,
406 rate_limiter: Arc<RateLimitTracker>,
408}
409
410impl std::fmt::Debug for BinanceMargin {
411 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
412 f.debug_struct("BinanceMargin")
413 .field("testnet", &self.config.testnet)
414 .field("is_isolated", &self.config.is_isolated)
415 .field("side_effect", &self.config.side_effect)
416 .finish_non_exhaustive()
417 }
418}
419
420impl BinanceMargin {
421 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
431 ConfigurationRestApi::builder()
432 .api_key(config.api_key.clone())
433 .api_secret(config.secret_key.clone())
434 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
435 .build()
436 .expect("failed to build Binance margin REST configuration")
437 }
438
439 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
450 ConfigurationWebsocketApi::builder()
451 .api_key(config.api_key.clone())
452 .api_secret(config.secret_key.clone())
453 .ws_url(SPOT_WS_API_PROD_URL)
457 .build()
458 .expect("failed to build Binance margin WebSocket configuration")
459 }
460
461 fn effective_isolated_set(
474 &self,
475 instruments: &[InstrumentNameExchange],
476 ) -> Vec<InstrumentNameExchange> {
477 if instruments.is_empty() {
478 return self.config.isolated_symbols.clone();
479 }
480 instruments
481 .iter()
482 .filter(|inst| {
483 let in_set = self.config.isolated_symbols.contains(*inst);
484 if !in_set {
485 warn!(
486 instrument = %inst.name(),
487 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
488 );
489 }
490 in_set
491 })
492 .cloned()
493 .collect()
494 }
495
496 async fn isolated_account_snapshot(
500 &self,
501 instruments: &[InstrumentNameExchange],
502 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
503 use futures::{StreamExt as _, TryStreamExt as _};
504
505 let effective = self.effective_isolated_set(instruments);
506 if effective.is_empty() {
507 return Ok(AccountSnapshot::new(
510 ExchangeId::BinanceMargin,
511 Vec::new(),
512 Vec::new(),
513 ));
514 }
515
516 let mut balances_by_symbol = convert_isolated_margin_assets(
518 fetch_isolated_margin_account_info(
519 self.rest.clone(),
520 self.rate_limiter.clone(),
521 effective.clone(),
522 )
523 .await?,
524 );
525
526 let instrument_snapshots: Vec<_> =
529 futures::stream::iter(effective.into_iter().map(|instrument| {
530 fetch_margin_open_orders_for_instrument(
531 self.rest.clone(),
532 self.rate_limiter.clone(),
533 instrument,
534 true,
535 )
536 }))
537 .buffer_unordered(8)
538 .map(|result| {
539 let (inst, listing) = result?;
540 let wrapped = listing
541 .orders
542 .into_iter()
543 .map(active_order_snapshot)
544 .collect();
545 let isolated = balances_by_symbol.remove(&inst);
546 if isolated.is_none() {
547 warn!(
551 instrument = %inst.name(),
552 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
553 );
554 }
555 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
556 inst,
557 wrapped,
558 listing.complete,
559 PositionReport::Unreported,
560 isolated,
561 ))
562 })
563 .try_collect()
564 .await?;
565
566 Ok(AccountSnapshot::new(
569 ExchangeId::BinanceMargin,
570 Vec::new(),
571 instrument_snapshots,
572 ))
573 }
574}
575
576impl ExecutionClient for BinanceMargin {
577 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
578
579 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
582 &[InstrumentKindDiscriminant::Spot];
583 type Config = BinanceMarginConfig;
584 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
585
586 fn new(config: Self::Config) -> Self {
599 if config.testnet {
600 warn!(
601 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
602 using production endpoints"
603 );
604 }
605 assert!(
606 !(config.is_isolated && config.isolated_symbols.is_empty()),
607 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
608 );
609 let rest_config = Self::build_rest_config(&config);
614 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
615 let rest_config = Arc::new(rest_config);
616 let ws_config = Self::build_ws_config(&config);
617 Self {
618 config: Arc::new(config),
619 rest,
620 rest_config,
621 ws_config,
622 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
623 }
624 }
625
626 async fn open_order(
640 &self,
641 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
642 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
643 let instrument = request.key.instrument.clone();
644 let side = request.state.side;
645 let price = request.state.price;
646 let quantity = request.state.quantity;
647 let kind = request.state.kind;
648 let time_in_force = request.state.time_in_force;
649 let cid = request.key.cid.clone();
650
651 let order_key = OrderKey::new(
652 ExchangeId::BinanceMargin,
653 instrument.clone(),
654 request.key.strategy.clone(),
655 cid.clone(),
656 );
657
658 let inactive = |state: UnindexedOrderState| {
661 Some(Order {
662 key: order_key.clone(),
663 side,
664 price,
665 quantity,
666 kind,
667 time_in_force,
668 state,
669 })
670 };
671
672 let params = match build_new_order_params(
673 instrument.name().to_string(),
674 side,
675 price,
676 quantity,
677 kind,
678 time_in_force,
679 cid.0.to_string(),
680 self.config.side_effect,
681 self.config.is_isolated,
682 ) {
683 Ok(params) => params,
684 Err(BuildOrderError::Unsupported) => {
685 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
686 format!(
687 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
688 ),
689 )));
690 }
691 Err(BuildOrderError::Build(msg)) => {
692 error!(%msg, "BinanceMargin failed to build new order params");
693 return inactive(OrderState::inactive(OrderError::Rejected(
694 ApiError::OrderRejected(msg),
695 )));
696 }
697 };
698
699 let response =
700 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
701 let params = params.clone();
702 Box::pin(async move { rest.margin_account_new_order(params).await })
703 })
704 .await
705 {
706 Ok(response) => response,
707 Err(e) => {
708 return inactive(OrderState::inactive(classify_rest_order_error(
709 &e,
710 &instrument,
711 )));
712 }
713 };
714
715 let data = match response.data().await {
716 Ok(data) => data,
717 Err(e) => {
718 return inactive(OrderState::inactive(OrderError::Rejected(
721 ApiError::OrderRejected(e.to_string()),
722 )));
723 }
724 };
725
726 let time_exchange = data
727 .transact_time
728 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
729 .unwrap_or_else(Utc::now);
730
731 let exchange_order_id = match data.order_id {
732 Some(id) => OrderId(format_smolstr!("{id}")),
733 None => {
734 error!("BinanceMargin open_order response missing orderId");
735 return inactive(OrderState::inactive(OrderError::Rejected(
736 ApiError::OrderRejected("open_order response missing orderId".into()),
737 )));
738 }
739 };
740
741 let filled_qty = match data.executed_qty.as_deref() {
742 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
743 warn!(
747 executed_qty = q,
748 "BinanceMargin: failed to parse executedQty; treating as zero"
749 );
750 Decimal::ZERO
751 }),
752 None => {
753 warn!("BinanceMargin: executedQty missing in response; treating as zero");
756 Decimal::ZERO
757 }
758 };
759
760 let state = if filled_qty >= quantity {
761 let avg_price = margin_avg_price(data.cummulative_quote_qty.as_deref(), filled_qty);
764 OrderState::fully_filled(Filled::new(
765 exchange_order_id,
766 time_exchange,
767 filled_qty,
768 avg_price,
769 ))
770 } else {
771 OrderState::active(Open::new(
772 VenueOrderId::Assigned(exchange_order_id),
773 time_exchange,
774 filled_qty,
775 ))
776 };
777
778 Some(Order {
779 key: order_key,
780 side,
781 price,
782 quantity,
783 kind,
784 time_in_force,
785 state,
786 })
787 }
788
789 async fn cancel_order(
799 &self,
800 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
801 ) -> Option<UnindexedOrderResponseCancel> {
802 let instrument = request.key.instrument.clone();
803 let key = OrderKey {
804 exchange: request.key.exchange,
805 instrument: instrument.clone(),
806 strategy: request.key.strategy.clone(),
807 cid: request.key.cid.clone(),
808 };
809
810 let params = match build_cancel_order_params(
811 instrument.name().to_string(),
812 request.state.id.as_ref().and_then(VenueOrderId::assigned),
813 &request.key.cid,
814 self.config.is_isolated,
815 ) {
816 Ok(p) => p,
817 Err(e) => {
818 error!(%e, "BinanceMargin failed to build cancel order params");
819 return Some(UnindexedOrderResponseCancel {
820 key,
821 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
822 });
823 }
824 };
825
826 let response =
827 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
828 let params = params.clone();
829 Box::pin(async move { rest.margin_account_cancel_order(params).await })
830 })
831 .await
832 {
833 Ok(response) => response,
834 Err(e) => {
835 return Some(UnindexedOrderResponseCancel {
836 key,
837 state: Err(classify_rest_order_error(&e, &instrument)),
838 });
839 }
840 };
841
842 let data = match response.data().await {
843 Ok(data) => data,
844 Err(e) => {
845 return Some(UnindexedOrderResponseCancel {
848 key,
849 state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
850 });
851 }
852 };
853
854 let time_exchange = Utc::now();
856
857 let exchange_order_id = match data.order_id {
858 Some(id) => OrderId(SmolStr::new(id)),
861 None => {
862 error!("BinanceMargin cancel response missing orderId");
863 return Some(UnindexedOrderResponseCancel {
864 key,
865 state: Err(OrderError::Rejected(ApiError::OrderRejected(
866 "cancel response missing orderId".into(),
867 ))),
868 });
869 }
870 };
871
872 let filled_qty = match data.executed_qty.as_deref() {
873 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
874 warn!(
878 executed_qty = q,
879 "BinanceMargin: failed to parse executedQty; treating as zero"
880 );
881 Decimal::ZERO
882 }),
883 None => {
884 warn!("BinanceMargin: executedQty missing in response; treating as zero");
887 Decimal::ZERO
888 }
889 };
890
891 Some(UnindexedOrderResponseCancel {
892 key,
893 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
894 })
895 }
896
897 async fn account_snapshot(
912 &self,
913 assets: &[AssetNameExchange],
914 instruments: &[InstrumentNameExchange],
915 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
916 if self.config.is_isolated {
917 return self.isolated_account_snapshot(instruments).await;
918 }
919 let response =
920 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
921 Box::pin(async move {
922 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
923 rest.query_cross_margin_account_details(params).await
924 })
925 })
926 .await
927 .map_err(|e| classify_rest_query_error(&e, None))?;
928
929 let account = response.data().await.map_err(response_decode_error)?;
930
931 let balances =
932 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
933
934 use futures::{StreamExt as _, TryStreamExt as _};
939 let instrument_snapshots: Vec<_> =
940 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
941 fetch_margin_open_orders_for_instrument(
942 self.rest.clone(),
943 self.rate_limiter.clone(),
944 instrument,
945 false,
947 )
948 }))
949 .buffer_unordered(8)
950 .map(|result| {
951 let (inst, listing) = result?;
952 let wrapped = listing
953 .orders
954 .into_iter()
955 .map(active_order_snapshot)
956 .collect();
957 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
958 inst,
959 wrapped,
960 listing.complete,
961 PositionReport::Unreported,
962 None,
963 ))
964 })
965 .try_collect()
966 .await?;
967
968 Ok(AccountSnapshot::new(
969 ExchangeId::BinanceMargin,
970 balances,
971 instrument_snapshots,
972 ))
973 }
974
975 async fn fetch_balances(
985 &self,
986 assets: &[AssetNameExchange],
987 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
988 if self.config.is_isolated {
989 return Ok(Vec::new());
990 }
991 let response =
992 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
993 Box::pin(async move {
994 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
995 rest.query_cross_margin_account_details(params).await
996 })
997 })
998 .await
999 .map_err(|e| classify_rest_query_error(&e, None))?;
1000
1001 let account = response.data().await.map_err(response_decode_error)?;
1002
1003 Ok(filter_and_convert_margin_balances(
1004 account.user_assets.unwrap_or_default(),
1005 assets,
1006 ))
1007 }
1008
1009 async fn fetch_open_orders(
1020 &self,
1021 instruments: &[InstrumentNameExchange],
1022 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1023 use futures::{StreamExt as _, TryStreamExt as _};
1024
1025 if self.config.is_isolated {
1029 let effective = self.effective_isolated_set(instruments);
1030 let capacity = effective.len();
1031 return futures::stream::iter(effective.into_iter().map(|instrument| {
1032 fetch_margin_open_orders_for_instrument(
1033 self.rest.clone(),
1034 self.rate_limiter.clone(),
1035 instrument,
1036 true,
1037 )
1038 }))
1039 .buffer_unordered(8)
1040 .try_fold(
1041 Vec::with_capacity(capacity),
1042 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1043 (_, listing)| async move {
1044 acc.extend(listing.orders);
1045 Ok(acc)
1046 },
1047 )
1048 .await;
1049 }
1050
1051 if instruments.is_empty() {
1054 return fetch_margin_all_open_orders(
1055 self.rest.clone(),
1056 self.rate_limiter.clone(),
1057 false,
1058 )
1059 .await;
1060 }
1061 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1062 fetch_margin_open_orders_for_instrument(
1063 self.rest.clone(),
1064 self.rate_limiter.clone(),
1065 instrument,
1066 false,
1067 )
1068 }))
1069 .buffer_unordered(8)
1070 .try_fold(
1071 Vec::with_capacity(instruments.len()),
1072 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1073 acc.extend(listing.orders);
1074 Ok(acc)
1075 },
1076 )
1077 .await
1078 }
1079
1080 async fn fetch_trades(
1105 &self,
1106 time_since: DateTime<Utc>,
1107 instruments: &[InstrumentNameExchange],
1108 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1109 use futures::StreamExt as _;
1110
1111 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1114 self.effective_isolated_set(instruments)
1115 } else {
1116 instruments.to_vec()
1117 };
1118 if effective.is_empty() {
1119 debug!(
1122 is_isolated = self.config.is_isolated,
1123 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1124 );
1125 return Ok(Vec::new());
1126 }
1127 let start_time_ms = time_since.timestamp_millis();
1128 let is_isolated = self.config.is_isolated;
1129 let mut all_trades = Vec::new();
1130
1131 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1134 let rest = self.rest.clone();
1135 let rate_limiter = self.rate_limiter.clone();
1136 async move {
1137 let pages = paginate_margin_my_trades(
1138 &rest,
1139 &rate_limiter,
1140 &inst,
1141 MyTradesFrom::Time(start_time_ms),
1142 is_isolated,
1143 RequestKind::Query,
1144 )
1145 .await?;
1146 Ok::<_, UnindexedClientError>((inst, pages))
1147 }
1148 }))
1149 .buffer_unordered(8);
1150 while let Some(result) = stream.next().await {
1151 let (instrument, trades_data) = result?;
1152 for t in trades_data {
1153 if let Some(trade) = convert_margin_trade(&t, &instrument) {
1154 all_trades.push(trade);
1155 }
1156 }
1157 }
1158
1159 Ok(all_trades)
1160 }
1161
1162 async fn account_stream(
1237 &self,
1238 _assets: &[AssetNameExchange],
1241 instruments: &[InstrumentNameExchange],
1242 ) -> Result<Self::AccountStream, UnindexedClientError> {
1243 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1244 let dedup = new_dedup_cache();
1245 let rest = self.rest.clone();
1246 let rest_config = self.rest_config.clone();
1247 let ws_config = self.ws_config.clone();
1248 let rate_limiter = self.rate_limiter.clone();
1249
1250 let cm_handle = if self.config.is_isolated {
1251 let symbols = self.config.isolated_symbols.clone();
1255 debug_assert!(
1258 symbols.iter().all(|i| i.name().len() <= 23),
1259 "instrument name exceeds SmolStr inline capacity: {:?}",
1260 symbols.iter().find(|i| i.name().len() > 23)
1261 );
1262
1263 let assets = fetch_isolated_margin_account_info(
1269 rest.clone(),
1270 rate_limiter.clone(),
1271 symbols.clone(),
1272 )
1273 .await?;
1274 let base_quote = Arc::new(build_base_quote_map(&assets));
1275 for sym in &symbols {
1276 if !base_quote.contains_key(sym) {
1277 warn!(
1278 symbol = %sym.name(),
1279 "BinanceMargin isolated: account info returned no base/quote for configured \
1280 symbol — its live per-pair balance frames will be dropped (fills/orders \
1281 unaffected; balances available via account_snapshot)"
1282 );
1283 }
1284 }
1285
1286 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1290 let live =
1291 isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
1292 .await
1293 .map_err(|e| {
1294 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1295 })?;
1296
1297 tokio::spawn(isolated_connection_manager(
1298 tx,
1299 dedup,
1300 ws_config,
1301 rest_config,
1302 rest,
1303 rate_limiter,
1304 symbols,
1305 base_quote,
1306 Some(live),
1307 ))
1308 } else {
1309 let instruments = instruments.to_vec();
1311 debug_assert!(
1315 instruments.iter().all(|i| i.name().len() <= 23),
1316 "instrument name exceeds SmolStr inline capacity: {:?}",
1317 instruments.iter().find(|i| i.name().len() > 23)
1318 );
1319
1320 let initial_token =
1326 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1327 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1328 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1329 })?;
1330
1331 tokio::spawn(margin_connection_manager(
1332 tx,
1333 dedup,
1334 ws_config,
1335 rest_config,
1336 rest,
1337 rate_limiter,
1338 instruments,
1339 Some((initial_ws, initial_token)),
1340 ))
1341 };
1342
1343 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1344 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1345 Ok(futures::StreamExt::boxed(guarded_stream))
1346 }
1347}
1348
1349const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1357
1358struct UserListenToken {
1360 token: String,
1361 expiration_time_ms: i64,
1362}
1363
1364fn build_listen_token_query(
1368 symbol: Option<&InstrumentNameExchange>,
1369) -> BTreeMap<String, serde_json::Value> {
1370 let mut query = BTreeMap::new();
1371 if let Some(sym) = symbol {
1372 query.insert(
1373 "isIsolated".to_string(),
1374 serde_json::Value::String("TRUE".to_string()),
1375 );
1376 query.insert(
1377 "symbol".to_string(),
1378 serde_json::Value::String(sym.name().to_string()),
1379 );
1380 }
1381 query
1382}
1383
1384#[derive(Deserialize)]
1386struct UserListenTokenResponse {
1387 #[serde(alias = "listenToken")]
1389 token: String,
1390 #[serde(rename = "expirationTime")]
1391 expiration_time: i64,
1392}
1393
1394async fn acquire_user_listen_token(
1409 rest_config: &Arc<ConfigurationRestApi>,
1410 rate_limiter: &RateLimitTracker,
1411 symbol: Option<&InstrumentNameExchange>,
1412) -> Result<UserListenToken, UnindexedClientError> {
1413 let query = build_listen_token_query(symbol);
1416 let response = rest_call_with_retry(rest_config, rate_limiter, RequestKind::Essential, |cfg| {
1417 let query = query.clone();
1418 Box::pin(async move {
1419 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1420 &cfg,
1421 "/sapi/v1/userListenToken",
1422 reqwest::Method::POST,
1423 query,
1426 BTreeMap::new(),
1427 None,
1428 true, )
1430 .await
1431 })
1432 })
1433 .await
1434 .map_err(|e| classify_rest_query_error(&e, symbol))?;
1435
1436 let data = response.data().await.map_err(response_decode_error)?;
1437 Ok(UserListenToken {
1438 token: data.token,
1439 expiration_time_ms: data.expiration_time,
1440 })
1441}
1442
1443fn token_renew_after(expiration_time_ms: i64) -> Duration {
1446 let now_ms = Utc::now().timestamp_millis();
1447 let remaining_secs = expiration_time_ms
1451 .saturating_sub(now_ms)
1452 .saturating_div(1_000)
1453 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1454 let secs = u64::try_from(remaining_secs).unwrap_or(0);
1455 if secs < TOKEN_MIN_LIFETIME_SECS {
1458 warn!(
1459 computed_secs = secs,
1460 floor_secs = TOKEN_MIN_LIFETIME_SECS,
1461 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1462 );
1463 }
1464 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1465}
1466
1467async fn connect_margin_ws(
1474 ws_config: &ConfigurationWebsocketApi,
1475) -> anyhow::Result<Arc<WsApiBase>> {
1476 let ws = WsApiBase::new(ws_config.clone(), vec![]);
1477 tokio::time::timeout(
1480 Duration::from_secs(CONNECT_TIMEOUT_SECS),
1481 ws.clone().connect(),
1482 )
1483 .await
1484 .map_err(|_| {
1485 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1486 })??;
1487 Ok(ws)
1488}
1489
1490async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1497 let mut payload = BTreeMap::new();
1500 payload.insert(
1501 "listenToken".to_string(),
1502 serde_json::Value::String(token.to_string()),
1503 );
1504 ws.send_message::<serde_json::Value>(
1505 "userDataStream.subscribe.listenToken",
1506 payload,
1507 WebsocketMessageSendOptions::new(),
1508 )
1509 .await
1510 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1511 Ok(())
1512}
1513
1514async fn subscribe_listen_token_capture(
1527 ws: &Arc<WsApiBase>,
1528 token: &str,
1529) -> anyhow::Result<Option<i64>> {
1530 #[derive(Deserialize)]
1533 struct SubscribeAck {
1534 #[serde(rename = "subscriptionId", default)]
1535 subscription_id: Option<i64>,
1536 }
1537
1538 let mut payload = BTreeMap::new();
1539 payload.insert(
1540 "listenToken".to_string(),
1541 serde_json::Value::String(token.to_string()),
1542 );
1543 let result = ws
1544 .send_message::<SubscribeAck>(
1545 "userDataStream.subscribe.listenToken",
1546 payload,
1547 WebsocketMessageSendOptions::new(),
1548 )
1549 .await
1550 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1551 let ack = match result {
1552 SendWebsocketMessageResult::Single(resp) => resp.data()?,
1553 SendWebsocketMessageResult::Multiple(responses) => responses
1555 .into_iter()
1556 .next()
1557 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
1558 .data()?,
1559 };
1560 Ok(ack.subscription_id)
1561}
1562
1563fn cross_account_position_handler(
1567 position: OutboundAccountPosition,
1568 _subscription_id: Option<i64>,
1569 buf: &mut Vec<UnindexedAccountEvent>,
1570) {
1571 convert_margin_account_position(position, buf);
1572}
1573
1574#[cfg(test)]
1579fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1580 convert_margin_user_data_events_with(frame, buf, &mut cross_account_position_handler)
1581}
1582
1583fn convert_margin_user_data_events_with(
1598 frame: &str,
1599 buf: &mut Vec<UnindexedAccountEvent>,
1600 handle_position: &mut impl FnMut(
1601 OutboundAccountPosition,
1602 Option<i64>,
1603 &mut Vec<UnindexedAccountEvent>,
1604 ),
1605) -> bool {
1606 let (subscription_id, event_type, event_raw) = match parse_user_data_frame(frame) {
1607 UserDataFrame::Response => return false,
1609 UserDataFrame::Event {
1610 subscription_id,
1611 event_type,
1612 event,
1613 } => (subscription_id, event_type, event),
1614 UserDataFrame::Unrecognised => {
1615 static SEEN: AtomicU64 = AtomicU64::new(0);
1616 log_unrecognised_frame("BinanceMargin", &SEEN, frame);
1617 return false;
1618 }
1619 };
1620 match event_type {
1621 "executionReport" => {
1622 match serde_json::from_str::<ExecutionReport>(event_raw) {
1625 Ok(report) => convert_execution_report(&report, ExchangeId::BinanceMargin, buf),
1626 Err(e) => {
1627 warn!(error = %e, "BinanceMargin: undeserializable executionReport, dropping")
1628 }
1629 }
1630 false
1631 }
1632 "outboundAccountPosition" => {
1633 match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
1634 Ok(position) => handle_position(position, subscription_id, buf),
1636 Err(e) => {
1637 warn!(error = %e, "BinanceMargin: undeserializable outboundAccountPosition, dropping")
1638 }
1639 }
1640 false
1641 }
1642 "balanceUpdate" => {
1643 false
1647 }
1648 "userLiabilityChange" => {
1649 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
1656 Ok(c) => {
1657 if tracing::enabled!(tracing::Level::INFO) {
1658 info!(
1659 asset = c.a.as_deref().unwrap_or("?"),
1660 kind = c.t.as_deref().unwrap_or("?"),
1661 principal = c.p.as_deref().unwrap_or("?"),
1662 interest = c.i.as_deref().unwrap_or("?"),
1663 "BinanceMargin userLiabilityChange (observable; not applied to balance state)"
1664 );
1665 }
1666 }
1667 Err(e) => warn!(
1668 error = %e,
1669 "BinanceMargin: undeserializable userLiabilityChange, dropping"
1670 ),
1671 }
1672 false
1673 }
1674 "marginLevelStatusChange" => {
1675 if tracing::enabled!(tracing::Level::WARN) {
1680 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
1681 Ok(c) => warn!(
1682 margin_level = c.l.as_deref().unwrap_or("?"),
1683 status = c.s.as_deref().unwrap_or("?"),
1684 "BinanceMargin marginLevelStatusChange (liquidation risk; observable, no policy)"
1685 ),
1686 Err(e) => warn!(
1687 error = %e,
1688 "BinanceMargin: undeserializable marginLevelStatusChange, dropping"
1689 ),
1690 }
1691 }
1692 false
1693 }
1694 "eventStreamTerminated" => {
1695 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
1696 true
1697 }
1698 other => {
1699 trace!(
1700 event_type = other,
1701 "BinanceMargin ignoring unhandled user data event"
1702 );
1703 false
1704 }
1705 }
1706}
1707
1708fn convert_margin_account_position(
1713 position: OutboundAccountPosition,
1714 buf: &mut Vec<UnindexedAccountEvent>,
1715) {
1716 let time_exchange = position
1717 .u
1718 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1719 .unwrap_or_else(Utc::now);
1720
1721 for b in position.b_uppercase.unwrap_or_default() {
1722 let asset = match b.a {
1723 Some(a) => AssetNameExchange::new(a),
1724 None => {
1725 warn!("BinanceMargin account position entry missing asset name");
1726 continue;
1727 }
1728 };
1729 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1730 Some(v) => v,
1731 None => {
1732 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
1733 continue;
1734 }
1735 };
1736 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1737 Some(v) => v,
1738 None => {
1739 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
1740 continue;
1741 }
1742 };
1743 let update =
1744 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
1745 buf.push(UnindexedAccountEvent::new(
1746 ExchangeId::BinanceMargin,
1747 AccountEventKind::BalanceStreamUpdate(
1748 rustrade_integration::collection::snapshot::Snapshot::new(update),
1749 ),
1750 ));
1751 }
1752}
1753
1754fn route_isolated_account_position(
1765 position: OutboundAccountPosition,
1766 subscription_id: Option<i64>,
1767 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
1768 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
1769 buf: &mut Vec<UnindexedAccountEvent>,
1770) {
1771 let Some(sub_id) = subscription_id else {
1772 warn!(
1773 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
1774 (per-instrument balance routing requires it)"
1775 );
1776 return;
1777 };
1778 let instrument = {
1779 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
1782 match map.get(&sub_id) {
1783 Some(inst) => inst.clone(),
1784 None => {
1785 warn!(
1786 subscription_id = sub_id,
1787 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
1788 );
1789 return;
1790 }
1791 }
1792 };
1793 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
1794 warn!(
1795 instrument = %instrument.name(),
1796 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
1797 );
1798 return;
1799 };
1800
1801 let time_exchange = position
1802 .u
1803 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1804 .unwrap_or_else(Utc::now);
1805
1806 let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1807 let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1808 for b in position.b_uppercase.unwrap_or_default() {
1809 let Some(asset) = b.a.as_deref() else {
1810 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
1811 continue;
1812 };
1813 let side = if asset == base_asset.name().as_str() {
1815 &mut base_update
1816 } else if asset == quote_asset.name().as_str() {
1817 &mut quote_update
1818 } else {
1819 continue;
1820 };
1821 let (Some(free), Some(locked)) = (
1822 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1823 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1824 ) else {
1825 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
1826 continue;
1827 };
1828 *side = Some(AssetBalanceUpdate::new(
1829 AssetNameExchange::new(asset),
1830 BalanceUpdate::new(free, locked),
1831 time_exchange,
1832 ));
1833 }
1834
1835 let (Some(base), Some(quote)) = (base_update, quote_update) else {
1839 warn!(
1840 instrument = %instrument.name(),
1841 "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
1842 );
1843 return;
1844 };
1845 buf.push(UnindexedAccountEvent::new(
1846 ExchangeId::BinanceMargin,
1847 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
1848 instrument, base, quote,
1849 )),
1850 ));
1851}
1852
1853fn register_user_data_listener(
1864 ws: &Arc<WsApiBase>,
1865 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1866 dedup: SharedDedupCache,
1867 heartbeat_flag: Arc<AtomicBool>,
1868 signal_tx: oneshot::Sender<()>,
1869 mut handle_position: impl FnMut(
1870 OutboundAccountPosition,
1871 Option<i64>,
1872 &mut Vec<UnindexedAccountEvent>,
1873 ) + Send
1874 + 'static,
1875) -> Subscription {
1876 let mut signal_tx_opt = Some(signal_tx);
1877 let mut event_tx = Some(tx);
1878 let mut event_buf = Vec::with_capacity(32);
1882
1883 ws.common.events.subscribe(move |event| {
1887 let Some(ref sender) = event_tx else { return };
1888 match event {
1889 WebsocketEvent::Message(json_str) => {
1890 heartbeat_flag.store(true, Ordering::Release);
1891 let terminated = convert_margin_user_data_events_with(
1894 &json_str,
1895 &mut event_buf,
1896 &mut handle_position,
1897 );
1898 for ev in event_buf.drain(..) {
1899 if let Some(key) = dedup_key_from_event(&ev)
1900 && is_duplicate(&dedup, key)
1901 {
1902 trace!("BinanceMargin dedup: skipping duplicate event");
1903 continue;
1904 }
1905 if sender.send(ev).is_err() {
1906 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
1907 event_tx.take();
1908 if let Some(s) = signal_tx_opt.take() {
1909 let _ = s.send(());
1910 }
1911 return;
1912 }
1913 }
1914 if terminated {
1915 event_tx.take();
1916 if let Some(s) = signal_tx_opt.take() {
1917 let _ = s.send(());
1918 }
1919 }
1920 }
1921 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1922 heartbeat_flag.store(true, Ordering::Release);
1923 }
1924 WebsocketEvent::Error(e) => {
1925 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
1926 event_tx.take();
1927 if let Some(s) = signal_tx_opt.take() {
1928 let _ = s.send(());
1929 }
1930 }
1931 WebsocketEvent::Close(code, reason) => {
1932 warn!(code, %reason, "BinanceMargin WebSocket closed");
1933 event_tx.take();
1934 if let Some(s) = signal_tx_opt.take() {
1935 let _ = s.send(());
1936 }
1937 }
1938 _ => {
1939 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
1940 }
1941 }
1942 })
1943}
1944
1945async fn recover_margin_fills(
1965 rest: &Arc<RestApi>,
1966 rate_limiter: &Arc<RateLimitTracker>,
1967 unrecovered: &mut UnrecoveredFills,
1968 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1969 dedup: &SharedDedupCache,
1970 is_isolated: bool,
1971) {
1972 use futures::StreamExt as _;
1973
1974 let due = unrecovered.due(tokio::time::Instant::now());
1975 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
1976 return;
1977 };
1978 info!(
1979 gaps = due.len(),
1980 oldest = %gap_time(oldest),
1981 is_isolated,
1982 "BinanceMargin recovering fills missed while disconnected"
1983 );
1984
1985 let mut settled = vec![false; due.len()];
1988 let started = AtomicUsize::new(0);
1989 let recovery = async {
1990 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
1991 let mut recovered = 0u32;
1992 let mut duplicates = 0u32;
1993
1994 let mut stream =
1995 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
1996 started.store(index + 1, Ordering::Relaxed);
1997 let rest = rest.clone();
1998 let rl = rate_limiter.clone();
1999 async move {
2000 let span = MyTradesFrom::Span {
2001 start: gap.start_ms,
2002 end: gap.end_ms,
2003 };
2004 let raw = match paginate_margin_my_trades(
2005 &rest,
2006 &rl,
2007 &inst,
2008 span,
2009 is_isolated,
2010 RequestKind::Essential,
2011 )
2012 .await
2013 {
2014 Ok(pages) => pages,
2015 Err(e) => return (index, Err(e)),
2016 };
2017 let totals = recovered_order_totals(
2021 ExchangeId::BinanceMargin,
2022 &inst,
2023 &raw,
2024 order_executions_deadline,
2025 |order_id| {
2026 paginate_margin_my_trades(
2027 &rest,
2028 &rl,
2029 &inst,
2030 MyTradesFrom::Order(order_id),
2031 is_isolated,
2032 RequestKind::Essential,
2033 )
2034 },
2035 )
2036 .await;
2037 let trades = raw
2038 .iter()
2039 .filter_map(|t| {
2040 let mut trade = convert_margin_trade(t, &inst)?;
2041 trade.order_filled_quantity =
2042 t.id.and_then(|id| totals.get(&id).copied());
2043 Some(trade)
2044 })
2045 .collect::<Vec<_>>();
2046 (index, Ok(trades))
2047 }
2048 }))
2049 .buffer_unordered(8);
2050 while let Some((index, result)) = stream.next().await {
2051 let (inst, gap) = &due[index];
2052 settled[index] = true;
2053 let trades = match result {
2054 Ok(trades) => trades,
2055 Err(e) => {
2056 gap_failed(
2057 unrecovered,
2058 ExchangeId::BinanceMargin,
2059 inst,
2060 gap,
2061 &e.to_string(),
2062 );
2063 continue;
2064 }
2065 };
2066 for trade in trades {
2067 let event = UnindexedAccountEvent::new(
2068 ExchangeId::BinanceMargin,
2069 AccountEventKind::Trade(trade),
2070 );
2071 if let Some(key) = dedup_key_from_event(&event)
2072 && is_duplicate(dedup, key)
2073 {
2074 duplicates += 1;
2075 continue;
2076 }
2077 if tx.send(event).is_err() {
2078 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2079 return;
2080 }
2081 recovered += 1;
2082 }
2083 unrecovered.recovered(inst, gap);
2084 }
2085 info!(
2086 recovered,
2087 duplicates, is_isolated, "BinanceMargin fill recovery complete"
2088 );
2089 };
2090 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2094 .await
2095 .is_err()
2096 {
2097 let started = started.load(Ordering::Relaxed);
2098 for ((inst, gap), _) in due[..started]
2099 .iter()
2100 .zip(&settled)
2101 .filter(|(_, settled)| !**settled)
2102 {
2103 gap_failed(
2104 unrecovered,
2105 ExchangeId::BinanceMargin,
2106 inst,
2107 gap,
2108 "fill recovery timed out",
2109 );
2110 }
2111 }
2112}
2113
2114#[allow(
2121 clippy::cognitive_complexity,
2122 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2123 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2124)]
2125async fn margin_connection_manager(
2126 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2127 dedup: SharedDedupCache,
2128 ws_config: ConfigurationWebsocketApi,
2129 rest_config: Arc<ConfigurationRestApi>,
2130 rest: Arc<RestApi>,
2131 rate_limiter: Arc<RateLimitTracker>,
2132 instruments: Vec<InstrumentNameExchange>,
2133 initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2134) {
2135 enum DisconnectReason {
2136 Signal,
2137 HeartbeatTimeout,
2138 TokenRefresh,
2139 ConsumerDropped,
2140 }
2141
2142 let mut backoff = ExponentialBackoff::new();
2143 let mut disconnect_time: Option<DateTime<Utc>> = None;
2144 let mut unrecovered = UnrecoveredFills::default();
2146 let (mut current_ws, mut current_token) = match initial {
2147 Some((ws, token)) => (Some(ws), Some(token)),
2148 None => (None, None),
2149 };
2150
2151 loop {
2152 let token = match current_token.take() {
2154 Some(t) => t,
2155 None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2156 Ok(t) => t,
2157 Err(e) => {
2158 error!(%e, "BinanceMargin userListenToken acquisition failed");
2159 if !backoff.wait().await {
2160 error!("BinanceMargin max reconnect attempts exhausted");
2161 emit_stream_terminated(
2162 &tx,
2163 ExchangeId::BinanceMargin,
2164 StreamTerminationReason::ReconnectBudgetExhausted {
2165 attempts: backoff.attempts(),
2166 last_error: e.to_string(),
2167 },
2168 );
2169 break;
2170 }
2171 continue;
2172 }
2173 },
2174 };
2175
2176 let ws = match current_ws.take() {
2178 Some(ws) => ws,
2179 None => match connect_margin_ws(&ws_config).await {
2180 Ok(ws) => ws,
2181 Err(e) => {
2182 error!(%e, "BinanceMargin WS connect failed");
2183 if !backoff.wait().await {
2184 error!("BinanceMargin max reconnect attempts exhausted");
2185 emit_stream_terminated(
2186 &tx,
2187 ExchangeId::BinanceMargin,
2188 StreamTerminationReason::ReconnectBudgetExhausted {
2189 attempts: backoff.attempts(),
2190 last_error: e.to_string(),
2191 },
2192 );
2193 break;
2194 }
2195 continue;
2196 }
2197 },
2198 };
2199
2200 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2202 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2204 let subscription = register_user_data_listener(
2207 &ws,
2208 tx.clone(),
2209 dedup.clone(),
2210 heartbeat_flag.clone(),
2211 signal_tx,
2212 cross_account_position_handler,
2213 );
2214
2215 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2217 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2218 subscription.unsubscribe();
2219 if let Err(de) = ws.disconnect().await {
2220 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2221 }
2222 if !backoff.wait().await {
2224 error!("BinanceMargin max reconnect attempts exhausted");
2225 emit_stream_terminated(
2226 &tx,
2227 ExchangeId::BinanceMargin,
2228 StreamTerminationReason::ReconnectBudgetExhausted {
2229 attempts: backoff.attempts(),
2230 last_error: e.to_string(),
2231 },
2232 );
2233 break;
2234 }
2235 continue;
2236 }
2237 info!("BinanceMargin account_stream connected and subscribed");
2238 backoff.reset();
2242
2243 let token_deadline =
2246 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2247
2248 if let Some(dt) = disconnect_time.take() {
2251 unrecovered.open(&instruments, dt, Utc::now());
2252 }
2253 recover_margin_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, false).await;
2256
2257 let reason = {
2259 let mut signal_rx = signal_rx;
2260 let retry_gaps = async {
2264 loop {
2265 match unrecovered.next_due() {
2266 Some(due) => tokio::time::sleep_until(due).await,
2267 None => std::future::pending::<()>().await,
2268 }
2269 recover_margin_fills(
2270 &rest,
2271 &rate_limiter,
2272 &mut unrecovered,
2273 &tx,
2274 &dedup,
2275 false,
2276 )
2277 .await;
2278 }
2279 };
2280 tokio::pin!(retry_gaps);
2281 loop {
2282 tokio::select! {
2283 biased;
2286 _ = tx.closed() => {
2287 debug!("BinanceMargin account_stream consumer dropped, terminating");
2288 break DisconnectReason::ConsumerDropped;
2289 }
2290 _ = &mut signal_rx => {
2291 warn!("BinanceMargin WS disconnected, will attempt reconnect");
2292 break DisconnectReason::Signal;
2293 }
2294 () = tokio::time::sleep_until(token_deadline) => {
2295 info!("BinanceMargin userListenToken nearing expiry, renewing");
2296 break DisconnectReason::TokenRefresh;
2297 }
2298 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2299 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2300 continue;
2304 }
2305 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2306 break DisconnectReason::HeartbeatTimeout;
2307 }
2308 () = &mut retry_gaps => {}
2310 }
2311 }
2312 };
2313 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2314
2315 if should_reconnect {
2316 disconnect_time = Some(match reason {
2317 DisconnectReason::HeartbeatTimeout => {
2318 Utc::now()
2319 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2320 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2321 }
2322 DisconnectReason::TokenRefresh => {
2323 Utc::now()
2328 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2329 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2330 }
2331 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2332 });
2333 }
2334
2335 subscription.unsubscribe();
2337 if let Err(e) = ws.disconnect().await {
2338 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2339 }
2340
2341 if !should_reconnect || tx.is_closed() {
2342 debug!("BinanceMargin connection manager exiting");
2344 break;
2345 }
2346
2347 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2350 error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
2351 let last_error = match reason {
2354 DisconnectReason::HeartbeatTimeout => {
2355 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2356 }
2357 _ => "WebSocket disconnected (server close/error)".to_string(),
2358 };
2359 emit_stream_terminated(
2360 &tx,
2361 ExchangeId::BinanceMargin,
2362 StreamTerminationReason::ReconnectBudgetExhausted {
2363 attempts: backoff.attempts(),
2364 last_error,
2365 },
2366 );
2367 break;
2368 }
2369 }
2370}
2371
2372fn build_base_quote_map(
2384 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2385) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2386 let mut map = HashMap::with_capacity(assets.len());
2387 for entry in assets {
2388 let (Some(symbol), Some(base), Some(quote)) = (
2389 entry.symbol.as_deref(),
2390 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2391 entry
2392 .quote_asset
2393 .as_deref()
2394 .and_then(|q| q.asset.as_deref()),
2395 ) else {
2396 warn!(
2397 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2398 skipping base/quote map entry"
2399 );
2400 continue;
2401 };
2402 map.insert(
2403 InstrumentNameExchange::new(symbol),
2404 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2405 );
2406 }
2407 map
2408}
2409
2410async fn acquire_all_isolated_tokens(
2423 rest_config: &Arc<ConfigurationRestApi>,
2424 rate_limiter: &Arc<RateLimitTracker>,
2425 symbols: &[InstrumentNameExchange],
2426) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
2427 use futures::{StreamExt as _, TryStreamExt as _};
2428
2429 futures::stream::iter(symbols.iter().cloned().map(|sym| {
2430 let rest_config = rest_config.clone();
2431 let rate_limiter = rate_limiter.clone();
2432 async move {
2433 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
2434 Ok::<_, UnindexedClientError>((sym, token))
2435 }
2436 }))
2437 .buffer_unordered(8)
2438 .try_collect()
2439 .await
2440}
2441
2442fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
2447 tokens
2448 .iter()
2449 .map(|(_, t)| t.expiration_time_ms)
2450 .min()
2451 .unwrap_or(i64::MAX)
2452}
2453
2454struct IsolatedLiveConn {
2457 ws: Arc<WsApiBase>,
2458 subscription: Subscription,
2459 signal_rx: oneshot::Receiver<()>,
2460 heartbeat_flag: Arc<AtomicBool>,
2461 earliest_expiry_ms: i64,
2464}
2465
2466async fn isolated_connect_and_subscribe(
2476 ws_config: &ConfigurationWebsocketApi,
2477 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2478 dedup: &SharedDedupCache,
2479 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2480 tokens: &[(InstrumentNameExchange, UserListenToken)],
2481) -> anyhow::Result<IsolatedLiveConn> {
2482 let ws = connect_margin_ws(ws_config).await?;
2483 let sub_map = Arc::new(Mutex::new(
2489 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
2490 ));
2491 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2492 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2494
2495 let handle_position = {
2496 let sub_map = sub_map.clone();
2497 let base_quote = base_quote.clone();
2498 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
2499 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
2500 }
2501 };
2502 let subscription = register_user_data_listener(
2504 &ws,
2505 tx.clone(),
2506 dedup.clone(),
2507 heartbeat_flag.clone(),
2508 signal_tx,
2509 handle_position,
2510 );
2511
2512 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
2513 for (sym, token) in tokens {
2514 match subscribe_listen_token_capture(&ws, &token.token).await {
2515 Ok(Some(id)) => {
2516 sub_map
2517 .lock()
2518 .unwrap_or_else(|p| p.into_inner())
2519 .insert(id, sym.clone());
2520 }
2521 Ok(None) => warn!(
2522 symbol = %sym.name(),
2523 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
2524 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
2525 ),
2526 Err(e) => {
2527 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
2530 subscription.unsubscribe();
2531 if let Err(de) = ws.disconnect().await {
2532 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
2533 }
2534 return Err(e);
2535 }
2536 }
2537 }
2538
2539 Ok(IsolatedLiveConn {
2540 ws,
2541 subscription,
2542 signal_rx,
2543 heartbeat_flag,
2544 earliest_expiry_ms,
2545 })
2546}
2547
2548#[allow(
2557 clippy::cognitive_complexity,
2558 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
2559 + backoff); mirrors the cross manager, not worth splitting further"
2560)]
2561async fn isolated_connection_manager(
2562 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2563 dedup: SharedDedupCache,
2564 ws_config: ConfigurationWebsocketApi,
2565 rest_config: Arc<ConfigurationRestApi>,
2566 rest: Arc<RestApi>,
2567 rate_limiter: Arc<RateLimitTracker>,
2568 symbols: Vec<InstrumentNameExchange>,
2569 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2570 initial: Option<IsolatedLiveConn>,
2571) {
2572 enum DisconnectReason {
2573 Signal,
2574 HeartbeatTimeout,
2575 TokenRefresh,
2576 ConsumerDropped,
2577 }
2578
2579 let mut backoff = ExponentialBackoff::new();
2580 let mut disconnect_time: Option<DateTime<Utc>> = None;
2581 let mut unrecovered = UnrecoveredFills::default();
2583 let mut current = initial;
2584
2585 loop {
2586 let IsolatedLiveConn {
2588 ws,
2589 subscription,
2590 signal_rx,
2591 heartbeat_flag,
2592 earliest_expiry_ms,
2593 } = match current.take() {
2594 Some(live) => live,
2596 None => {
2597 let tokens = match acquire_all_isolated_tokens(
2600 &rest_config,
2601 &rate_limiter,
2602 &symbols,
2603 )
2604 .await
2605 {
2606 Ok(t) => t,
2607 Err(e) => {
2608 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
2609 if !backoff.wait().await {
2610 error!("BinanceMargin isolated max reconnect attempts exhausted");
2611 emit_stream_terminated(
2612 &tx,
2613 ExchangeId::BinanceMargin,
2614 StreamTerminationReason::ReconnectBudgetExhausted {
2615 attempts: backoff.attempts(),
2616 last_error: e.to_string(),
2617 },
2618 );
2619 break;
2620 }
2621 continue;
2622 }
2623 };
2624 match isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
2625 .await
2626 {
2627 Ok(live) => live,
2628 Err(e) => {
2629 error!(%e, "BinanceMargin isolated connect/subscribe failed");
2630 if !backoff.wait().await {
2631 error!("BinanceMargin isolated max reconnect attempts exhausted");
2632 emit_stream_terminated(
2633 &tx,
2634 ExchangeId::BinanceMargin,
2635 StreamTerminationReason::ReconnectBudgetExhausted {
2636 attempts: backoff.attempts(),
2637 last_error: e.to_string(),
2638 },
2639 );
2640 break;
2641 }
2642 continue;
2643 }
2644 }
2645 }
2646 };
2647
2648 info!(
2649 symbols = symbols.len(),
2650 "BinanceMargin isolated account_stream connected and subscribed"
2651 );
2652 backoff.reset();
2655
2656 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
2659
2660 if let Some(dt) = disconnect_time.take() {
2663 unrecovered.open(&symbols, dt, Utc::now());
2664 }
2665 recover_margin_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, true).await;
2667
2668 let reason = {
2671 let mut signal_rx = signal_rx;
2672 let retry_gaps = async {
2676 loop {
2677 match unrecovered.next_due() {
2678 Some(due) => tokio::time::sleep_until(due).await,
2679 None => std::future::pending::<()>().await,
2680 }
2681 recover_margin_fills(&rest, &rate_limiter, &mut unrecovered, &tx, &dedup, true)
2682 .await;
2683 }
2684 };
2685 tokio::pin!(retry_gaps);
2686 loop {
2687 tokio::select! {
2688 biased;
2689 _ = tx.closed() => {
2690 debug!("BinanceMargin isolated consumer dropped, terminating");
2691 break DisconnectReason::ConsumerDropped;
2692 }
2693 _ = &mut signal_rx => {
2694 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
2695 break DisconnectReason::Signal;
2696 }
2697 () = tokio::time::sleep_until(token_deadline) => {
2698 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
2699 break DisconnectReason::TokenRefresh;
2700 }
2701 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2702 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2703 continue;
2704 }
2705 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2706 break DisconnectReason::HeartbeatTimeout;
2707 }
2708 () = &mut retry_gaps => {}
2710 }
2711 }
2712 };
2713 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2714
2715 if should_reconnect {
2716 disconnect_time = Some(match reason {
2717 DisconnectReason::HeartbeatTimeout => {
2718 Utc::now()
2719 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2720 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2721 }
2722 DisconnectReason::TokenRefresh => {
2723 Utc::now()
2727 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2728 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2729 }
2730 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2731 });
2732 }
2733
2734 subscription.unsubscribe();
2736 if let Err(e) = ws.disconnect().await {
2737 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
2738 }
2739
2740 if !should_reconnect || tx.is_closed() {
2741 debug!("BinanceMargin isolated connection manager exiting");
2743 break;
2744 }
2745
2746 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2749 error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
2750 let last_error = match reason {
2753 DisconnectReason::HeartbeatTimeout => {
2754 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2755 }
2756 _ => "WebSocket disconnected (server close/error)".to_string(),
2757 };
2758 emit_stream_terminated(
2759 &tx,
2760 ExchangeId::BinanceMargin,
2761 StreamTerminationReason::ReconnectBudgetExhausted {
2762 attempts: backoff.attempts(),
2763 last_error,
2764 },
2765 );
2766 break;
2767 }
2768 }
2769}
2770
2771async fn fetch_margin_open_orders_for_instrument(
2778 rest: Arc<RestApi>,
2779 rate_limiter: Arc<RateLimitTracker>,
2780 instrument: InstrumentNameExchange,
2781 is_isolated: bool,
2782) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
2783 let symbol_str = instrument.name().to_string();
2785 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2786 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
2787 let sym = symbol_str.clone();
2788 let isolated = isolated.clone();
2789 Box::pin(async move {
2790 let params = QueryMarginAccountsOpenOrdersParams::builder()
2791 .symbol(sym)
2792 .is_isolated(isolated)
2793 .build()?;
2794 rest.query_margin_accounts_open_orders(params).await
2795 })
2796 })
2797 .await
2798 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
2799
2800 let orders_data = response.data().await.map_err(response_decode_error)?;
2801
2802 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceMargin, &instrument);
2803
2804 Ok((instrument, listing))
2805}
2806
2807async fn fetch_margin_all_open_orders(
2814 rest: Arc<RestApi>,
2815 rate_limiter: Arc<RateLimitTracker>,
2816 is_isolated: bool,
2817) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2818 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2819 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
2820 let isolated = isolated.clone();
2821 Box::pin(async move {
2822 let params = QueryMarginAccountsOpenOrdersParams::builder()
2823 .is_isolated(isolated)
2824 .build()?;
2825 rest.query_margin_accounts_open_orders(params).await
2826 })
2827 })
2828 .await
2829 .map_err(|e| classify_rest_query_error(&e, None))?;
2830
2831 let orders_data = response.data().await.map_err(response_decode_error)?;
2832
2833 let orders = orders_data
2834 .into_iter()
2835 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin))
2836 .collect();
2837
2838 Ok(orders)
2839}
2840
2841const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
2847
2848#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2851enum MarginTradesQuery {
2852 Window { start: i64, end: i64 },
2854 FromId(i64),
2856 Order(i64),
2858 OrderFromId { order_id: i64, from_id: i64 },
2860}
2861
2862impl MarginTradesQuery {
2863 fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
2867 match from {
2868 MyTradesFrom::Time(start) | MyTradesFrom::Span { start, .. } => {
2869 (start <= now_ms).then(|| Self::window(start, now_ms))
2870 }
2871 MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
2872 }
2873 }
2874
2875 fn window(start: i64, now_ms: i64) -> Self {
2877 Self::Window {
2878 start,
2879 end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
2880 }
2881 }
2882
2883 fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
2886 let full = page_len >= BINANCE_MAX_TRADES;
2887 let after_last = || last_id?.checked_add(1);
2888 match self {
2889 Self::Window { end, .. } if page_len == 0 => {
2894 (end < now_ms).then(|| Self::window(end, now_ms))
2895 }
2896 Self::Window { end, .. } if end >= now_ms && !full => None,
2898 Self::Window { .. } => after_last().map(Self::FromId),
2901 Self::FromId(_) if full => after_last().map(Self::FromId),
2902 Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
2903 after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
2904 }
2905 Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
2906 }
2907 }
2908}
2909
2910async fn paginate_margin_my_trades(
2933 rest: &Arc<RestApi>,
2934 rate_limiter: &Arc<RateLimitTracker>,
2935 instrument: &InstrumentNameExchange,
2936 from: MyTradesFrom,
2937 is_isolated: bool,
2938 kind: RequestKind,
2939) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
2940 let symbol_str = instrument.name().to_string();
2942 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
2943 #[allow(clippy::cast_possible_wrap)]
2948 let limit = BINANCE_MAX_TRADES as i64;
2949 let now_ms = match from {
2952 MyTradesFrom::Span { end, .. } => end.min(Utc::now().timestamp_millis()),
2953 MyTradesFrom::Time(_) | MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
2954 };
2955 let mut all_pages = Vec::new();
2956 let mut requests = 0u32;
2957 let mut next = MarginTradesQuery::first(from, now_ms);
2958 while let Some(query) = next {
2959 requests += 1;
2960 let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
2961 let sym = symbol_str.clone();
2962 let isolated = isolated.clone();
2963 Box::pin(async move {
2964 let builder = QueryMarginAccountsTradeListParams::builder(sym)
2965 .is_isolated(isolated)
2966 .limit(limit);
2967 let params = match query {
2968 MarginTradesQuery::Window { start, end } => {
2969 builder.start_time(start).end_time(end)
2970 }
2971 MarginTradesQuery::FromId(id) => builder.from_id(id),
2972 MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
2973 MarginTradesQuery::OrderFromId { order_id, from_id } => {
2974 builder.order_id(order_id).from_id(from_id)
2975 }
2976 }
2977 .build()?;
2978 rest.query_margin_accounts_trade_list(params).await
2979 })
2980 })
2981 .await
2982 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
2983
2984 let mut page = response.data().await.map_err(response_decode_error)?;
2985
2986 let page_len = page.len();
2987 let last_id = page.last().and_then(|t| t.id);
2988 let past_end = match from {
2990 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
2991 MyTradesFrom::Time(_) | MyTradesFrom::Order(_) => false,
2992 };
2993 all_pages.extend(page);
2994 if past_end {
2995 break;
2996 }
2997
2998 if page_len > 0 && last_id.is_none() {
2999 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
3000 break;
3001 }
3002 next = query.next(page_len, last_id, now_ms);
3003 }
3004 debug!(
3005 %instrument,
3006 requests,
3007 trades = all_pages.len(),
3008 "BinanceMargin paginate_my_trades: walk complete"
3009 );
3010 Ok(all_pages)
3011}
3012
3013fn convert_margin_balance_entry(
3023 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
3024 now: DateTime<Utc>,
3025) -> Option<AssetBalance<AssetNameExchange>> {
3026 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
3027 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3028 Some(v) => v,
3029 None => {
3030 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
3031 return None;
3032 }
3033 };
3034 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3035 Some(v) => v,
3036 None => {
3037 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
3038 return None;
3039 }
3040 };
3041 let borrowed = b
3046 .borrowed
3047 .as_deref()
3048 .and_then(|s| Decimal::from_str(s).ok())
3049 .unwrap_or(Decimal::ZERO);
3050 let interest = b
3051 .interest
3052 .as_deref()
3053 .and_then(|s| Decimal::from_str(s).ok())
3054 .unwrap_or(Decimal::ZERO);
3055 Some(AssetBalance::new(
3056 asset_name,
3057 Balance::new_margin(free + locked, free, borrowed, interest),
3058 now,
3059 ))
3060}
3061
3062fn filter_and_convert_margin_balances(
3065 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3066 assets: &[AssetNameExchange],
3067) -> Vec<AssetBalance<AssetNameExchange>> {
3068 let now = Utc::now();
3069 if assets.is_empty() {
3071 return user_assets
3072 .into_iter()
3073 .filter_map(|b| convert_margin_balance_entry(b, now))
3074 .collect();
3075 }
3076 if assets.len() <= 16 {
3078 return user_assets
3079 .into_iter()
3080 .filter_map(|b| {
3081 let asset_name_str = b.asset.as_deref()?;
3082 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3083 return None;
3084 }
3085 convert_margin_balance_entry(b, now)
3086 })
3087 .collect();
3088 }
3089 use std::collections::HashSet;
3090 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3091 user_assets
3092 .into_iter()
3093 .filter_map(|b| {
3094 let asset_name_str = b.asset.as_deref()?;
3095 if !asset_set.contains(asset_name_str) {
3096 return None;
3097 }
3098 convert_margin_balance_entry(b, now)
3099 })
3100 .collect()
3101}
3102
3103fn active_order_snapshot(
3110 o: Order<ExchangeId, InstrumentNameExchange, Open>,
3111) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3112{
3113 Order {
3114 key: o.key,
3115 side: o.side,
3116 price: o.price,
3117 quantity: o.quantity,
3118 kind: o.kind,
3119 time_in_force: o.time_in_force,
3120 state: OrderState::active(o.state),
3121 }
3122}
3123
3124fn convert_isolated_asset_balance(
3134 asset: Option<&str>,
3135 free: Option<&str>,
3136 locked: Option<&str>,
3137 borrowed: Option<&str>,
3138 interest: Option<&str>,
3139 now: DateTime<Utc>,
3140) -> Option<AssetBalance<AssetNameExchange>> {
3141 let asset_name = AssetNameExchange::new(asset?);
3142 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3143 Some(v) => v,
3144 None => {
3145 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3146 return None;
3147 }
3148 };
3149 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3150 Some(v) => v,
3151 None => {
3152 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3153 return None;
3154 }
3155 };
3156 let borrowed = borrowed
3157 .and_then(|s| Decimal::from_str(s).ok())
3158 .unwrap_or(Decimal::ZERO);
3159 let interest = interest
3160 .and_then(|s| Decimal::from_str(s).ok())
3161 .unwrap_or(Decimal::ZERO);
3162 Some(AssetBalance::new(
3163 asset_name,
3164 Balance::new_margin(free + locked, free, borrowed, interest),
3165 now,
3166 ))
3167}
3168
3169fn convert_isolated_margin_assets(
3177 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3178) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3179 let now = Utc::now();
3182 let mut map = HashMap::with_capacity(assets.len());
3183 for entry in assets {
3184 let Some(symbol) = entry.symbol.as_deref() else {
3185 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3186 continue;
3187 };
3188 let instrument = InstrumentNameExchange::new(symbol);
3189
3190 let Some(base_raw) = entry.base_asset.as_deref() else {
3191 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3192 continue;
3193 };
3194 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3195 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3196 continue;
3197 };
3198
3199 let Some(base) = convert_isolated_asset_balance(
3200 base_raw.asset.as_deref(),
3201 base_raw.free.as_deref(),
3202 base_raw.locked.as_deref(),
3203 base_raw.borrowed.as_deref(),
3204 base_raw.interest.as_deref(),
3205 now,
3206 ) else {
3207 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3208 continue;
3209 };
3210 let Some(quote) = convert_isolated_asset_balance(
3211 quote_raw.asset.as_deref(),
3212 quote_raw.free.as_deref(),
3213 quote_raw.locked.as_deref(),
3214 quote_raw.borrowed.as_deref(),
3215 quote_raw.interest.as_deref(),
3216 now,
3217 ) else {
3218 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3219 continue;
3220 };
3221
3222 let risk = IsolatedMarginRisk {
3223 margin_level: entry
3224 .margin_level
3225 .as_deref()
3226 .and_then(|s| Decimal::from_str(s).ok()),
3227 margin_ratio: entry
3228 .margin_ratio
3229 .as_deref()
3230 .and_then(|s| Decimal::from_str(s).ok()),
3231 liquidation_price: entry
3232 .liquidate_price
3233 .as_deref()
3234 .and_then(|s| Decimal::from_str(s).ok()),
3235 };
3236
3237 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3238 }
3239 map
3240}
3241
3242fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3245 symbols
3246 .chunks(5)
3247 .map(|chunk| {
3248 chunk
3249 .iter()
3250 .map(|s| s.name().as_str())
3251 .collect::<Vec<_>>()
3252 .join(",")
3253 })
3254 .collect()
3255}
3256
3257async fn fetch_isolated_margin_account_info(
3263 rest: Arc<RestApi>,
3264 rate_limiter: Arc<RateLimitTracker>,
3265 symbols: Vec<InstrumentNameExchange>,
3266) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
3267 use futures::{StreamExt as _, TryStreamExt as _};
3268
3269 let chunks = chunk_symbols(&symbols);
3270
3271 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3275 let rest = rest.clone();
3276 let rate_limiter = rate_limiter.clone();
3277 async move {
3278 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3279 let symbols_param = symbols_param.clone();
3280 Box::pin(async move {
3281 let params = QueryIsolatedMarginAccountInfoParams::builder()
3282 .symbols(symbols_param)
3283 .build()?;
3284 rest.query_isolated_margin_account_info(params).await
3285 })
3286 })
3287 .await
3288 .map_err(|e| classify_rest_query_error(&e, None))?;
3289
3290 let info = response.data().await.map_err(response_decode_error)?;
3291 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3292 }
3293 }))
3294 .buffer_unordered(8)
3295 .try_fold(
3296 Vec::with_capacity(symbols.len()),
3297 |mut acc, assets| async move {
3298 acc.extend(assets);
3299 Ok(acc)
3300 },
3301 )
3302 .await
3303}
3304
3305fn convert_margin_trade(
3312 t: &QueryMarginAccountsTradeListResponseInner,
3313 instrument: &InstrumentNameExchange,
3314) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3315 let trade_id_raw = match t.id {
3316 Some(id) => id,
3317 None => {
3318 warn!(%instrument, "BinanceMargin trade missing id");
3319 return None;
3320 }
3321 };
3322 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3323 let order_id = match t.order_id {
3324 Some(id) => OrderId(format_smolstr!("{id}")),
3325 None => {
3326 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3327 return None;
3328 }
3329 };
3330 let side = match t.is_buyer {
3331 Some(true) => Side::Buy,
3332 Some(false) => Side::Sell,
3333 None => {
3334 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3335 return None;
3336 }
3337 };
3338 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3339 Some(v) => v,
3340 None => {
3341 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3342 return None;
3343 }
3344 };
3345 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3346 Some(v) => v,
3347 None => {
3348 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3349 return None;
3350 }
3351 };
3352 let commission = t
3353 .commission
3354 .as_deref()
3355 .and_then(|s| Decimal::from_str(s).ok())
3356 .unwrap_or(Decimal::ZERO);
3357 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3358 Some(ts) => ts,
3359 None => {
3360 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3361 Utc::now()
3362 }
3363 };
3364
3365 let fee_asset = t
3369 .commission_asset
3370 .as_deref()
3371 .map(AssetNameExchange::from)
3372 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3373
3374 Some(Trade::new(
3375 trade_id,
3376 order_id,
3377 instrument.clone(),
3378 StrategyId::unknown(), time_exchange,
3380 side,
3381 price,
3382 quantity,
3383 None,
3385 AssetFees::new(fee_asset, commission, None),
3386 ))
3387}
3388
3389#[derive(Debug)]
3395enum BuildOrderError {
3396 Unsupported,
3398 Build(String),
3400}
3401
3402trait IsolatedFlag {
3413 fn from_flag(is_isolated: bool) -> Self;
3415}
3416
3417macro_rules! impl_isolated_flag {
3422 ($($t:ty),* $(,)?) => {
3423 $(
3424 impl IsolatedFlag for $t {
3425 fn from_flag(is_isolated: bool) -> Self {
3426 if is_isolated { Self::True } else { Self::False }
3427 }
3428 }
3429 )*
3430 };
3431}
3432
3433impl_isolated_flag!(
3434 MarginAccountNewOrderIsIsolatedEnum,
3435 MarginAccountCancelOrderIsIsolatedEnum,
3436 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3437 QueryMarginAccountsTradeListIsIsolatedEnum,
3438);
3439
3440fn build_cancel_order_params(
3448 symbol: String,
3449 id: Option<&OrderId>,
3450 cid: &ClientOrderId,
3451 is_isolated: bool,
3452) -> Result<MarginAccountCancelOrderParams, String> {
3453 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3454 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3455 );
3456
3457 match id {
3458 Some(order_id) => match order_id.0.parse::<i64>() {
3459 Ok(id) => builder = builder.order_id(id),
3460 Err(_) => {
3461 error!(
3463 order_id = %order_id.0,
3464 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3465 );
3466 builder = builder.orig_client_order_id(cid.0.to_string());
3467 }
3468 },
3469 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3470 }
3471
3472 builder.build().map_err(|e| e.to_string())
3473}
3474
3475#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3484 symbol: String,
3485 side: Side,
3486 price: Option<Decimal>,
3487 quantity: Decimal,
3488 kind: OrderKind,
3489 time_in_force: TimeInForce,
3490 new_client_order_id: String,
3491 side_effect: MarginSideEffect,
3492 is_isolated: bool,
3493) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3494 let binance_side = match side {
3495 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3496 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3497 };
3498
3499 let (binance_type, binance_tif) =
3500 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3501
3502 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3504 .quantity(quantity)
3505 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3506 .side_effect_type(side_effect.as_sdk_enum())
3507 .new_client_order_id(new_client_order_id)
3508 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3509
3510 if side_effect == MarginSideEffect::AutoBorrowRepay {
3513 builder = builder.auto_repay_at_cancel(true);
3514 }
3515
3516 if let Some(tif) = binance_tif {
3517 builder = builder.time_in_force(tif);
3518 }
3519
3520 match kind {
3523 OrderKind::Limit => {
3524 builder = builder.price(price);
3525 }
3526 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3527 builder = builder.stop_price(trigger_price);
3528 }
3529 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3530 builder = builder.price(price).stop_price(trigger_price);
3531 }
3532 _ => {}
3536 }
3537
3538 builder
3539 .build()
3540 .map_err(|e| BuildOrderError::Build(e.to_string()))
3541}
3542
3543fn convert_order_kind_tif_margin(
3553 kind: OrderKind,
3554 tif: TimeInForce,
3555) -> Option<(
3556 MarginAccountNewOrderTypeEnum,
3557 Option<MarginAccountNewOrderTimeInForceEnum>,
3558)> {
3559 if matches!(
3560 kind,
3561 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. }
3562 ) {
3563 warn!(
3564 ?kind,
3565 "BinanceMargin does not support trailing-stop orders (SDK trailingDelta binding gap)"
3566 );
3567 return None;
3568 }
3569
3570 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3571 let margin_type = match binance_type {
3572 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3573 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3574 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3575 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3576 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3577 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3578 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3579 };
3580 let margin_tif = binance_tif.map(|t| match t {
3581 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3582 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3583 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3584 });
3585 Some((margin_type, margin_tif))
3586}
3587
3588fn margin_avg_price(cummulative_quote_qty: Option<&str>, filled_qty: Decimal) -> Option<Decimal> {
3594 if filled_qty.is_zero() {
3595 return None;
3596 }
3597 let s = cummulative_quote_qty?;
3598 match Decimal::from_str(s) {
3599 Ok(cumulative) => cumulative.checked_div(filled_qty),
3600 Err(_) => {
3601 warn!(
3604 cummulative_quote_qty = s,
3605 "BinanceMargin: failed to parse cummulativeQuoteQty; avg price unavailable"
3606 );
3607 None
3608 }
3609 }
3610}
3611
3612#[cfg(test)]
3613#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3615 use super::*;
3616 use crate::client::binance::shared::convert_open_order;
3617 use crate::order::state::ActiveOrderState;
3618 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
3619
3620 #[test]
3621 fn margin_side_effect_default_is_auto_borrow_repay() {
3622 assert_eq!(
3623 MarginSideEffect::default(),
3624 MarginSideEffect::AutoBorrowRepay
3625 );
3626 }
3627
3628 #[test]
3629 fn margin_side_effect_wire_strings() {
3630 assert_eq!(
3631 MarginSideEffect::AutoBorrowRepay.as_binance_str(),
3632 "AUTO_BORROW_REPAY"
3633 );
3634 assert_eq!(
3635 MarginSideEffect::NoBorrow.as_binance_str(),
3636 "NO_SIDE_EFFECT"
3637 );
3638 }
3639
3640 #[test]
3641 fn margin_side_effect_serde_round_trip() {
3642 assert_eq!(
3644 serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
3645 r#""auto_borrow_repay""#
3646 );
3647 assert_eq!(
3648 serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
3649 r#""no_borrow""#
3650 );
3651 assert_eq!(
3652 serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
3653 MarginSideEffect::AutoBorrowRepay
3654 );
3655 assert_eq!(
3656 serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
3657 MarginSideEffect::NoBorrow
3658 );
3659 }
3660
3661 #[test]
3662 fn config_debug_redacts_secrets() {
3663 let config = BinanceMarginConfig::new(
3664 "my_api_key".to_string(),
3665 "my_secret_key".to_string(),
3666 false,
3667 false,
3668 Vec::new(),
3669 MarginSideEffect::default(),
3670 );
3671 let debug = format!("{config:?}");
3672 assert!(!debug.contains("my_api_key"));
3673 assert!(!debug.contains("my_secret_key"));
3674 assert!(debug.contains("***"));
3675 }
3676
3677 #[test]
3678 fn cross_margin_uses_common_case_defaults() {
3679 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3680 assert!(!config.testnet);
3681 assert!(!config.is_isolated);
3682 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3683 assert_eq!(config.api_key(), "k");
3684 }
3685
3686 #[test]
3687 fn config_deserializes_with_defaults() {
3688 let config: BinanceMarginConfig =
3690 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3691 .expect("deserialize");
3692 assert!(!config.is_isolated);
3693 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3694 }
3695
3696 #[test]
3697 fn config_deserializes_explicit_side_effect() {
3698 let config: BinanceMarginConfig = serde_json::from_str(
3699 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
3700 )
3701 .expect("deserialize");
3702 assert!(config.is_isolated);
3703 assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
3704 }
3705
3706 #[test]
3707 fn isolated_ctor_sets_symbols_and_flag() {
3708 let symbols = vec![
3709 InstrumentNameExchange::new("BTCUSDT"),
3710 InstrumentNameExchange::new("ETHUSDT"),
3711 ];
3712 let config =
3713 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
3714 assert!(config.is_isolated);
3715 assert_eq!(config.isolated_symbols, symbols);
3716 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3717 }
3718
3719 #[test]
3720 fn isolated_config_with_symbols_constructs() {
3721 let config = BinanceMarginConfig::isolated(
3723 "k".to_string(),
3724 "s".to_string(),
3725 vec![InstrumentNameExchange::new("BTCUSDT")],
3726 );
3727 let _client = BinanceMargin::new(config); }
3729
3730 #[test]
3731 #[should_panic(expected = "non-empty isolated_symbols")]
3732 fn isolated_empty_symbols_panics_at_new() {
3733 let config = BinanceMarginConfig::new(
3735 "k".to_string(),
3736 "s".to_string(),
3737 false,
3738 true,
3739 Vec::new(),
3740 MarginSideEffect::default(),
3741 );
3742 let _ = BinanceMargin::new(config);
3743 }
3744
3745 #[test]
3746 #[should_panic(expected = "non-empty isolated_symbols")]
3747 fn isolated_empty_symbols_panics_via_deserialize_path() {
3748 let config: BinanceMarginConfig = serde_json::from_str(
3751 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
3752 )
3753 .expect("deserialize");
3754 assert!(config.isolated_symbols.is_empty());
3755 let _ = BinanceMargin::new(config);
3756 }
3757
3758 #[test]
3759 fn isolated_flag_maps_mode() {
3760 assert_eq!(
3763 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
3764 "TRUE"
3765 );
3766 assert_eq!(
3767 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
3768 "FALSE"
3769 );
3770 assert_eq!(
3771 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
3772 "TRUE"
3773 );
3774 assert_eq!(
3775 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
3776 "FALSE"
3777 );
3778 assert_eq!(
3779 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
3780 "TRUE"
3781 );
3782 assert_eq!(
3783 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
3784 "FALSE"
3785 );
3786 assert_eq!(
3787 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
3788 "TRUE"
3789 );
3790 assert_eq!(
3791 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
3792 "FALSE"
3793 );
3794 }
3795
3796 #[test]
3801 fn cancel_params_cross_with_parseable_order_id() {
3802 let p = build_cancel_order_params(
3803 "BTCUSDT".to_string(),
3804 Some(&OrderId::new("12345")),
3805 &ClientOrderId::new("cid-1"),
3806 false,
3807 )
3808 .expect("build");
3809 assert_eq!(p.symbol, "BTCUSDT");
3810 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
3811 assert_eq!(p.order_id, Some(12345));
3812 assert_eq!(p.orig_client_order_id, None);
3813 }
3814
3815 #[test]
3816 fn cancel_params_isolated_is_config_driven() {
3817 let p = build_cancel_order_params(
3818 "BTCUSDT".to_string(),
3819 Some(&OrderId::new("12345")),
3820 &ClientOrderId::new("cid-1"),
3821 true,
3822 )
3823 .expect("build");
3824 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
3825 assert_eq!(p.order_id, Some(12345));
3826 }
3827
3828 #[test]
3829 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
3830 let p = build_cancel_order_params(
3831 "BTCUSDT".to_string(),
3832 Some(&OrderId::new("not-an-i64")),
3833 &ClientOrderId::new("cid-1"),
3834 false,
3835 )
3836 .expect("build");
3837 assert_eq!(p.order_id, None);
3838 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3839 }
3840
3841 #[test]
3842 fn cancel_params_absent_order_id_uses_cid() {
3843 let p = build_cancel_order_params(
3844 "BTCUSDT".to_string(),
3845 None,
3846 &ClientOrderId::new("cid-1"),
3847 false,
3848 )
3849 .expect("build");
3850 assert_eq!(p.order_id, None);
3851 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3852 }
3853
3854 use crate::order::TrailingOffsetType;
3859
3860 fn params(
3862 side: Side,
3863 price: Option<Decimal>,
3864 kind: OrderKind,
3865 tif: TimeInForce,
3866 side_effect: MarginSideEffect,
3867 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3868 build_new_order_params(
3869 "BTCUSDT".to_string(),
3870 side,
3871 price,
3872 Decimal::from(2),
3873 kind,
3874 tif,
3875 "cid-1".to_string(),
3876 side_effect,
3877 false, )
3879 }
3880
3881 fn gtc() -> TimeInForce {
3882 TimeInForce::GoodUntilCancelled { post_only: false }
3883 }
3884
3885 #[test]
3886 fn new_order_limit_maps_core_fields() {
3887 let p = params(
3888 Side::Buy,
3889 Some(Decimal::from(50_000)),
3890 OrderKind::Limit,
3891 gtc(),
3892 MarginSideEffect::AutoBorrowRepay,
3893 )
3894 .expect("build");
3895
3896 assert_eq!(p.symbol, "BTCUSDT");
3897 assert_eq!(p.side.as_str(), "BUY");
3898 assert_eq!(p.r#type.as_str(), "LIMIT");
3899 assert_eq!(p.quantity, Some(Decimal::from(2)));
3900 assert_eq!(p.price, Some(Decimal::from(50_000)));
3901 assert_eq!(p.stop_price, None);
3902 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
3903 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
3904 assert_eq!(
3906 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
3907 Some("FULL")
3908 );
3909 }
3910
3911 #[test]
3912 fn new_order_is_isolated_is_config_driven() {
3913 let cross = build_new_order_params(
3915 "BTCUSDT".to_string(),
3916 Side::Sell,
3917 Some(Decimal::from(10)),
3918 Decimal::from(2),
3919 OrderKind::Limit,
3920 gtc(),
3921 "cid-1".to_string(),
3922 MarginSideEffect::AutoBorrowRepay,
3923 false,
3924 )
3925 .expect("build");
3926 assert_eq!(
3927 cross.is_isolated.as_ref().map(|i| i.as_str()),
3928 Some("FALSE")
3929 );
3930 assert_eq!(cross.side.as_str(), "SELL");
3931
3932 let isolated = build_new_order_params(
3934 "BTCUSDT".to_string(),
3935 Side::Sell,
3936 Some(Decimal::from(10)),
3937 Decimal::from(2),
3938 OrderKind::Limit,
3939 gtc(),
3940 "cid-1".to_string(),
3941 MarginSideEffect::AutoBorrowRepay,
3942 true,
3943 )
3944 .expect("build");
3945 assert_eq!(
3946 isolated.is_isolated.as_ref().map(|i| i.as_str()),
3947 Some("TRUE")
3948 );
3949 }
3950
3951 #[test]
3952 fn side_effect_and_auto_repay_gating() {
3953 let auto = params(
3955 Side::Buy,
3956 Some(Decimal::from(1)),
3957 OrderKind::Limit,
3958 gtc(),
3959 MarginSideEffect::AutoBorrowRepay,
3960 )
3961 .expect("build");
3962 assert_eq!(
3963 auto.side_effect_type.as_ref().map(|s| s.as_str()),
3964 Some("AUTO_BORROW_REPAY")
3965 );
3966 assert_eq!(auto.auto_repay_at_cancel, Some(true));
3967
3968 let no_borrow = params(
3970 Side::Buy,
3971 Some(Decimal::from(1)),
3972 OrderKind::Limit,
3973 gtc(),
3974 MarginSideEffect::NoBorrow,
3975 )
3976 .expect("build");
3977 assert_eq!(
3978 no_borrow.side_effect_type.as_ref().map(|s| s.as_str()),
3979 Some("NO_SIDE_EFFECT")
3980 );
3981 assert_eq!(no_borrow.auto_repay_at_cancel, None);
3982 }
3983
3984 #[test]
3985 fn new_order_market_has_no_price_or_tif() {
3986 let p = params(
3987 Side::Buy,
3988 None,
3989 OrderKind::Market,
3990 gtc(),
3991 MarginSideEffect::AutoBorrowRepay,
3992 )
3993 .expect("build");
3994 assert_eq!(p.r#type.as_str(), "MARKET");
3995 assert_eq!(p.price, None);
3996 assert_eq!(p.stop_price, None);
3997 assert!(p.time_in_force.is_none());
3998 }
3999
4000 #[test]
4001 fn post_only_limit_maps_to_limit_maker() {
4002 let p = params(
4003 Side::Buy,
4004 Some(Decimal::from(10)),
4005 OrderKind::Limit,
4006 TimeInForce::GoodUntilCancelled { post_only: true },
4007 MarginSideEffect::AutoBorrowRepay,
4008 )
4009 .expect("build");
4010 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
4011 assert!(p.time_in_force.is_none());
4013 }
4014
4015 #[test]
4016 fn conditional_kinds_set_stop_price() {
4017 let trigger = Decimal::from(48_000);
4018
4019 let stop = params(
4020 Side::Sell,
4021 None,
4022 OrderKind::Stop {
4023 trigger_price: trigger,
4024 },
4025 gtc(),
4026 MarginSideEffect::AutoBorrowRepay,
4027 )
4028 .expect("build");
4029 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
4030 assert_eq!(stop.stop_price, Some(trigger));
4031 assert_eq!(stop.price, None);
4032
4033 let stop_limit = params(
4034 Side::Sell,
4035 Some(Decimal::from(47_900)),
4036 OrderKind::StopLimit {
4037 trigger_price: trigger,
4038 },
4039 gtc(),
4040 MarginSideEffect::AutoBorrowRepay,
4041 )
4042 .expect("build");
4043 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4044 assert_eq!(stop_limit.stop_price, Some(trigger));
4045 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4046 assert_eq!(
4047 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4048 Some("GTC")
4049 );
4050
4051 let take_profit = params(
4052 Side::Sell,
4053 None,
4054 OrderKind::TakeProfit {
4055 trigger_price: trigger,
4056 },
4057 gtc(),
4058 MarginSideEffect::AutoBorrowRepay,
4059 )
4060 .expect("build");
4061 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
4062 assert_eq!(take_profit.stop_price, Some(trigger));
4063 }
4064
4065 #[test]
4066 fn trailing_kinds_are_rejected() {
4067 let trailing_stop = params(
4068 Side::Sell,
4069 None,
4070 OrderKind::TrailingStop {
4071 offset: Decimal::from(100),
4072 offset_type: TrailingOffsetType::BasisPoints,
4073 },
4074 gtc(),
4075 MarginSideEffect::AutoBorrowRepay,
4076 );
4077 assert!(matches!(trailing_stop, Err(BuildOrderError::Unsupported)));
4078
4079 let trailing_stop_limit = params(
4080 Side::Sell,
4081 Some(Decimal::from(100)),
4082 OrderKind::TrailingStopLimit {
4083 offset: Decimal::from(100),
4084 offset_type: TrailingOffsetType::BasisPoints,
4085 limit_offset: Decimal::from(10),
4086 },
4087 gtc(),
4088 MarginSideEffect::AutoBorrowRepay,
4089 );
4090 assert!(matches!(
4091 trailing_stop_limit,
4092 Err(BuildOrderError::Unsupported)
4093 ));
4094 }
4095
4096 #[test]
4097 fn tif_variants_map_to_margin_enum() {
4098 for (tif, expected) in [
4099 (TimeInForce::ImmediateOrCancel, "IOC"),
4100 (TimeInForce::FillOrKill, "FOK"),
4101 (gtc(), "GTC"),
4102 ] {
4103 let p = params(
4104 Side::Buy,
4105 Some(Decimal::from(1)),
4106 OrderKind::Limit,
4107 tif,
4108 MarginSideEffect::AutoBorrowRepay,
4109 )
4110 .expect("build");
4111 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4112 }
4113 }
4114
4115 #[test]
4116 fn avg_price_from_cumulative_quote_qty() {
4117 assert_eq!(
4119 margin_avg_price(Some("100"), Decimal::from(4)),
4120 Some(Decimal::from(25))
4121 );
4122 assert_eq!(margin_avg_price(Some("100"), Decimal::ZERO), None);
4124 assert_eq!(margin_avg_price(None, Decimal::from(4)), None);
4126 assert_eq!(
4127 margin_avg_price(Some("not-a-number"), Decimal::from(4)),
4128 None
4129 );
4130 }
4131
4132 fn user_asset(
4137 asset: &str,
4138 free: &str,
4139 locked: &str,
4140 borrowed: &str,
4141 interest: &str,
4142 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4143 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4144 a.asset = Some(asset.to_string());
4145 a.free = Some(free.to_string());
4146 a.locked = Some(locked.to_string());
4147 a.borrowed = Some(borrowed.to_string());
4148 a.interest = Some(interest.to_string());
4149 a
4150 }
4151
4152 #[test]
4153 fn margin_balance_entry_maps_debt() {
4154 let ab =
4156 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4157 .expect("convert");
4158 assert_eq!(ab.asset.name().as_str(), "USDT");
4159 assert_eq!(ab.balance.total, Decimal::from(12));
4160 assert_eq!(ab.balance.free, Decimal::from(10));
4161 assert!(
4162 ab.balance.margin.is_some(),
4163 "REST snapshot must populate margin"
4164 );
4165 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4167 }
4168
4169 #[test]
4170 fn margin_balance_short_is_negative_net() {
4171 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4173 .expect("convert");
4174 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4175 }
4176
4177 #[test]
4178 fn margin_balance_missing_debt_defaults_to_zero() {
4179 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4182 a.asset = Some("ETH".to_string());
4183 a.free = Some("4".to_string());
4184 a.locked = Some("0".to_string());
4185 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4186 assert!(ab.balance.margin.is_some());
4187 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4188 }
4189
4190 #[test]
4191 fn margin_balance_missing_free_is_dropped() {
4192 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4194 a.asset = Some("USDT".to_string());
4195 a.locked = Some("0".to_string());
4196 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4197 }
4198
4199 #[test]
4200 fn margin_balance_filtering() {
4201 let assets = vec![
4202 user_asset("USDT", "10", "0", "0", "0"),
4203 user_asset("BTC", "1", "0", "0", "0"),
4204 user_asset("ETH", "5", "0", "0", "0"),
4205 ];
4206 assert_eq!(
4208 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4209 3
4210 );
4211 let filtered = filter_and_convert_margin_balances(
4213 assets,
4214 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4215 );
4216 assert_eq!(filtered.len(), 2);
4217 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4218 }
4219
4220 #[test]
4221 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4222 let assets = vec![
4224 user_asset("BTC", "1", "0", "0", "0"),
4225 user_asset("ETH", "5", "0", "0", "0"),
4226 user_asset("DOGE", "9", "0", "0", "0"), ];
4228 let mut requested: Vec<AssetNameExchange> = (0..17)
4230 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4231 .collect();
4232 requested.push(AssetNameExchange::new("BTC"));
4233 requested.push(AssetNameExchange::new("ETH"));
4234 assert!(
4235 requested.len() > 16,
4236 "must exceed the linear-scan threshold"
4237 );
4238
4239 let filtered = filter_and_convert_margin_balances(assets, &requested);
4240 assert_eq!(filtered.len(), 2);
4241 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4242 }
4243
4244 use binance_sdk::margin_trading::rest_api::{
4249 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4250 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4251 };
4252
4253 fn d(s: &str) -> Decimal {
4254 Decimal::from_str(s).unwrap()
4255 }
4256
4257 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4258 let mut b = IsoBase::new();
4259 b.asset = Some(asset.to_string());
4260 b.free = Some(free.to_string());
4261 b.locked = Some(locked.to_string());
4262 b.borrowed = Some(borrowed.to_string());
4263 b.interest = Some(interest.to_string());
4264 b
4265 }
4266
4267 fn iso_quote(
4268 asset: &str,
4269 free: &str,
4270 locked: &str,
4271 borrowed: &str,
4272 interest: &str,
4273 ) -> IsoQuote {
4274 let mut q = IsoQuote::new();
4275 q.asset = Some(asset.to_string());
4276 q.free = Some(free.to_string());
4277 q.locked = Some(locked.to_string());
4278 q.borrowed = Some(borrowed.to_string());
4279 q.interest = Some(interest.to_string());
4280 q
4281 }
4282
4283 fn iso_entry(
4284 symbol: &str,
4285 base: IsoBase,
4286 quote: IsoQuote,
4287 margin_level: Option<&str>,
4288 margin_ratio: Option<&str>,
4289 liquidate_price: Option<&str>,
4290 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4291 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4292 e.symbol = Some(symbol.to_string());
4293 e.base_asset = Some(Box::new(base));
4294 e.quote_asset = Some(Box::new(quote));
4295 e.margin_level = margin_level.map(str::to_string);
4296 e.margin_ratio = margin_ratio.map(str::to_string);
4297 e.liquidate_price = liquidate_price.map(str::to_string);
4298 e
4299 }
4300
4301 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4302 let config = BinanceMarginConfig::isolated(
4303 "k".to_string(),
4304 "s".to_string(),
4305 symbols
4306 .iter()
4307 .map(|s| InstrumentNameExchange::new(*s))
4308 .collect(),
4309 );
4310 BinanceMargin::new(config)
4311 }
4312
4313 #[test]
4314 fn isolated_assets_map_base_quote_and_risk() {
4315 let entry = iso_entry(
4318 "BTCUSDT",
4319 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4320 iso_quote("USDT", "1000", "50", "300", "1.5"),
4321 Some("3.5"),
4322 Some("0.12"),
4323 Some("48000"),
4324 );
4325 let map = convert_isolated_margin_assets(vec![entry]);
4326 let state = map
4327 .get(&InstrumentNameExchange::new("BTCUSDT"))
4328 .expect("entry present");
4329
4330 assert_eq!(state.base.asset.name().as_str(), "BTC");
4331 assert_eq!(state.base.balance.total, d("0.6"));
4332 assert_eq!(state.base.balance.free, d("0.5"));
4333 assert_eq!(state.base.balance.net_asset(), d("0.4"));
4334
4335 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4336 assert_eq!(state.quote.balance.total, d("1050"));
4337 assert_eq!(state.quote.balance.net_asset(), d("750"));
4338
4339 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4340 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4341 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4342 }
4343
4344 #[test]
4345 fn isolated_base_short_is_negative_net() {
4346 let entry = iso_entry(
4348 "BTCUSDT",
4349 iso_base("BTC", "0", "0", "1.5", "0.001"),
4350 iso_quote("USDT", "100", "0", "0", "0"),
4351 None,
4352 None,
4353 None,
4354 );
4355 let map = convert_isolated_margin_assets(vec![entry]);
4356 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4357 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4358 }
4359
4360 #[test]
4361 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4362 let mut base = IsoBase::new();
4365 base.asset = Some("BTC".to_string());
4366 base.free = Some("0.5".to_string());
4367 base.locked = Some("0".to_string());
4368 let mut quote = IsoQuote::new();
4369 quote.asset = Some("USDT".to_string());
4370 quote.free = Some("100".to_string());
4371 quote.locked = Some("0".to_string());
4372
4373 let map = convert_isolated_margin_assets(vec![iso_entry(
4374 "BTCUSDT", base, quote, None, None, None,
4375 )]);
4376 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4377 assert!(state.base.balance.margin.is_some());
4378 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4379 assert_eq!(state.risk.margin_level, None);
4380 assert_eq!(state.risk.liquidation_price, None);
4381 }
4382
4383 #[test]
4384 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4385 let entry = iso_entry(
4386 "BTCUSDT",
4387 iso_base("BTC", "1", "0", "0", "0"),
4388 iso_quote("USDT", "1", "0", "0", "0"),
4389 Some("not_a_number"),
4390 None,
4391 None,
4392 );
4393 let map = convert_isolated_margin_assets(vec![entry]);
4394 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4395 assert_eq!(state.risk.margin_level, None);
4396 }
4397
4398 #[test]
4399 fn isolated_missing_base_free_drops_entry() {
4400 let mut base = IsoBase::new();
4402 base.asset = Some("BTC".to_string());
4403 base.locked = Some("0".to_string()); let entry = iso_entry(
4405 "BTCUSDT",
4406 base,
4407 iso_quote("USDT", "100", "0", "0", "0"),
4408 None,
4409 None,
4410 None,
4411 );
4412 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4413 }
4414
4415 #[test]
4416 fn isolated_missing_symbol_drops_entry() {
4417 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4418 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4419 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4420 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4422 }
4423
4424 #[test]
4425 fn chunk_symbols_batches_by_five() {
4426 let syms: Vec<InstrumentNameExchange> = (0..12)
4427 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4428 .collect();
4429 let chunks = chunk_symbols(&syms);
4430 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4431 assert_eq!(chunks[0].split(',').count(), 5);
4432 assert_eq!(chunks[1].split(',').count(), 5);
4433 assert_eq!(chunks[2].split(',').count(), 2);
4434
4435 let three: Vec<_> = (0..3)
4437 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4438 .collect();
4439 assert_eq!(chunk_symbols(&three).len(), 1);
4440
4441 let five: Vec<_> = (0..5)
4443 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4444 .collect();
4445 let c = chunk_symbols(&five);
4446 assert_eq!(c.len(), 1);
4447 assert_eq!(c[0].split(',').count(), 5);
4448
4449 assert!(chunk_symbols(&[]).is_empty());
4451 }
4452
4453 #[test]
4454 fn effective_isolated_set_empty_returns_all_configured() {
4455 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4456 assert_eq!(
4457 client.effective_isolated_set(&[]),
4458 vec![
4459 InstrumentNameExchange::new("BTCUSDT"),
4460 InstrumentNameExchange::new("ETHUSDT"),
4461 ]
4462 );
4463 }
4464
4465 #[test]
4466 fn effective_isolated_set_intersects_requested() {
4467 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4468 assert_eq!(
4469 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4470 vec![InstrumentNameExchange::new("ETHUSDT")]
4471 );
4472 }
4473
4474 #[test]
4475 fn effective_isolated_set_skips_out_of_set() {
4476 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4477 assert_eq!(
4479 client.effective_isolated_set(&[
4480 InstrumentNameExchange::new("BTCUSDT"),
4481 InstrumentNameExchange::new("DOGEUSDT"),
4482 ]),
4483 vec![InstrumentNameExchange::new("BTCUSDT")]
4484 );
4485 }
4486
4487 #[tokio::test]
4488 async fn fetch_balances_isolated_returns_empty() {
4489 let client = isolated_client(&["BTCUSDT"]);
4492 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4493 }
4494
4495 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4496 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4497 o.order_id = order_id;
4498 o.client_order_id = Some("cid-9".to_string());
4499 o.side = Some(side.to_string());
4500 o.price = Some("50000".to_string());
4501 o.orig_qty = Some("2".to_string());
4502 o.executed_qty = Some("0.5".to_string());
4503 o.r#type = Some("LIMIT".to_string());
4504 o.time_in_force = Some("GTC".to_string());
4505 o.time = Some(1_700_000_000_000);
4506 o.status = Some("PARTIALLY_FILLED".to_string());
4507 o
4508 }
4509
4510 #[test]
4511 fn margin_open_order_converts() {
4512 let inst = InstrumentNameExchange::new("BTCUSDT");
4513 let order = convert_open_order(
4514 &open_order(Some(42), "BUY"),
4515 ExchangeId::BinanceMargin,
4516 &inst,
4517 )
4518 .expect("convert");
4519 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4520 assert_eq!(
4521 order.state.id.assigned().map(|id| id.0.as_str()),
4522 Some("42")
4523 );
4524 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4525 assert_eq!(order.side, Side::Buy);
4526 assert_eq!(order.price, Some(Decimal::from(50_000)));
4527 assert_eq!(order.quantity, Decimal::from(2));
4528 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4529 assert_eq!(order.kind, OrderKind::Limit);
4530 }
4531
4532 #[test]
4535 fn margin_open_order_that_is_not_live_is_dropped() {
4536 let inst = InstrumentNameExchange::new("BTCUSDT");
4537 let mut o = open_order(Some(42), "BUY");
4538 o.status = Some("CANCELED".to_string());
4539 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst).is_none());
4540 }
4541
4542 #[test]
4543 fn margin_open_order_missing_order_id_is_dropped() {
4544 let inst = InstrumentNameExchange::new("BTCUSDT");
4545 assert!(
4546 convert_open_order(&open_order(None, "BUY"), ExchangeId::BinanceMargin, &inst)
4547 .is_none()
4548 );
4549 }
4550
4551 #[test]
4557 fn margin_open_order_prefers_update_time_over_creation_time() {
4558 let inst = InstrumentNameExchange::new("BTCUSDT");
4559
4560 let mut updated = open_order(Some(42), "BUY");
4561 updated.update_time = Some(1_700_000_005_000);
4562 let order =
4563 convert_open_order(&updated, ExchangeId::BinanceMargin, &inst).expect("convert");
4564 assert_eq!(
4565 order.state.time_exchange,
4566 Utc.timestamp_millis_opt(1_700_000_005_000)
4567 .single()
4568 .unwrap(),
4569 "update_time must win over time"
4570 );
4571 }
4572
4573 #[test]
4576 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
4577 let inst = InstrumentNameExchange::new("BTCUSDT");
4578
4579 let base = open_order(Some(42), "BUY");
4580 assert!(base.update_time.is_none(), "fixture must omit update_time");
4581 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst).expect("convert");
4582 assert_eq!(
4583 order.state.time_exchange,
4584 Utc.timestamp_millis_opt(1_700_000_000_000)
4585 .single()
4586 .unwrap(),
4587 "absent update_time falls back to time"
4588 );
4589 }
4590
4591 #[test]
4592 fn margin_open_order_owned_symbol_recovers_instrument() {
4593 let mut o = open_order(Some(42), "SELL");
4595 o.symbol = Some("ETHUSDT".to_string());
4596 let order =
4597 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).expect("convert");
4598 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4599 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4600 assert_eq!(order.side, Side::Sell);
4601 }
4602
4603 #[test]
4604 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4605 let o = open_order(Some(42), "BUY");
4607 assert!(o.symbol.is_none());
4608 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).is_none());
4609 }
4610
4611 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4612 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4613 t.id = id;
4614 t.order_id = Some(7);
4615 t.is_buyer = is_buyer;
4616 t.price = Some("48000".to_string());
4617 t.qty = Some("0.25".to_string());
4618 t.commission = Some("0.001".to_string());
4619 t.commission_asset = Some("BNB".to_string());
4620 t.time = Some(1_700_000_000_000);
4621 t
4622 }
4623
4624 #[test]
4625 fn margin_trade_converts() {
4626 let inst = InstrumentNameExchange::new("BTCUSDT");
4627 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4628 assert_eq!(tr.id.0.as_str(), "11");
4629 assert_eq!(tr.order_id.0.as_str(), "7");
4630 assert_eq!(tr.side, Side::Sell);
4631 assert_eq!(tr.price, Decimal::from(48_000));
4632 assert_eq!(tr.quantity, Decimal::new(25, 2));
4633 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4635 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4636 }
4637
4638 #[test]
4639 fn margin_trade_missing_fields_are_dropped() {
4640 let inst = InstrumentNameExchange::new("BTCUSDT");
4641 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4643 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4644 }
4645
4646 #[test]
4649 fn token_renew_after_applies_margin_and_floor() {
4650 let now_ms = Utc::now().timestamp_millis();
4651 let far = token_renew_after(now_ms + 86_400_000).as_secs();
4653 assert!(
4654 far > 80_000 && far <= 86_400,
4655 "expected ~24h-minus-margin, got {far}"
4656 );
4657 assert_eq!(
4659 token_renew_after(now_ms - 10_000).as_secs(),
4660 TOKEN_MIN_LIFETIME_SECS
4661 );
4662 assert_eq!(
4664 token_renew_after(now_ms + 60_000).as_secs(),
4665 TOKEN_MIN_LIFETIME_SECS
4666 );
4667 }
4668
4669 fn push(event: serde_json::Value) -> String {
4674 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
4675 }
4676
4677 #[test]
4678 fn margin_ws_rpc_ack_yields_no_events() {
4679 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
4681 let mut buf = Vec::new();
4682 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4683 assert!(buf.is_empty());
4684 }
4685
4686 #[test]
4689 fn margin_ws_unrecognised_frames_yield_no_events() {
4690 for frame in [
4691 r#"{"e":"eventStreamTerminated","E":1}"#,
4692 r#"{"subscriptionId":1,"event":{"E":1}}"#,
4693 ] {
4694 let mut buf = Vec::new();
4695 assert!(!convert_margin_user_data_events(frame, &mut buf), "{frame}");
4696 assert!(buf.is_empty(), "{frame}");
4697 }
4698 }
4699
4700 #[test]
4708 fn margin_ws_execution_report_trade_maps_to_trade_and_order_snapshot() {
4709 let frame = push(serde_json::json!({
4710 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4711 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
4712 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4713 "p": "48000", "q": "2", "f": "GTC",
4714 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
4715 }));
4716 let mut buf = Vec::new();
4717 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4718 assert_eq!(
4719 buf.len(),
4720 2,
4721 "a fill must produce the execution and the order snapshot, got: {buf:?}"
4722 );
4723 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
4724 match &buf[0].kind {
4725 AccountEventKind::Trade(t) => {
4726 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
4727 assert_eq!(t.side, Side::Buy);
4728 assert_eq!(t.price, Decimal::from(48_000));
4729 assert_eq!(t.quantity, Decimal::new(5, 1));
4730 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
4732 }
4733 other => panic!("expected Trade, got {other:?}"),
4734 }
4735 assert_eq!(buf[1].exchange, ExchangeId::BinanceMargin);
4736 match &buf[1].kind {
4737 AccountEventKind::OrderSnapshot(snap) => {
4738 assert_eq!(
4739 snap.0.quantity,
4740 Decimal::from(2),
4741 "the order's total quantity"
4742 );
4743 match &snap.0.state {
4744 OrderState::Active(ActiveOrderState::Open(open)) => {
4745 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("12345"));
4746 assert_eq!(
4747 open.filled_quantity,
4748 Decimal::new(5, 1),
4749 "the snapshot must carry the order's cumulative filled quantity (z)"
4750 );
4751 assert_eq!(
4752 open.time_exchange.timestamp_millis(),
4753 1_700_000_000_000,
4754 "stamped with the execution's transaction time (T), which is what \
4755 gives the engine's staleness gate an ordering key"
4756 );
4757 }
4758 other => panic!("expected an Open order, got {other:?}"),
4759 }
4760 }
4761 other => panic!("expected OrderSnapshot, got {other:?}"),
4762 }
4763 }
4764
4765 #[test]
4769 fn margin_trade_for_an_order_no_longer_working_emits_no_snapshot() {
4770 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
4771 let frame = push(serde_json::json!({
4772 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4773 "x": "TRADE", "X": status, "i": 12_345_i64, "c": "cid-1",
4774 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4775 "p": "48000", "q": "2", "f": "GTC", "T": 1_700_000_000_000_i64,
4776 }));
4777 let mut buf = Vec::new();
4778 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4779 assert_eq!(
4780 buf.len(),
4781 1,
4782 "status {status} must produce the execution alone, got: {buf:?}"
4783 );
4784 assert!(
4785 matches!(buf[0].kind, AccountEventKind::Trade(_)),
4786 "the execution must still be reported for status {status}"
4787 );
4788 }
4789 }
4790
4791 #[test]
4792 fn margin_ws_new_report_maps_to_active_order_snapshot() {
4793 let frame = push(serde_json::json!({
4794 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
4795 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
4796 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
4797 }));
4798 let mut buf = Vec::new();
4799 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4800 assert_eq!(buf.len(), 1);
4801 match &buf[0].kind {
4802 AccountEventKind::OrderSnapshot(snap) => {
4803 assert_eq!(snap.0.side, Side::Sell);
4804 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
4805 assert_eq!(snap.0.quantity, Decimal::from(1));
4806 assert!(
4807 matches!(snap.0.state, OrderState::Active(_)),
4808 "NEW should be an active (resting) order"
4809 );
4810 }
4811 other => panic!("expected OrderSnapshot, got {other:?}"),
4812 }
4813 }
4814
4815 #[test]
4816 fn margin_ws_canceled_report_maps_to_order_cancelled() {
4817 let frame = push(serde_json::json!({
4818 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4819 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
4820 "z": "0", "T": 1_700_000_000_000_i64,
4821 }));
4822 let mut buf = Vec::new();
4823 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4824 assert_eq!(buf.len(), 1);
4825 match &buf[0].kind {
4826 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
4827 other => panic!("expected OrderCancelled, got {other:?}"),
4828 }
4829 }
4830
4831 #[test]
4832 fn margin_ws_execution_report_missing_symbol_is_dropped() {
4833 let frame = push(serde_json::json!({
4835 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
4836 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
4837 }));
4838 let mut buf = Vec::new();
4839 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4840 assert!(buf.is_empty());
4841 }
4842
4843 #[test]
4844 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
4845 let frame = push(serde_json::json!({
4846 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4847 "B": [
4848 { "a": "USDT", "f": "100.0", "l": "5.0" },
4849 { "a": "BTC", "f": "0.5", "l": "0" },
4850 ],
4851 }));
4852 let mut buf = Vec::new();
4853 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4854 assert_eq!(buf.len(), 2);
4855 for ev in &buf {
4857 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
4858 }
4859 }
4860
4861 #[test]
4862 fn margin_ws_stream_terminated_signals_reconnect() {
4863 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
4864 let mut buf = Vec::new();
4865 assert!(
4866 convert_margin_user_data_events(&frame, &mut buf),
4867 "eventStreamTerminated must signal reconnect"
4868 );
4869 assert!(buf.is_empty());
4870 }
4871
4872 #[test]
4873 fn margin_ws_margin_specific_events_are_observable_only() {
4874 let mut buf = Vec::new();
4877 let liability = push(serde_json::json!({
4878 "e": "userLiabilityChange", "a": "USDT", "t": "BORROW", "p": "100", "i": "0.01",
4879 }));
4880 assert!(!convert_margin_user_data_events(&liability, &mut buf));
4881 let level = push(serde_json::json!({
4882 "e": "marginLevelStatusChange", "l": "1.5", "s": "MARGIN_LEVEL_2",
4883 }));
4884 assert!(!convert_margin_user_data_events(&level, &mut buf));
4885 assert!(buf.is_empty());
4886 }
4887
4888 #[test]
4891 fn margin_trade_event_dedup_by_key() {
4892 let frame = push(serde_json::json!({
4895 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4896 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
4897 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
4898 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
4899 }));
4900 let mut buf = Vec::new();
4901 convert_margin_user_data_events(&frame, &mut buf);
4902 let event = buf.first().expect("a Trade event");
4905 assert!(
4906 matches!(event.kind, AccountEventKind::Trade(_)),
4907 "the execution is the first of the two events a fill produces"
4908 );
4909
4910 let cache = new_dedup_cache();
4911 let first = dedup_key_from_event(event).expect("Trade events have a dedup key");
4913 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
4914 let second = dedup_key_from_event(event).expect("Trade events have a dedup key");
4915 assert!(
4916 is_duplicate(&cache, second),
4917 "second sighting is a duplicate"
4918 );
4919 }
4920
4921 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
4929 let cache = new_dedup_cache();
4930 let mut delivered = Vec::new();
4931 let mut buf = Vec::new();
4932 for frame in frames {
4933 let _ = convert_margin_user_data_events(frame, &mut buf);
4934 for ev in buf.drain(..) {
4935 if let Some(key) = dedup_key_from_event(&ev)
4936 && is_duplicate(&cache, key)
4937 {
4938 continue;
4939 }
4940 delivered.push(ev);
4941 }
4942 }
4943 delivered
4944 }
4945
4946 fn ws_new(cumulative_filled: &str) -> String {
4947 push(serde_json::json!({
4948 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4949 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "client-1",
4950 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
4951 "T": 1_700_000_000_000_i64,
4952 }))
4953 }
4954
4955 fn ws_partial_fill(cumulative_filled: &str) -> String {
4956 push(serde_json::json!({
4957 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4958 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "client-1",
4959 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
4960 "l": "1", "L": "100", "t": 555_i64, "n": "0.1", "N": "USDT",
4961 "T": 1_700_000_001_000_i64,
4962 }))
4963 }
4964
4965 fn open_filled_quantities(events: &[UnindexedAccountEvent]) -> Vec<Decimal> {
4966 events
4967 .iter()
4968 .filter_map(|ev| match &ev.kind {
4969 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
4970 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
4971 _ => None,
4972 },
4973 _ => None,
4974 })
4975 .collect()
4976 }
4977
4978 #[test]
4986 fn margin_ws_fill_snapshot_survives_the_dedup_gate() {
4987 let delivered = drive_ws_pipeline(&[&ws_new("0"), &ws_partial_fill("1")]);
4988
4989 assert_eq!(
4990 delivered.len(),
4991 3,
4992 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
4993 );
4994 assert_eq!(
4995 open_filled_quantities(&delivered),
4996 vec![Decimal::ZERO, Decimal::ONE],
4997 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
4998 );
4999 }
5000
5001 #[test]
5004 fn margin_ws_replayed_frame_is_still_deduplicated() {
5005 let delivered = drive_ws_pipeline(&[
5006 &ws_new("0"),
5007 &ws_partial_fill("1"),
5008 &ws_new("0"),
5009 &ws_partial_fill("1"),
5010 ]);
5011
5012 assert_eq!(
5013 delivered.len(),
5014 3,
5015 "re-delivering both frames must add nothing, got: {delivered:?}"
5016 );
5017 }
5018
5019 #[test]
5025 fn margin_ws_an_ack_and_its_fill_reporting_one_state_deliver_that_state_once() {
5026 let delivered = drive_ws_pipeline(&[&ws_new("1"), &ws_partial_fill("1")]);
5027
5028 assert_eq!(
5029 delivered.len(),
5030 2,
5031 "the ack snapshot and the execution, with the fill's duplicate snapshot collapsed, \
5032 got: {delivered:?}"
5033 );
5034 assert!(
5035 delivered
5036 .iter()
5037 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
5038 "the execution must reach the consumer regardless"
5039 );
5040 assert_eq!(
5041 open_filled_quantities(&delivered),
5042 vec![Decimal::ONE],
5043 "one snapshot, carrying the state both reports agree on"
5044 );
5045 }
5046
5047 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
5052 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
5053 }
5054
5055 fn isolated_handler(
5058 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
5059 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
5060 ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
5061 move |position, subscription_id, buf| {
5062 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
5063 }
5064 }
5065
5066 fn btcusdt() -> InstrumentNameExchange {
5067 InstrumentNameExchange::new("BTCUSDT")
5068 }
5069
5070 fn token(expiration_time_ms: i64) -> UserListenToken {
5071 UserListenToken {
5072 token: "t".to_string(),
5073 expiration_time_ms,
5074 }
5075 }
5076
5077 #[test]
5078 fn listen_token_query_cross_vs_isolated() {
5079 assert!(build_listen_token_query(None).is_empty());
5081
5082 let q = build_listen_token_query(Some(&btcusdt()));
5084 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
5085 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
5086 assert_eq!(q.len(), 2);
5087 }
5088
5089 #[test]
5090 fn earliest_token_expiry_picks_min() {
5091 let tokens = vec![
5092 (btcusdt(), token(3_000)),
5093 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
5094 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
5095 ];
5096 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
5097 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
5099 }
5100
5101 #[test]
5102 fn base_quote_map_extracts_base_and_quote() {
5103 let entries = vec![
5104 iso_entry(
5105 "BTCUSDT",
5106 iso_base("BTC", "0", "0", "0", "0"),
5107 iso_quote("USDT", "0", "0", "0", "0"),
5108 None,
5109 None,
5110 None,
5111 ),
5112 iso_entry(
5113 "ETHBTC",
5114 iso_base("ETH", "0", "0", "0", "0"),
5115 iso_quote("BTC", "0", "0", "0", "0"),
5116 None,
5117 None,
5118 None,
5119 ),
5120 ];
5121 let map = build_base_quote_map(&entries);
5122 assert_eq!(
5123 map.get(&btcusdt())
5124 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5125 Some(("BTC", "USDT"))
5126 );
5127 assert_eq!(
5130 map.get(&InstrumentNameExchange::new("ETHBTC"))
5131 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5132 Some(("ETH", "BTC"))
5133 );
5134 }
5135
5136 #[test]
5137 fn isolated_outbound_position_routes_to_instrument_balance_update() {
5138 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5139 let base_quote = Arc::new(HashMap::from([(
5140 btcusdt(),
5141 (
5142 AssetNameExchange::new("BTC"),
5143 AssetNameExchange::new("USDT"),
5144 ),
5145 )]));
5146 let mut handler = isolated_handler(sub_map, base_quote);
5147
5148 let frame = push_with_sub(
5150 7,
5151 serde_json::json!({
5152 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5153 "B": [
5154 { "a": "USDT", "f": "1000.0", "l": "50.0" },
5155 { "a": "BTC", "f": "0.5", "l": "0.1" },
5156 ],
5157 }),
5158 );
5159 let mut buf = Vec::new();
5160 assert!(!convert_margin_user_data_events_with(
5161 &frame,
5162 &mut buf,
5163 &mut handler
5164 ));
5165 assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
5166 match &buf[0].kind {
5167 AccountEventKind::InstrumentBalanceUpdate(ibu) => {
5168 assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
5169 assert_eq!(ibu.base.asset.name().as_str(), "BTC");
5170 assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
5171 assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
5172 assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
5173 assert_eq!(ibu.quote.update.free, Decimal::from(1000));
5174 assert_eq!(ibu.quote.update.locked, Decimal::from(50));
5175 }
5176 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
5177 }
5178 assert!(!matches!(
5180 buf[0].kind,
5181 AccountEventKind::BalanceStreamUpdate(_)
5182 ));
5183 }
5184
5185 #[test]
5186 fn isolated_outbound_position_unknown_subscription_dropped() {
5187 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5189 let base_quote = Arc::new(HashMap::from([(
5190 btcusdt(),
5191 (
5192 AssetNameExchange::new("BTC"),
5193 AssetNameExchange::new("USDT"),
5194 ),
5195 )]));
5196 let mut handler = isolated_handler(sub_map, base_quote);
5197
5198 let frame = push_with_sub(
5199 99,
5200 serde_json::json!({
5201 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5202 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
5203 }),
5204 );
5205 let mut buf = Vec::new();
5206 assert!(!convert_margin_user_data_events_with(
5207 &frame,
5208 &mut buf,
5209 &mut handler
5210 ));
5211 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
5212 }
5213
5214 #[test]
5215 fn isolated_outbound_position_missing_side_dropped() {
5216 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5219 let base_quote = Arc::new(HashMap::from([(
5220 btcusdt(),
5221 (
5222 AssetNameExchange::new("BTC"),
5223 AssetNameExchange::new("USDT"),
5224 ),
5225 )]));
5226 let mut handler = isolated_handler(sub_map, base_quote);
5227
5228 let frame = push_with_sub(
5229 7,
5230 serde_json::json!({
5231 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5232 "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
5233 }),
5234 );
5235 let mut buf = Vec::new();
5236 assert!(!convert_margin_user_data_events_with(
5237 &frame,
5238 &mut buf,
5239 &mut handler
5240 ));
5241 assert!(buf.is_empty(), "missing quote side → frame dropped");
5242 }
5243
5244 #[test]
5245 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
5246 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
5250 let base_quote = Arc::new(HashMap::new());
5251 let mut handler = isolated_handler(sub_map, base_quote);
5252
5253 let frame = push_with_sub(
5254 42,
5255 serde_json::json!({
5256 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5257 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
5258 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
5259 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5260 }),
5261 );
5262 let mut buf = Vec::new();
5263 assert!(!convert_margin_user_data_events_with(
5264 &frame,
5265 &mut buf,
5266 &mut handler
5267 ));
5268 assert_eq!(buf.len(), 2, "the execution and its order snapshot");
5269 match &buf[0].kind {
5270 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
5271 other => panic!("expected Trade, got {other:?}"),
5272 }
5273 match &buf[1].kind {
5274 AccountEventKind::OrderSnapshot(snap) => {
5275 assert_eq!(snap.0.key.instrument.name().as_str(), "BTCUSDT");
5276 }
5277 other => panic!("expected OrderSnapshot, got {other:?}"),
5278 }
5279 }
5280
5281 struct MarginMyTradesVenue {
5292 trades: Vec<(i64, i64, i64)>,
5294 end_exclusive: bool,
5295 }
5296
5297 impl wiremock::Respond for MarginMyTradesVenue {
5298 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
5299 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
5300 let query: std::collections::HashMap<String, String> =
5301 request.url.query_pairs().into_owned().collect();
5302 let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
5303 let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
5304 let order = param("orderId");
5305
5306 let mut selected: Vec<&(i64, i64, i64)> = self
5307 .trades
5308 .iter()
5309 .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
5310 .collect();
5311 if let Some(from_id) = param("fromId") {
5312 selected.retain(|(id, _, _)| *id >= from_id);
5313 } else if order.is_none() {
5314 let start = param("startTime").expect("a query by time sends startTime");
5315 let end = match param("endTime") {
5316 Some(end) if end - start >= DAY_MS => {
5317 return wiremock::ResponseTemplate::new(400).set_body_json(
5318 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5319 );
5320 }
5321 Some(end) if self.end_exclusive => end - 1,
5322 Some(end) => end,
5323 None => start + DAY_MS - 1,
5324 };
5325 selected.retain(|(_, time, _)| (start..=end).contains(time));
5326 }
5327
5328 let page: Vec<serde_json::Value> = selected
5329 .into_iter()
5330 .take(limit)
5331 .map(|(id, time, order_id)| {
5332 serde_json::json!({
5333 "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
5334 "price": "50000", "qty": "0.01", "commission": "0",
5335 "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
5336 "isBestMatch": true, "isIsolated": true,
5337 })
5338 })
5339 .collect();
5340 wiremock::ResponseTemplate::new(200).set_body_json(page)
5341 }
5342 }
5343
5344 async fn read_margin_trades(
5347 venue: MarginMyTradesVenue,
5348 from: MyTradesFrom,
5349 ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
5350 let server = wiremock::MockServer::start().await;
5351 wiremock::Mock::given(wiremock::matchers::method("GET"))
5352 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5353 .respond_with(venue)
5354 .mount(&server)
5355 .await;
5356 let rest = Arc::new(MarginTradingRestApi::from_config(
5357 ConfigurationRestApi::builder()
5358 .api_key("key")
5359 .api_secret("secret")
5360 .base_path(server.uri())
5361 .build()
5362 .unwrap(),
5363 ));
5364
5365 let read = paginate_margin_my_trades(
5366 &rest,
5367 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5368 &InstrumentNameExchange::new("BTCUSDT"),
5369 from,
5370 true,
5371 RequestKind::Query,
5372 )
5373 .await
5374 .unwrap();
5375
5376 let requests = server
5377 .received_requests()
5378 .await
5379 .unwrap()
5380 .iter()
5381 .map(|request| request.url.query_pairs().into_owned().collect())
5382 .collect();
5383 (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
5384 }
5385
5386 fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
5388 MarginMyTradesVenue {
5389 trades: trades
5390 .into_iter()
5391 .map(|(id, time)| (id, time, id))
5392 .collect(),
5393 end_exclusive: false,
5394 }
5395 }
5396
5397 const HOUR_MS: i64 = 60 * 60 * 1000;
5398
5399 #[tokio::test]
5402 async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
5403 let now = Utc::now().timestamp_millis();
5404 let (ids, _) = read_margin_trades(
5405 venue(vec![(7, now - 72 * HOUR_MS)]),
5406 MyTradesFrom::Time(now - 120 * HOUR_MS),
5407 )
5408 .await;
5409
5410 assert_eq!(ids, vec![7]);
5411 }
5412
5413 #[tokio::test]
5416 async fn margin_trades_read_fills_across_several_windows_once_each() {
5417 let now = Utc::now().timestamp_millis();
5418 let trades = vec![
5419 (1, now - 119 * HOUR_MS),
5420 (2, now - 72 * HOUR_MS),
5421 (3, now - HOUR_MS),
5422 ];
5423 let (ids, _) =
5424 read_margin_trades(venue(trades), MyTradesFrom::Time(now - 144 * HOUR_MS)).await;
5425
5426 assert_eq!(ids, vec![1, 2, 3]);
5427 }
5428
5429 #[tokio::test]
5432 async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
5433 for end_exclusive in [false, true] {
5434 let now = Utc::now().timestamp_millis();
5435 let from = now - 48 * HOUR_MS;
5436 let venue = MarginMyTradesVenue {
5437 end_exclusive,
5438 ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
5439 };
5440 let (ids, _) = read_margin_trades(venue, MyTradesFrom::Time(from)).await;
5441
5442 assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
5443 }
5444 }
5445
5446 #[tokio::test]
5448 async fn margin_trades_page_on_by_id_past_a_full_window() {
5449 let now = Utc::now().timestamp_millis();
5450 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5451 let trades = (1..=count)
5452 .map(|id| (id, now - 48 * HOUR_MS + id))
5453 .collect();
5454 let (ids, _) =
5455 read_margin_trades(venue(trades), MyTradesFrom::Time(now - 50 * HOUR_MS)).await;
5456
5457 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5458 }
5459
5460 #[tokio::test]
5463 async fn margin_trades_page_on_past_a_full_final_window() {
5464 let now = Utc::now().timestamp_millis();
5465 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5466 let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
5467 let (ids, requests) =
5468 read_margin_trades(venue(trades), MyTradesFrom::Time(now - 2 * HOUR_MS)).await;
5469
5470 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
5471 assert_eq!(requests.len(), 2);
5472 }
5473
5474 #[tokio::test]
5477 async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
5478 let now = Utc::now().timestamp_millis();
5479 let (ids, requests) =
5480 read_margin_trades(venue(Vec::new()), MyTradesFrom::Time(now - 72 * HOUR_MS)).await;
5481
5482 assert!(ids.is_empty());
5483 assert_eq!(requests.len(), 4);
5485 }
5486
5487 #[tokio::test]
5490 async fn margin_trades_read_a_short_gap_in_one_request() {
5491 let now = Utc::now().timestamp_millis();
5492 let (ids, requests) = read_margin_trades(
5493 venue(vec![(9, now - HOUR_MS)]),
5494 MyTradesFrom::Time(now - 2 * HOUR_MS),
5495 )
5496 .await;
5497
5498 assert_eq!(ids, vec![9]);
5499 assert_eq!(requests.len(), 1);
5500 }
5501
5502 #[tokio::test]
5504 async fn margin_trades_from_the_future_send_no_request() {
5505 let now = Utc::now().timestamp_millis();
5506 let (ids, requests) =
5507 read_margin_trades(venue(Vec::new()), MyTradesFrom::Time(now + HOUR_MS)).await;
5508
5509 assert!(ids.is_empty());
5510 assert!(requests.is_empty());
5511 }
5512
5513 #[tokio::test]
5516 async fn margin_trades_by_order_page_by_id_without_time_bounds() {
5517 let now = Utc::now().timestamp_millis();
5518 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
5519 let trades = (1..=2 * count)
5521 .map(|id| {
5522 (
5523 id,
5524 now - 48 * HOUR_MS + id,
5525 if id % 2 == 0 { 42 } else { 7 },
5526 )
5527 })
5528 .collect();
5529 let venue = MarginMyTradesVenue {
5530 trades,
5531 end_exclusive: false,
5532 };
5533 let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
5534
5535 assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
5536 assert_eq!(requests.len(), 2);
5537 assert!(
5538 requests.iter().all(
5539 |query| query.get("orderId").map(String::as_str) == Some("42")
5540 && !query.contains_key("startTime")
5541 && !query.contains_key("endTime")
5542 )
5543 );
5544 }
5545
5546 async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
5549 let server = wiremock::MockServer::start().await;
5550 wiremock::Mock::given(wiremock::matchers::method("GET"))
5551 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5552 .respond_with(response)
5553 .mount(&server)
5554 .await;
5555 let rest = Arc::new(MarginTradingRestApi::from_config(
5556 ConfigurationRestApi::builder()
5557 .api_key("key")
5558 .api_secret("secret")
5559 .base_path(server.uri())
5560 .build()
5561 .unwrap(),
5562 ));
5563
5564 paginate_margin_my_trades(
5565 &rest,
5566 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5567 &InstrumentNameExchange::new("BTCUSDT"),
5568 MyTradesFrom::Time(Utc::now().timestamp_millis() - HOUR_MS),
5569 true,
5570 RequestKind::Query,
5571 )
5572 .await
5573 .unwrap_err()
5574 }
5575
5576 #[tokio::test]
5579 async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
5580 let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
5581 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
5582 ))
5583 .await;
5584
5585 let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
5586 panic!("expected RequestRejected, got {err:?}");
5587 };
5588 assert!(msg.contains("-1127"), "code missing from {msg:?}");
5589 assert!(!err.is_transient());
5590 }
5591
5592 #[tokio::test]
5594 async fn margin_trades_report_an_undecodable_response_as_internal() {
5595 let err = margin_trades_error(
5596 wiremock::ResponseTemplate::new(200)
5597 .set_body_json(serde_json::json!({"unexpected": 1})),
5598 )
5599 .await;
5600
5601 assert!(
5602 matches!(err, UnindexedClientError::Internal(_)),
5603 "got {err:?}"
5604 );
5605 assert!(!err.is_transient());
5606 }
5607
5608 #[tokio::test]
5611 async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
5612 for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
5613 let server = wiremock::MockServer::start().await;
5614 wiremock::Mock::given(wiremock::matchers::method("GET"))
5615 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
5616 .respond_with(
5617 wiremock::ResponseTemplate::new(200)
5618 .insert_header("x-sapi-used-ip-weight-1m", used)
5619 .set_body_json(serde_json::json!([])),
5620 )
5621 .mount(&server)
5622 .await;
5623 let rest = Arc::new(MarginTradingRestApi::from_config(
5624 ConfigurationRestApi::builder()
5625 .api_key("key")
5626 .api_secret("secret")
5627 .base_path(server.uri())
5628 .build()
5629 .unwrap(),
5630 ));
5631 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5632
5633 fetch_margin_all_open_orders(rest, Arc::clone(&tracker), false)
5634 .await
5635 .unwrap();
5636
5637 let query_waits = tokio::time::timeout(
5638 Duration::from_millis(50),
5639 tracker.wait_if_blocked(RequestKind::Query),
5640 )
5641 .await
5642 .is_err();
5643 assert_eq!(query_waits, pauses, "used IP weight {used}");
5644 tokio::time::timeout(
5645 Duration::from_millis(50),
5646 tracker.wait_if_blocked(RequestKind::Order),
5647 )
5648 .await
5649 .expect("orders never wait on the pause");
5650 }
5651 }
5652
5653 #[tokio::test]
5655 async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
5656 let server = wiremock::MockServer::start().await;
5657 wiremock::Mock::given(wiremock::matchers::method("GET"))
5658 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5659 .respond_with(
5661 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
5662 "symbol": "BTCUSDT",
5663 "id": 1,
5664 "orderId": 7,
5665 "price": "100",
5666 "qty": "1",
5667 "commission": "0",
5668 "commissionAsset": "USDT",
5669 "time": Utc::now().timestamp_millis(),
5670 "isBuyer": true,
5671 "isMaker": false,
5672 }])),
5673 )
5674 .expect(2)
5675 .mount(&server)
5676 .await;
5677 let rest = Arc::new(MarginTradingRestApi::from_config(
5678 ConfigurationRestApi::builder()
5679 .api_key("key")
5680 .api_secret("secret")
5681 .base_path(server.uri())
5682 .build()
5683 .unwrap(),
5684 ));
5685 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5686 tracker.throttle(Duration::from_secs(60));
5687 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5688 let mut unrecovered = UnrecoveredFills::default();
5689 unrecovered.open(
5690 &[InstrumentNameExchange::new("BTCUSDT")],
5691 Utc::now() - chrono::Duration::minutes(1),
5692 Utc::now(),
5693 );
5694
5695 tokio::time::timeout(
5697 Duration::from_millis(800),
5698 recover_margin_fills(
5699 &rest,
5700 &tracker,
5701 &mut unrecovered,
5702 &tx,
5703 &new_dedup_cache(),
5704 false,
5705 ),
5706 )
5707 .await
5708 .expect("recovery does not wait for the pause");
5709 }
5710
5711 #[tokio::test]
5714 async fn margin_trades_read_over_a_span_stop_at_its_end() {
5715 let now = Utc::now().timestamp_millis();
5716 let trades = vec![
5717 (1, now - 50 * HOUR_MS),
5718 (2, now - 49 * HOUR_MS),
5719 (3, now - 10 * HOUR_MS),
5720 ];
5721 let (ids, _) = read_margin_trades(
5722 venue(trades),
5723 MyTradesFrom::Span {
5724 start: now - 60 * HOUR_MS,
5725 end: now - 20 * HOUR_MS,
5726 },
5727 )
5728 .await;
5729
5730 assert_eq!(ids, vec![1, 2]);
5731 }
5732
5733 #[tokio::test]
5736 async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
5737 let disconnect = Utc::now() - chrono::Duration::minutes(10);
5738 let reconnect = Utc::now() - chrono::Duration::minutes(5);
5739 let server = wiremock::MockServer::start().await;
5740 wiremock::Mock::given(wiremock::matchers::method("GET"))
5741 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5742 .respond_with(
5743 wiremock::ResponseTemplate::new(400)
5744 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
5745 )
5746 .up_to_n_times(1)
5747 .with_priority(1)
5748 .mount(&server)
5749 .await;
5750 wiremock::Mock::given(wiremock::matchers::method("GET"))
5751 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5752 .respond_with(venue(vec![
5753 (
5754 1,
5755 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
5756 ),
5757 (
5758 2,
5759 (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
5760 ),
5761 ]))
5762 .mount(&server)
5763 .await;
5764 let rest = Arc::new(MarginTradingRestApi::from_config(
5765 ConfigurationRestApi::builder()
5766 .api_key("key")
5767 .api_secret("secret")
5768 .base_path(server.uri())
5769 .build()
5770 .unwrap(),
5771 ));
5772 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
5773 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
5774 let dedup = new_dedup_cache();
5775 let mut unrecovered = UnrecoveredFills::default();
5776 unrecovered.open(
5777 &[InstrumentNameExchange::new("BTCUSDT")],
5778 disconnect,
5779 reconnect,
5780 );
5781
5782 recover_margin_fills(&rest, &tracker, &mut unrecovered, &tx, &dedup, false).await;
5783 assert!(!unrecovered.is_empty(), "the failed gap is kept");
5784 assert!(rx.try_recv().is_err(), "nothing forwarded");
5785
5786 unrecovered.make_due();
5787 recover_margin_fills(&rest, &tracker, &mut unrecovered, &tx, &dedup, false).await;
5788 assert!(unrecovered.is_empty(), "the retry recovered it");
5789 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
5790 assert_eq!(
5791 forwarded.len(),
5792 1,
5793 "only the fill inside the gap: {forwarded:?}"
5794 );
5795 }
5796
5797 #[tokio::test]
5800 async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
5801 let server = wiremock::MockServer::start().await;
5802 wiremock::Mock::given(wiremock::matchers::method("GET"))
5803 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
5804 .respond_with(
5805 wiremock::ResponseTemplate::new(200)
5806 .set_body_json(serde_json::json!([]))
5807 .set_delay(Duration::from_secs(3_600)),
5808 )
5809 .mount(&server)
5810 .await;
5811 let rest = Arc::new(MarginTradingRestApi::from_config(
5812 ConfigurationRestApi::builder()
5813 .api_key("key")
5814 .api_secret("secret")
5815 .base_path(server.uri())
5816 .timeout(3_600_000_u64)
5817 .retries(0_u32)
5818 .build()
5819 .unwrap(),
5820 ));
5821 let instruments: Vec<_> = (0..9)
5822 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
5823 .collect();
5824 let mut unrecovered = UnrecoveredFills::default();
5825 unrecovered.open(
5826 &instruments,
5827 Utc::now() - chrono::Duration::minutes(10),
5828 Utc::now(),
5829 );
5830 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
5831 tokio::time::pause();
5833
5834 recover_margin_fills(
5835 &rest,
5836 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
5837 &mut unrecovered,
5838 &tx,
5839 &new_dedup_cache(),
5840 false,
5841 )
5842 .await;
5843
5844 let now = tokio::time::Instant::now();
5845 assert_eq!(
5846 unrecovered.due(now).len(),
5847 1,
5848 "the gap never started stays due"
5849 );
5850 assert_eq!(
5851 unrecovered
5852 .due(now + Duration::from_secs(crate::client::binance::shared::GAP_RETRY_BASE_SECS))
5853 .len(),
5854 9,
5855 "the eight started wait for their first retry"
5856 );
5857 }
5858}