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,
54 SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, classify_order_kind_tif,
55 classify_rest_order_error, connectivity_error, convert_execution_report,
56 convert_open_order_listing, convert_open_order_owned_symbol, dedup_key_from_event,
57 is_duplicate, new_dedup_cache, recovered_order_totals, rest_call_with_retry,
58};
59use crate::{
60 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, InstrumentBalanceUpdate,
61 IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent, UnindexedAccountSnapshot,
62 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
63 client::ExecutionClient,
64 emit_stream_terminated,
65 error::{
66 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
67 },
68 order::{
69 Order, OrderKey, OrderKind, TimeInForce,
70 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
71 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
72 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
73 },
74 trade::{AssetFees, Trade, TradeId},
75};
76use binance_sdk::{
77 common::{
78 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
79 constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
80 models::WebsocketEvent,
81 websocket::{
82 SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
83 WebsocketMessageSendOptions,
84 },
85 },
86 margin_trading::{
87 MarginTradingRestApi,
88 rest_api::{
89 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
90 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
91 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
92 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
93 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
94 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
95 QueryIsolatedMarginAccountInfoParams,
96 QueryIsolatedMarginAccountInfoResponseAssetsInner,
97 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
98 QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
99 QueryMarginAccountsTradeListResponseInner, RestApi,
100 },
101 websocket_streams::{
102 ExecutionReport, MarginLevelStatusChange, OutboundAccountPosition, UserLiabilityChange,
103 },
104 },
105};
106use chrono::{DateTime, TimeZone, Utc};
107use futures::stream::BoxStream;
108use rust_decimal::Decimal;
109use rustrade_instrument::{
110 Side,
111 asset::name::AssetNameExchange,
112 exchange::ExchangeId,
113 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
114};
115use serde::{Deserialize, Serialize};
116use smol_str::{SmolStr, format_smolstr};
117use std::{
118 collections::{BTreeMap, HashMap},
119 str::FromStr,
120 sync::{
121 Arc, Mutex,
122 atomic::{AtomicBool, Ordering},
123 },
124 time::Duration,
125};
126use tokio::sync::{mpsc, oneshot};
127use tracing::{debug, error, info, trace, warn};
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
147#[serde(rename_all = "snake_case")]
148pub enum MarginSideEffect {
149 #[default]
157 AutoBorrowRepay,
158 NoBorrow,
162}
163
164impl MarginSideEffect {
165 pub fn as_binance_str(self) -> &'static str {
167 match self {
168 MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
169 MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
170 }
171 }
172
173 fn as_sdk_enum(self) -> MarginAccountNewOrderSideEffectTypeEnum {
179 match self {
180 MarginSideEffect::AutoBorrowRepay => {
181 MarginAccountNewOrderSideEffectTypeEnum::AutoBorrowRepay
182 }
183 MarginSideEffect::NoBorrow => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
184 }
185 }
186}
187
188#[derive(Clone, Deserialize)]
198pub struct BinanceMarginConfig {
199 api_key: String,
202 secret_key: String,
203 pub testnet: bool,
209 #[serde(default)]
216 pub is_isolated: bool,
217 #[serde(default)]
228 pub isolated_symbols: Vec<InstrumentNameExchange>,
229 #[serde(default)]
233 pub side_effect: MarginSideEffect,
234}
235
236impl std::fmt::Debug for BinanceMarginConfig {
238 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239 f.debug_struct("BinanceMarginConfig")
240 .field("api_key", &"***")
241 .field("secret_key", &"***")
242 .field("testnet", &self.testnet)
243 .field("is_isolated", &self.is_isolated)
244 .field("isolated_symbols", &self.isolated_symbols)
245 .field("side_effect", &self.side_effect)
246 .finish()
247 }
248}
249
250impl BinanceMarginConfig {
251 pub fn new(
261 api_key: String,
262 secret_key: String,
263 testnet: bool,
264 is_isolated: bool,
265 isolated_symbols: Vec<InstrumentNameExchange>,
266 side_effect: MarginSideEffect,
267 ) -> Self {
268 Self {
269 api_key,
270 secret_key,
271 testnet,
272 is_isolated,
273 isolated_symbols,
274 side_effect,
275 }
276 }
277
278 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
285 Self::new(
286 api_key,
287 secret_key,
288 false,
289 false,
290 Vec::new(),
291 MarginSideEffect::default(),
292 )
293 }
294
295 pub fn isolated(
303 api_key: String,
304 secret_key: String,
305 symbols: Vec<InstrumentNameExchange>,
306 ) -> Self {
307 Self::new(
308 api_key,
309 secret_key,
310 false,
311 true,
312 symbols,
313 MarginSideEffect::default(),
314 )
315 }
316
317 pub fn api_key(&self) -> &str {
319 &self.api_key
320 }
321}
322
323#[derive(Clone)]
382pub struct BinanceMargin {
383 config: Arc<BinanceMarginConfig>,
384 rest: Arc<RestApi>,
386 rest_config: Arc<ConfigurationRestApi>,
390 ws_config: ConfigurationWebsocketApi,
394 rate_limiter: Arc<RateLimitTracker>,
396}
397
398impl std::fmt::Debug for BinanceMargin {
399 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
400 f.debug_struct("BinanceMargin")
401 .field("testnet", &self.config.testnet)
402 .field("is_isolated", &self.config.is_isolated)
403 .field("side_effect", &self.config.side_effect)
404 .finish_non_exhaustive()
405 }
406}
407
408impl BinanceMargin {
409 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
419 ConfigurationRestApi::builder()
420 .api_key(config.api_key.clone())
421 .api_secret(config.secret_key.clone())
422 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
423 .build()
424 .expect("failed to build Binance margin REST configuration")
425 }
426
427 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
437 ConfigurationWebsocketApi::builder()
438 .api_key(config.api_key.clone())
439 .api_secret(config.secret_key.clone())
440 .ws_url(SPOT_WS_API_PROD_URL)
444 .build()
445 .expect("failed to build Binance margin WebSocket configuration")
446 }
447
448 fn effective_isolated_set(
461 &self,
462 instruments: &[InstrumentNameExchange],
463 ) -> Vec<InstrumentNameExchange> {
464 if instruments.is_empty() {
465 return self.config.isolated_symbols.clone();
466 }
467 instruments
468 .iter()
469 .filter(|inst| {
470 let in_set = self.config.isolated_symbols.contains(*inst);
471 if !in_set {
472 warn!(
473 instrument = %inst.name(),
474 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
475 );
476 }
477 in_set
478 })
479 .cloned()
480 .collect()
481 }
482
483 async fn isolated_account_snapshot(
487 &self,
488 instruments: &[InstrumentNameExchange],
489 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
490 use futures::{StreamExt as _, TryStreamExt as _};
491
492 let effective = self.effective_isolated_set(instruments);
493 if effective.is_empty() {
494 return Ok(AccountSnapshot::new(
497 ExchangeId::BinanceMargin,
498 Vec::new(),
499 Vec::new(),
500 ));
501 }
502
503 let mut balances_by_symbol = convert_isolated_margin_assets(
505 fetch_isolated_margin_account_info(
506 self.rest.clone(),
507 self.rate_limiter.clone(),
508 effective.clone(),
509 )
510 .await?,
511 );
512
513 let instrument_snapshots: Vec<_> =
516 futures::stream::iter(effective.into_iter().map(|instrument| {
517 fetch_margin_open_orders_for_instrument(
518 self.rest.clone(),
519 self.rate_limiter.clone(),
520 instrument,
521 true,
522 )
523 }))
524 .buffer_unordered(8)
525 .map(|result| {
526 let (inst, listing) = result?;
527 let wrapped = listing
528 .orders
529 .into_iter()
530 .map(active_order_snapshot)
531 .collect();
532 let isolated = balances_by_symbol.remove(&inst);
533 if isolated.is_none() {
534 warn!(
538 instrument = %inst.name(),
539 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
540 );
541 }
542 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
543 inst,
544 wrapped,
545 listing.complete,
546 None,
547 isolated,
548 ))
549 })
550 .try_collect()
551 .await?;
552
553 Ok(AccountSnapshot::new(
556 ExchangeId::BinanceMargin,
557 Vec::new(),
558 instrument_snapshots,
559 ))
560 }
561}
562
563impl ExecutionClient for BinanceMargin {
564 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
565
566 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
569 &[InstrumentKindDiscriminant::Spot];
570 type Config = BinanceMarginConfig;
571 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
572
573 fn new(config: Self::Config) -> Self {
586 if config.testnet {
587 warn!(
588 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
589 using production endpoints"
590 );
591 }
592 assert!(
593 !(config.is_isolated && config.isolated_symbols.is_empty()),
594 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
595 );
596 let rest_config = Self::build_rest_config(&config);
601 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
602 let rest_config = Arc::new(rest_config);
603 let ws_config = Self::build_ws_config(&config);
604 Self {
605 config: Arc::new(config),
606 rest,
607 rest_config,
608 ws_config,
609 rate_limiter: Arc::new(RateLimitTracker::new()),
610 }
611 }
612
613 async fn open_order(
627 &self,
628 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
629 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
630 let instrument = request.key.instrument.clone();
631 let side = request.state.side;
632 let price = request.state.price;
633 let quantity = request.state.quantity;
634 let kind = request.state.kind;
635 let time_in_force = request.state.time_in_force;
636 let cid = request.key.cid.clone();
637
638 let order_key = OrderKey::new(
639 ExchangeId::BinanceMargin,
640 instrument.clone(),
641 request.key.strategy.clone(),
642 cid.clone(),
643 );
644
645 let inactive = |state: UnindexedOrderState| {
648 Some(Order {
649 key: order_key.clone(),
650 side,
651 price,
652 quantity,
653 kind,
654 time_in_force,
655 state,
656 })
657 };
658
659 let params = match build_new_order_params(
660 instrument.name().to_string(),
661 side,
662 price,
663 quantity,
664 kind,
665 time_in_force,
666 cid.0.to_string(),
667 self.config.side_effect,
668 self.config.is_isolated,
669 ) {
670 Ok(params) => params,
671 Err(BuildOrderError::Unsupported) => {
672 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
673 format!(
674 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
675 ),
676 )));
677 }
678 Err(BuildOrderError::Build(msg)) => {
679 error!(%msg, "BinanceMargin failed to build new order params");
680 return inactive(OrderState::inactive(OrderError::Rejected(
681 ApiError::OrderRejected(msg),
682 )));
683 }
684 };
685
686 let response = match rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
687 let params = params.clone();
688 Box::pin(async move { rest.margin_account_new_order(params).await })
689 })
690 .await
691 {
692 Ok(response) => response,
693 Err(e) => {
694 return inactive(OrderState::inactive(classify_rest_order_error(
695 &e,
696 &instrument,
697 )));
698 }
699 };
700
701 let data = match response.data().await {
702 Ok(data) => data,
703 Err(e) => {
704 return inactive(OrderState::inactive(OrderError::Rejected(
707 ApiError::OrderRejected(e.to_string()),
708 )));
709 }
710 };
711
712 let time_exchange = data
713 .transact_time
714 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
715 .unwrap_or_else(Utc::now);
716
717 let exchange_order_id = match data.order_id {
718 Some(id) => OrderId(format_smolstr!("{id}")),
719 None => {
720 error!("BinanceMargin open_order response missing orderId");
721 return inactive(OrderState::inactive(OrderError::Rejected(
722 ApiError::OrderRejected("open_order response missing orderId".into()),
723 )));
724 }
725 };
726
727 let filled_qty = match data.executed_qty.as_deref() {
728 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
729 warn!(
733 executed_qty = q,
734 "BinanceMargin: failed to parse executedQty; treating as zero"
735 );
736 Decimal::ZERO
737 }),
738 None => {
739 warn!("BinanceMargin: executedQty missing in response; treating as zero");
742 Decimal::ZERO
743 }
744 };
745
746 let state = if filled_qty >= quantity {
747 let avg_price = margin_avg_price(data.cummulative_quote_qty.as_deref(), filled_qty);
750 OrderState::fully_filled(Filled::new(
751 exchange_order_id,
752 time_exchange,
753 filled_qty,
754 avg_price,
755 ))
756 } else {
757 OrderState::active(Open::new(
758 VenueOrderId::Assigned(exchange_order_id),
759 time_exchange,
760 filled_qty,
761 ))
762 };
763
764 Some(Order {
765 key: order_key,
766 side,
767 price,
768 quantity,
769 kind,
770 time_in_force,
771 state,
772 })
773 }
774
775 async fn cancel_order(
785 &self,
786 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
787 ) -> Option<UnindexedOrderResponseCancel> {
788 let instrument = request.key.instrument.clone();
789 let key = OrderKey {
790 exchange: request.key.exchange,
791 instrument: instrument.clone(),
792 strategy: request.key.strategy.clone(),
793 cid: request.key.cid.clone(),
794 };
795
796 let params = match build_cancel_order_params(
797 instrument.name().to_string(),
798 request.state.id.as_ref().and_then(VenueOrderId::assigned),
799 &request.key.cid,
800 self.config.is_isolated,
801 ) {
802 Ok(p) => p,
803 Err(e) => {
804 error!(%e, "BinanceMargin failed to build cancel order params");
805 return Some(UnindexedOrderResponseCancel {
806 key,
807 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
808 });
809 }
810 };
811
812 let response = match rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
813 let params = params.clone();
814 Box::pin(async move { rest.margin_account_cancel_order(params).await })
815 })
816 .await
817 {
818 Ok(response) => response,
819 Err(e) => {
820 return Some(UnindexedOrderResponseCancel {
821 key,
822 state: Err(classify_rest_order_error(&e, &instrument)),
823 });
824 }
825 };
826
827 let data = match response.data().await {
828 Ok(data) => data,
829 Err(e) => {
830 return Some(UnindexedOrderResponseCancel {
833 key,
834 state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
835 });
836 }
837 };
838
839 let time_exchange = Utc::now();
841
842 let exchange_order_id = match data.order_id {
843 Some(id) => OrderId(SmolStr::new(id)),
846 None => {
847 error!("BinanceMargin cancel response missing orderId");
848 return Some(UnindexedOrderResponseCancel {
849 key,
850 state: Err(OrderError::Rejected(ApiError::OrderRejected(
851 "cancel response missing orderId".into(),
852 ))),
853 });
854 }
855 };
856
857 let filled_qty = match data.executed_qty.as_deref() {
858 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
859 warn!(
863 executed_qty = q,
864 "BinanceMargin: failed to parse executedQty; treating as zero"
865 );
866 Decimal::ZERO
867 }),
868 None => {
869 warn!("BinanceMargin: executedQty missing in response; treating as zero");
872 Decimal::ZERO
873 }
874 };
875
876 Some(UnindexedOrderResponseCancel {
877 key,
878 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
879 })
880 }
881
882 async fn account_snapshot(
897 &self,
898 assets: &[AssetNameExchange],
899 instruments: &[InstrumentNameExchange],
900 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
901 if self.config.is_isolated {
902 return self.isolated_account_snapshot(instruments).await;
903 }
904 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
905 Box::pin(async move {
906 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
907 rest.query_cross_margin_account_details(params).await
908 })
909 })
910 .await
911 .map_err(connectivity_error)?;
912
913 let account = response
914 .data()
915 .await
916 .map_err(|e| connectivity_error(e.into()))?;
917
918 let balances =
919 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
920
921 use futures::{StreamExt as _, TryStreamExt as _};
926 let instrument_snapshots: Vec<_> =
927 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
928 fetch_margin_open_orders_for_instrument(
929 self.rest.clone(),
930 self.rate_limiter.clone(),
931 instrument,
932 false,
934 )
935 }))
936 .buffer_unordered(8)
937 .map(|result| {
938 let (inst, listing) = result?;
939 let wrapped = listing
940 .orders
941 .into_iter()
942 .map(active_order_snapshot)
943 .collect();
944 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
945 inst,
946 wrapped,
947 listing.complete,
948 None,
949 None,
950 ))
951 })
952 .try_collect()
953 .await?;
954
955 Ok(AccountSnapshot::new(
956 ExchangeId::BinanceMargin,
957 balances,
958 instrument_snapshots,
959 ))
960 }
961
962 async fn fetch_balances(
972 &self,
973 assets: &[AssetNameExchange],
974 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
975 if self.config.is_isolated {
976 return Ok(Vec::new());
977 }
978 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
979 Box::pin(async move {
980 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
981 rest.query_cross_margin_account_details(params).await
982 })
983 })
984 .await
985 .map_err(connectivity_error)?;
986
987 let account = response
988 .data()
989 .await
990 .map_err(|e| connectivity_error(e.into()))?;
991
992 Ok(filter_and_convert_margin_balances(
993 account.user_assets.unwrap_or_default(),
994 assets,
995 ))
996 }
997
998 async fn fetch_open_orders(
1009 &self,
1010 instruments: &[InstrumentNameExchange],
1011 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1012 use futures::{StreamExt as _, TryStreamExt as _};
1013
1014 if self.config.is_isolated {
1018 let effective = self.effective_isolated_set(instruments);
1019 let capacity = effective.len();
1020 return futures::stream::iter(effective.into_iter().map(|instrument| {
1021 fetch_margin_open_orders_for_instrument(
1022 self.rest.clone(),
1023 self.rate_limiter.clone(),
1024 instrument,
1025 true,
1026 )
1027 }))
1028 .buffer_unordered(8)
1029 .try_fold(
1030 Vec::with_capacity(capacity),
1031 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1032 (_, listing)| async move {
1033 acc.extend(listing.orders);
1034 Ok(acc)
1035 },
1036 )
1037 .await;
1038 }
1039
1040 if instruments.is_empty() {
1043 return fetch_margin_all_open_orders(
1044 self.rest.clone(),
1045 self.rate_limiter.clone(),
1046 false,
1047 )
1048 .await;
1049 }
1050 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1051 fetch_margin_open_orders_for_instrument(
1052 self.rest.clone(),
1053 self.rate_limiter.clone(),
1054 instrument,
1055 false,
1056 )
1057 }))
1058 .buffer_unordered(8)
1059 .try_fold(
1060 Vec::with_capacity(instruments.len()),
1061 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1062 acc.extend(listing.orders);
1063 Ok(acc)
1064 },
1065 )
1066 .await
1067 }
1068
1069 async fn fetch_trades(
1082 &self,
1083 time_since: DateTime<Utc>,
1084 instruments: &[InstrumentNameExchange],
1085 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1086 use futures::StreamExt as _;
1087
1088 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1091 self.effective_isolated_set(instruments)
1092 } else {
1093 instruments.to_vec()
1094 };
1095 if effective.is_empty() {
1096 debug!(
1099 is_isolated = self.config.is_isolated,
1100 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1101 );
1102 return Ok(Vec::new());
1103 }
1104 let start_time_ms = time_since.timestamp_millis();
1105 let is_isolated = self.config.is_isolated;
1106 let mut all_trades = Vec::new();
1107
1108 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1111 let rest = self.rest.clone();
1112 let rate_limiter = self.rate_limiter.clone();
1113 async move {
1114 let pages = paginate_margin_my_trades(
1115 &rest,
1116 &rate_limiter,
1117 &inst,
1118 MyTradesFrom::Time(start_time_ms),
1119 is_isolated,
1120 )
1121 .await?;
1122 Ok::<_, UnindexedClientError>((inst, pages))
1123 }
1124 }))
1125 .buffer_unordered(8);
1126 while let Some(result) = stream.next().await {
1127 let (instrument, trades_data) = result?;
1128 for t in trades_data {
1129 if let Some(trade) = convert_margin_trade(&t, &instrument) {
1130 all_trades.push(trade);
1131 }
1132 }
1133 }
1134
1135 Ok(all_trades)
1136 }
1137
1138 async fn account_stream(
1208 &self,
1209 _assets: &[AssetNameExchange],
1212 instruments: &[InstrumentNameExchange],
1213 ) -> Result<Self::AccountStream, UnindexedClientError> {
1214 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1215 let dedup = new_dedup_cache();
1216 let rest = self.rest.clone();
1217 let rest_config = self.rest_config.clone();
1218 let ws_config = self.ws_config.clone();
1219 let rate_limiter = self.rate_limiter.clone();
1220
1221 let cm_handle = if self.config.is_isolated {
1222 let symbols = self.config.isolated_symbols.clone();
1226 debug_assert!(
1229 symbols.iter().all(|i| i.name().len() <= 23),
1230 "instrument name exceeds SmolStr inline capacity: {:?}",
1231 symbols.iter().find(|i| i.name().len() > 23)
1232 );
1233
1234 let assets = fetch_isolated_margin_account_info(
1240 rest.clone(),
1241 rate_limiter.clone(),
1242 symbols.clone(),
1243 )
1244 .await?;
1245 let base_quote = Arc::new(build_base_quote_map(&assets));
1246 for sym in &symbols {
1247 if !base_quote.contains_key(sym) {
1248 warn!(
1249 symbol = %sym.name(),
1250 "BinanceMargin isolated: account info returned no base/quote for configured \
1251 symbol — its live per-pair balance frames will be dropped (fills/orders \
1252 unaffected; balances available via account_snapshot)"
1253 );
1254 }
1255 }
1256
1257 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1261 let live =
1262 isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
1263 .await
1264 .map_err(|e| {
1265 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1266 })?;
1267
1268 tokio::spawn(isolated_connection_manager(
1269 tx,
1270 dedup,
1271 ws_config,
1272 rest_config,
1273 rest,
1274 rate_limiter,
1275 symbols,
1276 base_quote,
1277 Some(live),
1278 ))
1279 } else {
1280 let instruments = instruments.to_vec();
1282 debug_assert!(
1286 instruments.iter().all(|i| i.name().len() <= 23),
1287 "instrument name exceeds SmolStr inline capacity: {:?}",
1288 instruments.iter().find(|i| i.name().len() > 23)
1289 );
1290
1291 let initial_token =
1297 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1298 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1299 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1300 })?;
1301
1302 tokio::spawn(margin_connection_manager(
1303 tx,
1304 dedup,
1305 ws_config,
1306 rest_config,
1307 rest,
1308 rate_limiter,
1309 instruments,
1310 Some((initial_ws, initial_token)),
1311 ))
1312 };
1313
1314 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1315 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1316 Ok(futures::StreamExt::boxed(guarded_stream))
1317 }
1318}
1319
1320const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1328
1329struct UserListenToken {
1331 token: String,
1332 expiration_time_ms: i64,
1333}
1334
1335fn build_listen_token_query(
1339 symbol: Option<&InstrumentNameExchange>,
1340) -> BTreeMap<String, serde_json::Value> {
1341 let mut query = BTreeMap::new();
1342 if let Some(sym) = symbol {
1343 query.insert(
1344 "isIsolated".to_string(),
1345 serde_json::Value::String("TRUE".to_string()),
1346 );
1347 query.insert(
1348 "symbol".to_string(),
1349 serde_json::Value::String(sym.name().to_string()),
1350 );
1351 }
1352 query
1353}
1354
1355#[derive(Deserialize)]
1357struct UserListenTokenResponse {
1358 #[serde(alias = "listenToken")]
1360 token: String,
1361 #[serde(rename = "expirationTime")]
1362 expiration_time: i64,
1363}
1364
1365async fn acquire_user_listen_token(
1379 rest_config: &Arc<ConfigurationRestApi>,
1380 rate_limiter: &RateLimitTracker,
1381 symbol: Option<&InstrumentNameExchange>,
1382) -> Result<UserListenToken, UnindexedClientError> {
1383 let query = build_listen_token_query(symbol);
1386 let response = rest_call_with_retry(rest_config, rate_limiter, |cfg| {
1387 let query = query.clone();
1388 Box::pin(async move {
1389 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1390 &cfg,
1391 "/sapi/v1/userListenToken",
1392 reqwest::Method::POST,
1393 query,
1396 BTreeMap::new(),
1397 None,
1398 true, )
1400 .await
1401 })
1402 })
1403 .await
1404 .map_err(connectivity_error)?;
1405
1406 let data = response
1407 .data()
1408 .await
1409 .map_err(|e| connectivity_error(e.into()))?;
1410 Ok(UserListenToken {
1411 token: data.token,
1412 expiration_time_ms: data.expiration_time,
1413 })
1414}
1415
1416fn token_renew_after(expiration_time_ms: i64) -> Duration {
1419 let now_ms = Utc::now().timestamp_millis();
1420 let remaining_secs = expiration_time_ms
1424 .saturating_sub(now_ms)
1425 .saturating_div(1_000)
1426 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1427 let secs = u64::try_from(remaining_secs).unwrap_or(0);
1428 if secs < TOKEN_MIN_LIFETIME_SECS {
1431 warn!(
1432 computed_secs = secs,
1433 floor_secs = TOKEN_MIN_LIFETIME_SECS,
1434 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1435 );
1436 }
1437 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1438}
1439
1440async fn connect_margin_ws(
1447 ws_config: &ConfigurationWebsocketApi,
1448) -> anyhow::Result<Arc<WsApiBase>> {
1449 let ws = WsApiBase::new(ws_config.clone(), vec![]);
1450 tokio::time::timeout(
1453 Duration::from_secs(CONNECT_TIMEOUT_SECS),
1454 ws.clone().connect(),
1455 )
1456 .await
1457 .map_err(|_| {
1458 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1459 })??;
1460 Ok(ws)
1461}
1462
1463async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1470 let mut payload = BTreeMap::new();
1473 payload.insert(
1474 "listenToken".to_string(),
1475 serde_json::Value::String(token.to_string()),
1476 );
1477 ws.send_message::<serde_json::Value>(
1478 "userDataStream.subscribe.listenToken",
1479 payload,
1480 WebsocketMessageSendOptions::new(),
1481 )
1482 .await
1483 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1484 Ok(())
1485}
1486
1487async fn subscribe_listen_token_capture(
1500 ws: &Arc<WsApiBase>,
1501 token: &str,
1502) -> anyhow::Result<Option<i64>> {
1503 #[derive(Deserialize)]
1506 struct SubscribeAck {
1507 #[serde(rename = "subscriptionId", default)]
1508 subscription_id: Option<i64>,
1509 }
1510
1511 let mut payload = BTreeMap::new();
1512 payload.insert(
1513 "listenToken".to_string(),
1514 serde_json::Value::String(token.to_string()),
1515 );
1516 let result = ws
1517 .send_message::<SubscribeAck>(
1518 "userDataStream.subscribe.listenToken",
1519 payload,
1520 WebsocketMessageSendOptions::new(),
1521 )
1522 .await
1523 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1524 let ack = match result {
1525 SendWebsocketMessageResult::Single(resp) => resp.data()?,
1526 SendWebsocketMessageResult::Multiple(responses) => responses
1528 .into_iter()
1529 .next()
1530 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
1531 .data()?,
1532 };
1533 Ok(ack.subscription_id)
1534}
1535
1536fn cross_account_position_handler(
1540 position: OutboundAccountPosition,
1541 _subscription_id: Option<i64>,
1542 buf: &mut Vec<UnindexedAccountEvent>,
1543) {
1544 convert_margin_account_position(position, buf);
1545}
1546
1547#[cfg(test)]
1552fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1553 convert_margin_user_data_events_with(frame, buf, &mut cross_account_position_handler)
1554}
1555
1556fn convert_margin_user_data_events_with(
1570 frame: &str,
1571 buf: &mut Vec<UnindexedAccountEvent>,
1572 handle_position: &mut impl FnMut(
1573 OutboundAccountPosition,
1574 Option<i64>,
1575 &mut Vec<UnindexedAccountEvent>,
1576 ),
1577) -> bool {
1578 use serde_json::value::RawValue;
1579
1580 #[derive(Deserialize)]
1586 struct Envelope<'a> {
1587 #[serde(borrow, default)]
1588 id: Option<&'a RawValue>,
1589 #[serde(borrow, default)]
1590 event: Option<&'a RawValue>,
1591 #[serde(rename = "subscriptionId", default)]
1592 subscription_id: Option<i64>,
1593 }
1594 #[derive(Deserialize)]
1596 struct EventTag<'a> {
1597 #[serde(borrow, default)]
1598 e: Option<&'a str>,
1599 }
1600
1601 let envelope = match serde_json::from_str::<Envelope<'_>>(frame) {
1602 Ok(env) => env,
1603 Err(e) => {
1604 trace!(error = %e, "BinanceMargin WS: skipped unparseable frame");
1605 return false;
1606 }
1607 };
1608 if envelope.id.is_some() {
1610 return false;
1611 }
1612 let Some(event) = envelope.event else {
1614 trace!("BinanceMargin WS: ignoring frame without `event` or `id`");
1615 return false;
1616 };
1617 let subscription_id = envelope.subscription_id;
1618 let event_raw = event.get();
1619 let event_type = serde_json::from_str::<EventTag<'_>>(event_raw)
1627 .ok()
1628 .and_then(|tag| tag.e)
1629 .unwrap_or_default();
1630 match event_type {
1631 "executionReport" => {
1632 match serde_json::from_str::<ExecutionReport>(event_raw) {
1635 Ok(report) => convert_execution_report(&report, ExchangeId::BinanceMargin, buf),
1636 Err(e) => {
1637 warn!(error = %e, "BinanceMargin: undeserializable executionReport, dropping")
1638 }
1639 }
1640 false
1641 }
1642 "outboundAccountPosition" => {
1643 match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
1644 Ok(position) => handle_position(position, subscription_id, buf),
1646 Err(e) => {
1647 warn!(error = %e, "BinanceMargin: undeserializable outboundAccountPosition, dropping")
1648 }
1649 }
1650 false
1651 }
1652 "balanceUpdate" => {
1653 false
1657 }
1658 "userLiabilityChange" => {
1659 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
1666 Ok(c) => {
1667 if tracing::enabled!(tracing::Level::INFO) {
1668 info!(
1669 asset = c.a.as_deref().unwrap_or("?"),
1670 kind = c.t.as_deref().unwrap_or("?"),
1671 principal = c.p.as_deref().unwrap_or("?"),
1672 interest = c.i.as_deref().unwrap_or("?"),
1673 "BinanceMargin userLiabilityChange (observable; not applied to balance state)"
1674 );
1675 }
1676 }
1677 Err(e) => warn!(
1678 error = %e,
1679 "BinanceMargin: undeserializable userLiabilityChange, dropping"
1680 ),
1681 }
1682 false
1683 }
1684 "marginLevelStatusChange" => {
1685 if tracing::enabled!(tracing::Level::WARN) {
1690 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
1691 Ok(c) => warn!(
1692 margin_level = c.l.as_deref().unwrap_or("?"),
1693 status = c.s.as_deref().unwrap_or("?"),
1694 "BinanceMargin marginLevelStatusChange (liquidation risk; observable, no policy)"
1695 ),
1696 Err(e) => warn!(
1697 error = %e,
1698 "BinanceMargin: undeserializable marginLevelStatusChange, dropping"
1699 ),
1700 }
1701 }
1702 false
1703 }
1704 "eventStreamTerminated" => {
1705 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
1706 true
1707 }
1708 other => {
1709 trace!(
1710 event_type = other,
1711 "BinanceMargin ignoring unhandled user data event"
1712 );
1713 false
1714 }
1715 }
1716}
1717
1718fn convert_margin_account_position(
1723 position: OutboundAccountPosition,
1724 buf: &mut Vec<UnindexedAccountEvent>,
1725) {
1726 let time_exchange = position
1727 .u
1728 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1729 .unwrap_or_else(Utc::now);
1730
1731 for b in position.b_uppercase.unwrap_or_default() {
1732 let asset = match b.a {
1733 Some(a) => AssetNameExchange::new(a),
1734 None => {
1735 warn!("BinanceMargin account position entry missing asset name");
1736 continue;
1737 }
1738 };
1739 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1740 Some(v) => v,
1741 None => {
1742 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
1743 continue;
1744 }
1745 };
1746 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1747 Some(v) => v,
1748 None => {
1749 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
1750 continue;
1751 }
1752 };
1753 let update =
1754 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
1755 buf.push(UnindexedAccountEvent::new(
1756 ExchangeId::BinanceMargin,
1757 AccountEventKind::BalanceStreamUpdate(
1758 rustrade_integration::collection::snapshot::Snapshot::new(update),
1759 ),
1760 ));
1761 }
1762}
1763
1764fn route_isolated_account_position(
1775 position: OutboundAccountPosition,
1776 subscription_id: Option<i64>,
1777 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
1778 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
1779 buf: &mut Vec<UnindexedAccountEvent>,
1780) {
1781 let Some(sub_id) = subscription_id else {
1782 warn!(
1783 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
1784 (per-instrument balance routing requires it)"
1785 );
1786 return;
1787 };
1788 let instrument = {
1789 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
1792 match map.get(&sub_id) {
1793 Some(inst) => inst.clone(),
1794 None => {
1795 warn!(
1796 subscription_id = sub_id,
1797 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
1798 );
1799 return;
1800 }
1801 }
1802 };
1803 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
1804 warn!(
1805 instrument = %instrument.name(),
1806 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
1807 );
1808 return;
1809 };
1810
1811 let time_exchange = position
1812 .u
1813 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1814 .unwrap_or_else(Utc::now);
1815
1816 let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1817 let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
1818 for b in position.b_uppercase.unwrap_or_default() {
1819 let Some(asset) = b.a.as_deref() else {
1820 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
1821 continue;
1822 };
1823 let side = if asset == base_asset.name().as_str() {
1825 &mut base_update
1826 } else if asset == quote_asset.name().as_str() {
1827 &mut quote_update
1828 } else {
1829 continue;
1830 };
1831 let (Some(free), Some(locked)) = (
1832 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1833 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
1834 ) else {
1835 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
1836 continue;
1837 };
1838 *side = Some(AssetBalanceUpdate::new(
1839 AssetNameExchange::new(asset),
1840 BalanceUpdate::new(free, locked),
1841 time_exchange,
1842 ));
1843 }
1844
1845 let (Some(base), Some(quote)) = (base_update, quote_update) else {
1849 warn!(
1850 instrument = %instrument.name(),
1851 "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
1852 );
1853 return;
1854 };
1855 buf.push(UnindexedAccountEvent::new(
1856 ExchangeId::BinanceMargin,
1857 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
1858 instrument, base, quote,
1859 )),
1860 ));
1861}
1862
1863fn register_user_data_listener(
1874 ws: &Arc<WsApiBase>,
1875 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
1876 dedup: SharedDedupCache,
1877 heartbeat_flag: Arc<AtomicBool>,
1878 signal_tx: oneshot::Sender<()>,
1879 mut handle_position: impl FnMut(
1880 OutboundAccountPosition,
1881 Option<i64>,
1882 &mut Vec<UnindexedAccountEvent>,
1883 ) + Send
1884 + 'static,
1885) -> Subscription {
1886 let mut signal_tx_opt = Some(signal_tx);
1887 let mut event_tx = Some(tx);
1888 let mut event_buf = Vec::with_capacity(32);
1892
1893 ws.common.events.subscribe(move |event| {
1897 let Some(ref sender) = event_tx else { return };
1898 match event {
1899 WebsocketEvent::Message(json_str) => {
1900 heartbeat_flag.store(true, Ordering::Release);
1901 let terminated = convert_margin_user_data_events_with(
1904 &json_str,
1905 &mut event_buf,
1906 &mut handle_position,
1907 );
1908 for ev in event_buf.drain(..) {
1909 if let Some(key) = dedup_key_from_event(&ev)
1910 && is_duplicate(&dedup, key)
1911 {
1912 trace!("BinanceMargin dedup: skipping duplicate event");
1913 continue;
1914 }
1915 if sender.send(ev).is_err() {
1916 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
1917 event_tx.take();
1918 if let Some(s) = signal_tx_opt.take() {
1919 let _ = s.send(());
1920 }
1921 return;
1922 }
1923 }
1924 if terminated {
1925 event_tx.take();
1926 if let Some(s) = signal_tx_opt.take() {
1927 let _ = s.send(());
1928 }
1929 }
1930 }
1931 WebsocketEvent::Ping | WebsocketEvent::Pong => {
1932 heartbeat_flag.store(true, Ordering::Release);
1933 }
1934 WebsocketEvent::Error(e) => {
1935 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
1936 event_tx.take();
1937 if let Some(s) = signal_tx_opt.take() {
1938 let _ = s.send(());
1939 }
1940 }
1941 WebsocketEvent::Close(code, reason) => {
1942 warn!(code, %reason, "BinanceMargin WebSocket closed");
1943 event_tx.take();
1944 if let Some(s) = signal_tx_opt.take() {
1945 let _ = s.send(());
1946 }
1947 }
1948 _ => {
1949 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
1950 }
1951 }
1952 })
1953}
1954
1955async fn recover_margin_fills(
1964 rest: &Arc<RestApi>,
1965 rate_limiter: &Arc<RateLimitTracker>,
1966 instruments: &[InstrumentNameExchange],
1967 disconnect_time: DateTime<Utc>,
1968 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
1969 dedup: &SharedDedupCache,
1970 is_isolated: bool,
1971) {
1972 use futures::StreamExt as _;
1973
1974 if instruments.is_empty() {
1975 debug!("BinanceMargin recover_fills: empty instruments — no fills recovered");
1976 return;
1977 }
1978 info!(
1979 since = %disconnect_time,
1980 instruments = instruments.len(),
1981 "BinanceMargin recovering fills after reconnect"
1982 );
1983
1984 let start_time_ms = disconnect_time.timestamp_millis();
1985 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
1986 let mut recovered = 0u32;
1987 let mut duplicates = 0u32;
1988 let mut failed_instruments = 0u32;
1989
1990 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
1991 let rest = rest.clone();
1992 let rl = rate_limiter.clone();
1993 async move {
1994 let raw = match paginate_margin_my_trades(
1995 &rest,
1996 &rl,
1997 &inst,
1998 MyTradesFrom::Time(start_time_ms),
1999 is_isolated,
2000 )
2001 .await
2002 {
2003 Ok(pages) => pages,
2004 Err(e) => {
2005 warn!(%e, %inst, "BinanceMargin fill recovery: REST request failed");
2006 return None;
2007 }
2008 };
2009 let totals = recovered_order_totals(
2012 ExchangeId::BinanceMargin,
2013 &inst,
2014 &raw,
2015 order_executions_deadline,
2016 |order_id| {
2017 paginate_margin_my_trades(
2018 &rest,
2019 &rl,
2020 &inst,
2021 MyTradesFrom::Order(order_id),
2022 is_isolated,
2023 )
2024 },
2025 )
2026 .await;
2027 Some(
2028 raw.iter()
2029 .filter_map(|t| {
2030 let mut trade = convert_margin_trade(t, &inst)?;
2031 trade.order_filled_quantity = t.id.and_then(|id| totals.get(&id).copied());
2032 Some(trade)
2033 })
2034 .collect::<Vec<_>>(),
2035 )
2036 }
2037 }))
2038 .buffer_unordered(8);
2039 while let Some(result) = stream.next().await {
2040 let trades = match result {
2041 Some(t) => t,
2042 None => {
2043 failed_instruments += 1;
2044 continue;
2045 }
2046 };
2047 for trade in trades {
2048 let event = UnindexedAccountEvent::new(
2049 ExchangeId::BinanceMargin,
2050 AccountEventKind::Trade(trade),
2051 );
2052 if let Some(key) = dedup_key_from_event(&event)
2053 && is_duplicate(dedup, key)
2054 {
2055 duplicates += 1;
2056 continue;
2057 }
2058 if tx.send(event).is_err() {
2059 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2060 return;
2061 }
2062 recovered += 1;
2063 }
2064 }
2065
2066 if failed_instruments > 0 {
2067 error!(
2068 recovered,
2069 duplicates,
2070 failed_instruments,
2071 "BinanceMargin fill recovery complete with failures — some fills may be permanently missed"
2072 );
2073 } else {
2074 info!(
2075 recovered,
2076 duplicates, "BinanceMargin fill recovery complete"
2077 );
2078 }
2079}
2080
2081#[allow(
2088 clippy::cognitive_complexity,
2089 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2090 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2091)]
2092async fn margin_connection_manager(
2093 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2094 dedup: SharedDedupCache,
2095 ws_config: ConfigurationWebsocketApi,
2096 rest_config: Arc<ConfigurationRestApi>,
2097 rest: Arc<RestApi>,
2098 rate_limiter: Arc<RateLimitTracker>,
2099 instruments: Vec<InstrumentNameExchange>,
2100 initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2101) {
2102 enum DisconnectReason {
2103 Signal,
2104 HeartbeatTimeout,
2105 TokenRefresh,
2106 ConsumerDropped,
2107 }
2108
2109 let mut backoff = ExponentialBackoff::new();
2110 let mut disconnect_time: Option<DateTime<Utc>> = None;
2111 let (mut current_ws, mut current_token) = match initial {
2112 Some((ws, token)) => (Some(ws), Some(token)),
2113 None => (None, None),
2114 };
2115
2116 loop {
2117 let token = match current_token.take() {
2119 Some(t) => t,
2120 None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2121 Ok(t) => t,
2122 Err(e) => {
2123 error!(%e, "BinanceMargin userListenToken acquisition failed");
2124 if !backoff.wait().await {
2125 error!("BinanceMargin max reconnect attempts exhausted");
2126 emit_stream_terminated(
2127 &tx,
2128 ExchangeId::BinanceMargin,
2129 StreamTerminationReason::ReconnectBudgetExhausted {
2130 attempts: backoff.attempts(),
2131 last_error: e.to_string(),
2132 },
2133 );
2134 break;
2135 }
2136 continue;
2137 }
2138 },
2139 };
2140
2141 let ws = match current_ws.take() {
2143 Some(ws) => ws,
2144 None => match connect_margin_ws(&ws_config).await {
2145 Ok(ws) => ws,
2146 Err(e) => {
2147 error!(%e, "BinanceMargin WS connect failed");
2148 if !backoff.wait().await {
2149 error!("BinanceMargin max reconnect attempts exhausted");
2150 emit_stream_terminated(
2151 &tx,
2152 ExchangeId::BinanceMargin,
2153 StreamTerminationReason::ReconnectBudgetExhausted {
2154 attempts: backoff.attempts(),
2155 last_error: e.to_string(),
2156 },
2157 );
2158 break;
2159 }
2160 continue;
2161 }
2162 },
2163 };
2164
2165 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2167 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2169 let subscription = register_user_data_listener(
2172 &ws,
2173 tx.clone(),
2174 dedup.clone(),
2175 heartbeat_flag.clone(),
2176 signal_tx,
2177 cross_account_position_handler,
2178 );
2179
2180 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2182 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2183 subscription.unsubscribe();
2184 if let Err(de) = ws.disconnect().await {
2185 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2186 }
2187 if !backoff.wait().await {
2189 error!("BinanceMargin max reconnect attempts exhausted");
2190 emit_stream_terminated(
2191 &tx,
2192 ExchangeId::BinanceMargin,
2193 StreamTerminationReason::ReconnectBudgetExhausted {
2194 attempts: backoff.attempts(),
2195 last_error: e.to_string(),
2196 },
2197 );
2198 break;
2199 }
2200 continue;
2201 }
2202 info!("BinanceMargin account_stream connected and subscribed");
2203 backoff.reset();
2207
2208 let token_deadline =
2211 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2212
2213 if let Some(dt) = disconnect_time.take()
2215 && tokio::time::timeout(
2216 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2217 recover_margin_fills(&rest, &rate_limiter, &instruments, dt, &tx, &dedup, false),
2220 )
2221 .await
2222 .is_err()
2223 {
2224 warn!(
2225 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2226 "BinanceMargin fill recovery timed out — remaining instruments not queried"
2227 );
2228 }
2229
2230 let reason = {
2232 let mut signal_rx = signal_rx;
2233 loop {
2234 tokio::select! {
2235 biased;
2238 _ = tx.closed() => {
2239 debug!("BinanceMargin account_stream consumer dropped, terminating");
2240 break DisconnectReason::ConsumerDropped;
2241 }
2242 _ = &mut signal_rx => {
2243 warn!("BinanceMargin WS disconnected, will attempt reconnect");
2244 break DisconnectReason::Signal;
2245 }
2246 () = tokio::time::sleep_until(token_deadline) => {
2247 info!("BinanceMargin userListenToken nearing expiry, renewing");
2248 break DisconnectReason::TokenRefresh;
2249 }
2250 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2251 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2252 continue;
2256 }
2257 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2258 break DisconnectReason::HeartbeatTimeout;
2259 }
2260 }
2261 }
2262 };
2263 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2264
2265 if should_reconnect {
2266 disconnect_time = Some(match reason {
2267 DisconnectReason::HeartbeatTimeout => {
2268 Utc::now()
2269 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2270 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2271 }
2272 DisconnectReason::TokenRefresh => {
2273 Utc::now()
2278 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2279 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2280 }
2281 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2282 });
2283 }
2284
2285 subscription.unsubscribe();
2287 if let Err(e) = ws.disconnect().await {
2288 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2289 }
2290
2291 if !should_reconnect || tx.is_closed() {
2292 debug!("BinanceMargin connection manager exiting");
2294 break;
2295 }
2296
2297 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2300 error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
2301 let last_error = match reason {
2304 DisconnectReason::HeartbeatTimeout => {
2305 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2306 }
2307 _ => "WebSocket disconnected (server close/error)".to_string(),
2308 };
2309 emit_stream_terminated(
2310 &tx,
2311 ExchangeId::BinanceMargin,
2312 StreamTerminationReason::ReconnectBudgetExhausted {
2313 attempts: backoff.attempts(),
2314 last_error,
2315 },
2316 );
2317 break;
2318 }
2319 }
2320}
2321
2322fn build_base_quote_map(
2334 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2335) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2336 let mut map = HashMap::with_capacity(assets.len());
2337 for entry in assets {
2338 let (Some(symbol), Some(base), Some(quote)) = (
2339 entry.symbol.as_deref(),
2340 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2341 entry
2342 .quote_asset
2343 .as_deref()
2344 .and_then(|q| q.asset.as_deref()),
2345 ) else {
2346 warn!(
2347 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2348 skipping base/quote map entry"
2349 );
2350 continue;
2351 };
2352 map.insert(
2353 InstrumentNameExchange::new(symbol),
2354 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2355 );
2356 }
2357 map
2358}
2359
2360async fn acquire_all_isolated_tokens(
2373 rest_config: &Arc<ConfigurationRestApi>,
2374 rate_limiter: &Arc<RateLimitTracker>,
2375 symbols: &[InstrumentNameExchange],
2376) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
2377 use futures::{StreamExt as _, TryStreamExt as _};
2378
2379 futures::stream::iter(symbols.iter().cloned().map(|sym| {
2380 let rest_config = rest_config.clone();
2381 let rate_limiter = rate_limiter.clone();
2382 async move {
2383 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
2384 Ok::<_, UnindexedClientError>((sym, token))
2385 }
2386 }))
2387 .buffer_unordered(8)
2388 .try_collect()
2389 .await
2390}
2391
2392fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
2397 tokens
2398 .iter()
2399 .map(|(_, t)| t.expiration_time_ms)
2400 .min()
2401 .unwrap_or(i64::MAX)
2402}
2403
2404struct IsolatedLiveConn {
2407 ws: Arc<WsApiBase>,
2408 subscription: Subscription,
2409 signal_rx: oneshot::Receiver<()>,
2410 heartbeat_flag: Arc<AtomicBool>,
2411 earliest_expiry_ms: i64,
2414}
2415
2416async fn isolated_connect_and_subscribe(
2426 ws_config: &ConfigurationWebsocketApi,
2427 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2428 dedup: &SharedDedupCache,
2429 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2430 tokens: &[(InstrumentNameExchange, UserListenToken)],
2431) -> anyhow::Result<IsolatedLiveConn> {
2432 let ws = connect_margin_ws(ws_config).await?;
2433 let sub_map = Arc::new(Mutex::new(
2439 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
2440 ));
2441 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2442 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2444
2445 let handle_position = {
2446 let sub_map = sub_map.clone();
2447 let base_quote = base_quote.clone();
2448 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
2449 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
2450 }
2451 };
2452 let subscription = register_user_data_listener(
2454 &ws,
2455 tx.clone(),
2456 dedup.clone(),
2457 heartbeat_flag.clone(),
2458 signal_tx,
2459 handle_position,
2460 );
2461
2462 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
2463 for (sym, token) in tokens {
2464 match subscribe_listen_token_capture(&ws, &token.token).await {
2465 Ok(Some(id)) => {
2466 sub_map
2467 .lock()
2468 .unwrap_or_else(|p| p.into_inner())
2469 .insert(id, sym.clone());
2470 }
2471 Ok(None) => warn!(
2472 symbol = %sym.name(),
2473 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
2474 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
2475 ),
2476 Err(e) => {
2477 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
2480 subscription.unsubscribe();
2481 if let Err(de) = ws.disconnect().await {
2482 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
2483 }
2484 return Err(e);
2485 }
2486 }
2487 }
2488
2489 Ok(IsolatedLiveConn {
2490 ws,
2491 subscription,
2492 signal_rx,
2493 heartbeat_flag,
2494 earliest_expiry_ms,
2495 })
2496}
2497
2498#[allow(
2507 clippy::cognitive_complexity,
2508 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
2509 + backoff); mirrors the cross manager, not worth splitting further"
2510)]
2511async fn isolated_connection_manager(
2512 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2513 dedup: SharedDedupCache,
2514 ws_config: ConfigurationWebsocketApi,
2515 rest_config: Arc<ConfigurationRestApi>,
2516 rest: Arc<RestApi>,
2517 rate_limiter: Arc<RateLimitTracker>,
2518 symbols: Vec<InstrumentNameExchange>,
2519 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2520 initial: Option<IsolatedLiveConn>,
2521) {
2522 enum DisconnectReason {
2523 Signal,
2524 HeartbeatTimeout,
2525 TokenRefresh,
2526 ConsumerDropped,
2527 }
2528
2529 let mut backoff = ExponentialBackoff::new();
2530 let mut disconnect_time: Option<DateTime<Utc>> = None;
2531 let mut current = initial;
2532
2533 loop {
2534 let IsolatedLiveConn {
2536 ws,
2537 subscription,
2538 signal_rx,
2539 heartbeat_flag,
2540 earliest_expiry_ms,
2541 } = match current.take() {
2542 Some(live) => live,
2544 None => {
2545 let tokens = match acquire_all_isolated_tokens(
2548 &rest_config,
2549 &rate_limiter,
2550 &symbols,
2551 )
2552 .await
2553 {
2554 Ok(t) => t,
2555 Err(e) => {
2556 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
2557 if !backoff.wait().await {
2558 error!("BinanceMargin isolated max reconnect attempts exhausted");
2559 emit_stream_terminated(
2560 &tx,
2561 ExchangeId::BinanceMargin,
2562 StreamTerminationReason::ReconnectBudgetExhausted {
2563 attempts: backoff.attempts(),
2564 last_error: e.to_string(),
2565 },
2566 );
2567 break;
2568 }
2569 continue;
2570 }
2571 };
2572 match isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
2573 .await
2574 {
2575 Ok(live) => live,
2576 Err(e) => {
2577 error!(%e, "BinanceMargin isolated connect/subscribe failed");
2578 if !backoff.wait().await {
2579 error!("BinanceMargin isolated max reconnect attempts exhausted");
2580 emit_stream_terminated(
2581 &tx,
2582 ExchangeId::BinanceMargin,
2583 StreamTerminationReason::ReconnectBudgetExhausted {
2584 attempts: backoff.attempts(),
2585 last_error: e.to_string(),
2586 },
2587 );
2588 break;
2589 }
2590 continue;
2591 }
2592 }
2593 }
2594 };
2595
2596 info!(
2597 symbols = symbols.len(),
2598 "BinanceMargin isolated account_stream connected and subscribed"
2599 );
2600 backoff.reset();
2603
2604 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
2607
2608 if let Some(dt) = disconnect_time.take()
2610 && tokio::time::timeout(
2611 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2612 recover_margin_fills(&rest, &rate_limiter, &symbols, dt, &tx, &dedup, true),
2614 )
2615 .await
2616 .is_err()
2617 {
2618 warn!(
2619 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2620 "BinanceMargin isolated fill recovery timed out — remaining instruments not queried"
2621 );
2622 }
2623
2624 let reason = {
2627 let mut signal_rx = signal_rx;
2628 loop {
2629 tokio::select! {
2630 biased;
2631 _ = tx.closed() => {
2632 debug!("BinanceMargin isolated consumer dropped, terminating");
2633 break DisconnectReason::ConsumerDropped;
2634 }
2635 _ = &mut signal_rx => {
2636 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
2637 break DisconnectReason::Signal;
2638 }
2639 () = tokio::time::sleep_until(token_deadline) => {
2640 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
2641 break DisconnectReason::TokenRefresh;
2642 }
2643 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2644 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2645 continue;
2646 }
2647 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2648 break DisconnectReason::HeartbeatTimeout;
2649 }
2650 }
2651 }
2652 };
2653 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2654
2655 if should_reconnect {
2656 disconnect_time = Some(match reason {
2657 DisconnectReason::HeartbeatTimeout => {
2658 Utc::now()
2659 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2660 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2661 }
2662 DisconnectReason::TokenRefresh => {
2663 Utc::now()
2667 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2668 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2669 }
2670 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2671 });
2672 }
2673
2674 subscription.unsubscribe();
2676 if let Err(e) = ws.disconnect().await {
2677 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
2678 }
2679
2680 if !should_reconnect || tx.is_closed() {
2681 debug!("BinanceMargin isolated connection manager exiting");
2683 break;
2684 }
2685
2686 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2689 error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
2690 let last_error = match reason {
2693 DisconnectReason::HeartbeatTimeout => {
2694 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2695 }
2696 _ => "WebSocket disconnected (server close/error)".to_string(),
2697 };
2698 emit_stream_terminated(
2699 &tx,
2700 ExchangeId::BinanceMargin,
2701 StreamTerminationReason::ReconnectBudgetExhausted {
2702 attempts: backoff.attempts(),
2703 last_error,
2704 },
2705 );
2706 break;
2707 }
2708 }
2709}
2710
2711async fn fetch_margin_open_orders_for_instrument(
2718 rest: Arc<RestApi>,
2719 rate_limiter: Arc<RateLimitTracker>,
2720 instrument: InstrumentNameExchange,
2721 is_isolated: bool,
2722) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
2723 let symbol_str = instrument.name().to_string();
2725 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2726 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
2727 let sym = symbol_str.clone();
2728 let isolated = isolated.clone();
2729 Box::pin(async move {
2730 let params = QueryMarginAccountsOpenOrdersParams::builder()
2731 .symbol(sym)
2732 .is_isolated(isolated)
2733 .build()?;
2734 rest.query_margin_accounts_open_orders(params).await
2735 })
2736 })
2737 .await
2738 .map_err(connectivity_error)?;
2739
2740 let orders_data = response
2741 .data()
2742 .await
2743 .map_err(|e| connectivity_error(e.into()))?;
2744
2745 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceMargin, &instrument);
2746
2747 Ok((instrument, listing))
2748}
2749
2750async fn fetch_margin_all_open_orders(
2757 rest: Arc<RestApi>,
2758 rate_limiter: Arc<RateLimitTracker>,
2759 is_isolated: bool,
2760) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2761 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2762 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
2763 let isolated = isolated.clone();
2764 Box::pin(async move {
2765 let params = QueryMarginAccountsOpenOrdersParams::builder()
2766 .is_isolated(isolated)
2767 .build()?;
2768 rest.query_margin_accounts_open_orders(params).await
2769 })
2770 })
2771 .await
2772 .map_err(connectivity_error)?;
2773
2774 let orders_data = response
2775 .data()
2776 .await
2777 .map_err(|e| connectivity_error(e.into()))?;
2778
2779 let orders = orders_data
2780 .into_iter()
2781 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin))
2782 .collect();
2783
2784 Ok(orders)
2785}
2786
2787async fn paginate_margin_my_trades(
2797 rest: &Arc<RestApi>,
2798 rate_limiter: &Arc<RateLimitTracker>,
2799 instrument: &InstrumentNameExchange,
2800 from: MyTradesFrom,
2801 is_isolated: bool,
2802) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
2803 let symbol_str = instrument.name().to_string();
2805 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
2806 #[allow(clippy::cast_possible_wrap)]
2811 let limit = BINANCE_MAX_TRADES as i64;
2812 let mut all_pages = Vec::new();
2813 let mut cursor: Option<i64> = None;
2814 loop {
2815 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, |rest| {
2817 let sym = symbol_str.clone();
2818 let isolated = isolated.clone();
2819 Box::pin(async move {
2820 let builder = QueryMarginAccountsTradeListParams::builder(sym)
2821 .is_isolated(isolated)
2822 .limit(limit);
2823 let params = match (from, fid) {
2824 (MyTradesFrom::Time(start_time_ms), None) => builder.start_time(start_time_ms),
2825 (MyTradesFrom::Time(_), Some(id)) => builder.from_id(id),
2826 (MyTradesFrom::Order(order_id), None) => builder.order_id(order_id),
2827 (MyTradesFrom::Order(order_id), Some(id)) => {
2828 builder.order_id(order_id).from_id(id)
2829 }
2830 }
2831 .build()?;
2832 rest.query_margin_accounts_trade_list(params).await
2833 })
2834 })
2835 .await
2836 .map_err(connectivity_error)?;
2837
2838 let page = response
2839 .data()
2840 .await
2841 .map_err(|e| connectivity_error(e.into()))?;
2842
2843 let page_len = page.len();
2844 let last_id = page.last().and_then(|t| t.id);
2845 all_pages.extend(page);
2846
2847 if page_len < BINANCE_MAX_TRADES {
2848 break;
2849 }
2850 match last_id {
2851 Some(id) => {
2852 debug!(%instrument, "BinanceMargin paginate_my_trades: fetching next page ({page_len} results)");
2853 match id.checked_add(1) {
2854 Some(next) => cursor = Some(next),
2855 None => break, }
2857 }
2858 None => {
2859 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, stopping pagination");
2860 break;
2861 }
2862 }
2863 }
2864 Ok(all_pages)
2865}
2866
2867fn convert_margin_balance_entry(
2877 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
2878 now: DateTime<Utc>,
2879) -> Option<AssetBalance<AssetNameExchange>> {
2880 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2881 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2882 Some(v) => v,
2883 None => {
2884 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
2885 return None;
2886 }
2887 };
2888 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2889 Some(v) => v,
2890 None => {
2891 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
2892 return None;
2893 }
2894 };
2895 let borrowed = b
2900 .borrowed
2901 .as_deref()
2902 .and_then(|s| Decimal::from_str(s).ok())
2903 .unwrap_or(Decimal::ZERO);
2904 let interest = b
2905 .interest
2906 .as_deref()
2907 .and_then(|s| Decimal::from_str(s).ok())
2908 .unwrap_or(Decimal::ZERO);
2909 Some(AssetBalance::new(
2910 asset_name,
2911 Balance::new_margin(free + locked, free, borrowed, interest),
2912 now,
2913 ))
2914}
2915
2916fn filter_and_convert_margin_balances(
2919 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
2920 assets: &[AssetNameExchange],
2921) -> Vec<AssetBalance<AssetNameExchange>> {
2922 let now = Utc::now();
2923 if assets.is_empty() {
2925 return user_assets
2926 .into_iter()
2927 .filter_map(|b| convert_margin_balance_entry(b, now))
2928 .collect();
2929 }
2930 if assets.len() <= 16 {
2932 return user_assets
2933 .into_iter()
2934 .filter_map(|b| {
2935 let asset_name_str = b.asset.as_deref()?;
2936 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
2937 return None;
2938 }
2939 convert_margin_balance_entry(b, now)
2940 })
2941 .collect();
2942 }
2943 use std::collections::HashSet;
2944 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
2945 user_assets
2946 .into_iter()
2947 .filter_map(|b| {
2948 let asset_name_str = b.asset.as_deref()?;
2949 if !asset_set.contains(asset_name_str) {
2950 return None;
2951 }
2952 convert_margin_balance_entry(b, now)
2953 })
2954 .collect()
2955}
2956
2957fn active_order_snapshot(
2964 o: Order<ExchangeId, InstrumentNameExchange, Open>,
2965) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
2966{
2967 Order {
2968 key: o.key,
2969 side: o.side,
2970 price: o.price,
2971 quantity: o.quantity,
2972 kind: o.kind,
2973 time_in_force: o.time_in_force,
2974 state: OrderState::active(o.state),
2975 }
2976}
2977
2978fn convert_isolated_asset_balance(
2988 asset: Option<&str>,
2989 free: Option<&str>,
2990 locked: Option<&str>,
2991 borrowed: Option<&str>,
2992 interest: Option<&str>,
2993 now: DateTime<Utc>,
2994) -> Option<AssetBalance<AssetNameExchange>> {
2995 let asset_name = AssetNameExchange::new(asset?);
2996 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
2997 Some(v) => v,
2998 None => {
2999 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3000 return None;
3001 }
3002 };
3003 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3004 Some(v) => v,
3005 None => {
3006 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3007 return None;
3008 }
3009 };
3010 let borrowed = borrowed
3011 .and_then(|s| Decimal::from_str(s).ok())
3012 .unwrap_or(Decimal::ZERO);
3013 let interest = interest
3014 .and_then(|s| Decimal::from_str(s).ok())
3015 .unwrap_or(Decimal::ZERO);
3016 Some(AssetBalance::new(
3017 asset_name,
3018 Balance::new_margin(free + locked, free, borrowed, interest),
3019 now,
3020 ))
3021}
3022
3023fn convert_isolated_margin_assets(
3031 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3032) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3033 let now = Utc::now();
3036 let mut map = HashMap::with_capacity(assets.len());
3037 for entry in assets {
3038 let Some(symbol) = entry.symbol.as_deref() else {
3039 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3040 continue;
3041 };
3042 let instrument = InstrumentNameExchange::new(symbol);
3043
3044 let Some(base_raw) = entry.base_asset.as_deref() else {
3045 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3046 continue;
3047 };
3048 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3049 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3050 continue;
3051 };
3052
3053 let Some(base) = convert_isolated_asset_balance(
3054 base_raw.asset.as_deref(),
3055 base_raw.free.as_deref(),
3056 base_raw.locked.as_deref(),
3057 base_raw.borrowed.as_deref(),
3058 base_raw.interest.as_deref(),
3059 now,
3060 ) else {
3061 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3062 continue;
3063 };
3064 let Some(quote) = convert_isolated_asset_balance(
3065 quote_raw.asset.as_deref(),
3066 quote_raw.free.as_deref(),
3067 quote_raw.locked.as_deref(),
3068 quote_raw.borrowed.as_deref(),
3069 quote_raw.interest.as_deref(),
3070 now,
3071 ) else {
3072 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3073 continue;
3074 };
3075
3076 let risk = IsolatedMarginRisk {
3077 margin_level: entry
3078 .margin_level
3079 .as_deref()
3080 .and_then(|s| Decimal::from_str(s).ok()),
3081 margin_ratio: entry
3082 .margin_ratio
3083 .as_deref()
3084 .and_then(|s| Decimal::from_str(s).ok()),
3085 liquidation_price: entry
3086 .liquidate_price
3087 .as_deref()
3088 .and_then(|s| Decimal::from_str(s).ok()),
3089 };
3090
3091 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3092 }
3093 map
3094}
3095
3096fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3099 symbols
3100 .chunks(5)
3101 .map(|chunk| {
3102 chunk
3103 .iter()
3104 .map(|s| s.name().as_str())
3105 .collect::<Vec<_>>()
3106 .join(",")
3107 })
3108 .collect()
3109}
3110
3111async fn fetch_isolated_margin_account_info(
3117 rest: Arc<RestApi>,
3118 rate_limiter: Arc<RateLimitTracker>,
3119 symbols: Vec<InstrumentNameExchange>,
3120) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
3121 use futures::{StreamExt as _, TryStreamExt as _};
3122
3123 let chunks = chunk_symbols(&symbols);
3124
3125 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3129 let rest = rest.clone();
3130 let rate_limiter = rate_limiter.clone();
3131 async move {
3132 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
3133 let symbols_param = symbols_param.clone();
3134 Box::pin(async move {
3135 let params = QueryIsolatedMarginAccountInfoParams::builder()
3136 .symbols(symbols_param)
3137 .build()?;
3138 rest.query_isolated_margin_account_info(params).await
3139 })
3140 })
3141 .await
3142 .map_err(connectivity_error)?;
3143
3144 let info = response
3145 .data()
3146 .await
3147 .map_err(|e| connectivity_error(e.into()))?;
3148 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3149 }
3150 }))
3151 .buffer_unordered(8)
3152 .try_fold(
3153 Vec::with_capacity(symbols.len()),
3154 |mut acc, assets| async move {
3155 acc.extend(assets);
3156 Ok(acc)
3157 },
3158 )
3159 .await
3160}
3161
3162fn convert_margin_trade(
3169 t: &QueryMarginAccountsTradeListResponseInner,
3170 instrument: &InstrumentNameExchange,
3171) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3172 let trade_id_raw = match t.id {
3173 Some(id) => id,
3174 None => {
3175 warn!(%instrument, "BinanceMargin trade missing id");
3176 return None;
3177 }
3178 };
3179 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3180 let order_id = match t.order_id {
3181 Some(id) => OrderId(format_smolstr!("{id}")),
3182 None => {
3183 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3184 return None;
3185 }
3186 };
3187 let side = match t.is_buyer {
3188 Some(true) => Side::Buy,
3189 Some(false) => Side::Sell,
3190 None => {
3191 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3192 return None;
3193 }
3194 };
3195 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3196 Some(v) => v,
3197 None => {
3198 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3199 return None;
3200 }
3201 };
3202 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3203 Some(v) => v,
3204 None => {
3205 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3206 return None;
3207 }
3208 };
3209 let commission = t
3210 .commission
3211 .as_deref()
3212 .and_then(|s| Decimal::from_str(s).ok())
3213 .unwrap_or(Decimal::ZERO);
3214 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3215 Some(ts) => ts,
3216 None => {
3217 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3218 Utc::now()
3219 }
3220 };
3221
3222 let fee_asset = t
3226 .commission_asset
3227 .as_deref()
3228 .map(AssetNameExchange::from)
3229 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3230
3231 Some(Trade::new(
3232 trade_id,
3233 order_id,
3234 instrument.clone(),
3235 StrategyId::unknown(), time_exchange,
3237 side,
3238 price,
3239 quantity,
3240 None,
3242 AssetFees::new(fee_asset, commission, None),
3243 ))
3244}
3245
3246#[derive(Debug)]
3252enum BuildOrderError {
3253 Unsupported,
3255 Build(String),
3257}
3258
3259trait IsolatedFlag {
3270 fn from_flag(is_isolated: bool) -> Self;
3272}
3273
3274macro_rules! impl_isolated_flag {
3279 ($($t:ty),* $(,)?) => {
3280 $(
3281 impl IsolatedFlag for $t {
3282 fn from_flag(is_isolated: bool) -> Self {
3283 if is_isolated { Self::True } else { Self::False }
3284 }
3285 }
3286 )*
3287 };
3288}
3289
3290impl_isolated_flag!(
3291 MarginAccountNewOrderIsIsolatedEnum,
3292 MarginAccountCancelOrderIsIsolatedEnum,
3293 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3294 QueryMarginAccountsTradeListIsIsolatedEnum,
3295);
3296
3297fn build_cancel_order_params(
3305 symbol: String,
3306 id: Option<&OrderId>,
3307 cid: &ClientOrderId,
3308 is_isolated: bool,
3309) -> Result<MarginAccountCancelOrderParams, String> {
3310 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3311 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3312 );
3313
3314 match id {
3315 Some(order_id) => match order_id.0.parse::<i64>() {
3316 Ok(id) => builder = builder.order_id(id),
3317 Err(_) => {
3318 error!(
3320 order_id = %order_id.0,
3321 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3322 );
3323 builder = builder.orig_client_order_id(cid.0.to_string());
3324 }
3325 },
3326 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3327 }
3328
3329 builder.build().map_err(|e| e.to_string())
3330}
3331
3332#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3341 symbol: String,
3342 side: Side,
3343 price: Option<Decimal>,
3344 quantity: Decimal,
3345 kind: OrderKind,
3346 time_in_force: TimeInForce,
3347 new_client_order_id: String,
3348 side_effect: MarginSideEffect,
3349 is_isolated: bool,
3350) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3351 let binance_side = match side {
3352 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3353 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3354 };
3355
3356 let (binance_type, binance_tif) =
3357 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3358
3359 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3361 .quantity(quantity)
3362 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3363 .side_effect_type(side_effect.as_sdk_enum())
3364 .new_client_order_id(new_client_order_id)
3365 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3366
3367 if side_effect == MarginSideEffect::AutoBorrowRepay {
3370 builder = builder.auto_repay_at_cancel(true);
3371 }
3372
3373 if let Some(tif) = binance_tif {
3374 builder = builder.time_in_force(tif);
3375 }
3376
3377 match kind {
3380 OrderKind::Limit => {
3381 builder = builder.price(price);
3382 }
3383 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3384 builder = builder.stop_price(trigger_price);
3385 }
3386 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3387 builder = builder.price(price).stop_price(trigger_price);
3388 }
3389 _ => {}
3393 }
3394
3395 builder
3396 .build()
3397 .map_err(|e| BuildOrderError::Build(e.to_string()))
3398}
3399
3400fn convert_order_kind_tif_margin(
3410 kind: OrderKind,
3411 tif: TimeInForce,
3412) -> Option<(
3413 MarginAccountNewOrderTypeEnum,
3414 Option<MarginAccountNewOrderTimeInForceEnum>,
3415)> {
3416 if matches!(
3417 kind,
3418 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. }
3419 ) {
3420 warn!(
3421 ?kind,
3422 "BinanceMargin does not support trailing-stop orders (SDK trailingDelta binding gap)"
3423 );
3424 return None;
3425 }
3426
3427 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3428 let margin_type = match binance_type {
3429 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3430 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3431 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3432 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3433 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3434 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3435 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3436 };
3437 let margin_tif = binance_tif.map(|t| match t {
3438 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3439 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3440 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3441 });
3442 Some((margin_type, margin_tif))
3443}
3444
3445fn margin_avg_price(cummulative_quote_qty: Option<&str>, filled_qty: Decimal) -> Option<Decimal> {
3451 if filled_qty.is_zero() {
3452 return None;
3453 }
3454 let s = cummulative_quote_qty?;
3455 match Decimal::from_str(s) {
3456 Ok(cumulative) => cumulative.checked_div(filled_qty),
3457 Err(_) => {
3458 warn!(
3461 cummulative_quote_qty = s,
3462 "BinanceMargin: failed to parse cummulativeQuoteQty; avg price unavailable"
3463 );
3464 None
3465 }
3466 }
3467}
3468
3469#[cfg(test)]
3470#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3472 use super::*;
3473 use crate::client::binance::shared::convert_open_order;
3474 use crate::order::state::ActiveOrderState;
3475 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
3476
3477 #[test]
3478 fn margin_side_effect_default_is_auto_borrow_repay() {
3479 assert_eq!(
3480 MarginSideEffect::default(),
3481 MarginSideEffect::AutoBorrowRepay
3482 );
3483 }
3484
3485 #[test]
3486 fn margin_side_effect_wire_strings() {
3487 assert_eq!(
3488 MarginSideEffect::AutoBorrowRepay.as_binance_str(),
3489 "AUTO_BORROW_REPAY"
3490 );
3491 assert_eq!(
3492 MarginSideEffect::NoBorrow.as_binance_str(),
3493 "NO_SIDE_EFFECT"
3494 );
3495 }
3496
3497 #[test]
3498 fn margin_side_effect_serde_round_trip() {
3499 assert_eq!(
3501 serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
3502 r#""auto_borrow_repay""#
3503 );
3504 assert_eq!(
3505 serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
3506 r#""no_borrow""#
3507 );
3508 assert_eq!(
3509 serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
3510 MarginSideEffect::AutoBorrowRepay
3511 );
3512 assert_eq!(
3513 serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
3514 MarginSideEffect::NoBorrow
3515 );
3516 }
3517
3518 #[test]
3519 fn config_debug_redacts_secrets() {
3520 let config = BinanceMarginConfig::new(
3521 "my_api_key".to_string(),
3522 "my_secret_key".to_string(),
3523 false,
3524 false,
3525 Vec::new(),
3526 MarginSideEffect::default(),
3527 );
3528 let debug = format!("{config:?}");
3529 assert!(!debug.contains("my_api_key"));
3530 assert!(!debug.contains("my_secret_key"));
3531 assert!(debug.contains("***"));
3532 }
3533
3534 #[test]
3535 fn cross_margin_uses_common_case_defaults() {
3536 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3537 assert!(!config.testnet);
3538 assert!(!config.is_isolated);
3539 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3540 assert_eq!(config.api_key(), "k");
3541 }
3542
3543 #[test]
3544 fn config_deserializes_with_defaults() {
3545 let config: BinanceMarginConfig =
3547 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3548 .expect("deserialize");
3549 assert!(!config.is_isolated);
3550 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3551 }
3552
3553 #[test]
3554 fn config_deserializes_explicit_side_effect() {
3555 let config: BinanceMarginConfig = serde_json::from_str(
3556 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
3557 )
3558 .expect("deserialize");
3559 assert!(config.is_isolated);
3560 assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
3561 }
3562
3563 #[test]
3564 fn isolated_ctor_sets_symbols_and_flag() {
3565 let symbols = vec![
3566 InstrumentNameExchange::new("BTCUSDT"),
3567 InstrumentNameExchange::new("ETHUSDT"),
3568 ];
3569 let config =
3570 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
3571 assert!(config.is_isolated);
3572 assert_eq!(config.isolated_symbols, symbols);
3573 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3574 }
3575
3576 #[test]
3577 fn isolated_config_with_symbols_constructs() {
3578 let config = BinanceMarginConfig::isolated(
3580 "k".to_string(),
3581 "s".to_string(),
3582 vec![InstrumentNameExchange::new("BTCUSDT")],
3583 );
3584 let _client = BinanceMargin::new(config); }
3586
3587 #[test]
3588 #[should_panic(expected = "non-empty isolated_symbols")]
3589 fn isolated_empty_symbols_panics_at_new() {
3590 let config = BinanceMarginConfig::new(
3592 "k".to_string(),
3593 "s".to_string(),
3594 false,
3595 true,
3596 Vec::new(),
3597 MarginSideEffect::default(),
3598 );
3599 let _ = BinanceMargin::new(config);
3600 }
3601
3602 #[test]
3603 #[should_panic(expected = "non-empty isolated_symbols")]
3604 fn isolated_empty_symbols_panics_via_deserialize_path() {
3605 let config: BinanceMarginConfig = serde_json::from_str(
3608 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
3609 )
3610 .expect("deserialize");
3611 assert!(config.isolated_symbols.is_empty());
3612 let _ = BinanceMargin::new(config);
3613 }
3614
3615 #[test]
3616 fn isolated_flag_maps_mode() {
3617 assert_eq!(
3620 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
3621 "TRUE"
3622 );
3623 assert_eq!(
3624 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
3625 "FALSE"
3626 );
3627 assert_eq!(
3628 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
3629 "TRUE"
3630 );
3631 assert_eq!(
3632 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
3633 "FALSE"
3634 );
3635 assert_eq!(
3636 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
3637 "TRUE"
3638 );
3639 assert_eq!(
3640 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
3641 "FALSE"
3642 );
3643 assert_eq!(
3644 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
3645 "TRUE"
3646 );
3647 assert_eq!(
3648 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
3649 "FALSE"
3650 );
3651 }
3652
3653 #[test]
3658 fn cancel_params_cross_with_parseable_order_id() {
3659 let p = build_cancel_order_params(
3660 "BTCUSDT".to_string(),
3661 Some(&OrderId::new("12345")),
3662 &ClientOrderId::new("cid-1"),
3663 false,
3664 )
3665 .expect("build");
3666 assert_eq!(p.symbol, "BTCUSDT");
3667 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
3668 assert_eq!(p.order_id, Some(12345));
3669 assert_eq!(p.orig_client_order_id, None);
3670 }
3671
3672 #[test]
3673 fn cancel_params_isolated_is_config_driven() {
3674 let p = build_cancel_order_params(
3675 "BTCUSDT".to_string(),
3676 Some(&OrderId::new("12345")),
3677 &ClientOrderId::new("cid-1"),
3678 true,
3679 )
3680 .expect("build");
3681 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
3682 assert_eq!(p.order_id, Some(12345));
3683 }
3684
3685 #[test]
3686 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
3687 let p = build_cancel_order_params(
3688 "BTCUSDT".to_string(),
3689 Some(&OrderId::new("not-an-i64")),
3690 &ClientOrderId::new("cid-1"),
3691 false,
3692 )
3693 .expect("build");
3694 assert_eq!(p.order_id, None);
3695 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3696 }
3697
3698 #[test]
3699 fn cancel_params_absent_order_id_uses_cid() {
3700 let p = build_cancel_order_params(
3701 "BTCUSDT".to_string(),
3702 None,
3703 &ClientOrderId::new("cid-1"),
3704 false,
3705 )
3706 .expect("build");
3707 assert_eq!(p.order_id, None);
3708 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3709 }
3710
3711 use crate::order::TrailingOffsetType;
3716
3717 fn params(
3719 side: Side,
3720 price: Option<Decimal>,
3721 kind: OrderKind,
3722 tif: TimeInForce,
3723 side_effect: MarginSideEffect,
3724 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3725 build_new_order_params(
3726 "BTCUSDT".to_string(),
3727 side,
3728 price,
3729 Decimal::from(2),
3730 kind,
3731 tif,
3732 "cid-1".to_string(),
3733 side_effect,
3734 false, )
3736 }
3737
3738 fn gtc() -> TimeInForce {
3739 TimeInForce::GoodUntilCancelled { post_only: false }
3740 }
3741
3742 #[test]
3743 fn new_order_limit_maps_core_fields() {
3744 let p = params(
3745 Side::Buy,
3746 Some(Decimal::from(50_000)),
3747 OrderKind::Limit,
3748 gtc(),
3749 MarginSideEffect::AutoBorrowRepay,
3750 )
3751 .expect("build");
3752
3753 assert_eq!(p.symbol, "BTCUSDT");
3754 assert_eq!(p.side.as_str(), "BUY");
3755 assert_eq!(p.r#type.as_str(), "LIMIT");
3756 assert_eq!(p.quantity, Some(Decimal::from(2)));
3757 assert_eq!(p.price, Some(Decimal::from(50_000)));
3758 assert_eq!(p.stop_price, None);
3759 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
3760 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
3761 assert_eq!(
3763 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
3764 Some("FULL")
3765 );
3766 }
3767
3768 #[test]
3769 fn new_order_is_isolated_is_config_driven() {
3770 let cross = build_new_order_params(
3772 "BTCUSDT".to_string(),
3773 Side::Sell,
3774 Some(Decimal::from(10)),
3775 Decimal::from(2),
3776 OrderKind::Limit,
3777 gtc(),
3778 "cid-1".to_string(),
3779 MarginSideEffect::AutoBorrowRepay,
3780 false,
3781 )
3782 .expect("build");
3783 assert_eq!(
3784 cross.is_isolated.as_ref().map(|i| i.as_str()),
3785 Some("FALSE")
3786 );
3787 assert_eq!(cross.side.as_str(), "SELL");
3788
3789 let isolated = build_new_order_params(
3791 "BTCUSDT".to_string(),
3792 Side::Sell,
3793 Some(Decimal::from(10)),
3794 Decimal::from(2),
3795 OrderKind::Limit,
3796 gtc(),
3797 "cid-1".to_string(),
3798 MarginSideEffect::AutoBorrowRepay,
3799 true,
3800 )
3801 .expect("build");
3802 assert_eq!(
3803 isolated.is_isolated.as_ref().map(|i| i.as_str()),
3804 Some("TRUE")
3805 );
3806 }
3807
3808 #[test]
3809 fn side_effect_and_auto_repay_gating() {
3810 let auto = params(
3812 Side::Buy,
3813 Some(Decimal::from(1)),
3814 OrderKind::Limit,
3815 gtc(),
3816 MarginSideEffect::AutoBorrowRepay,
3817 )
3818 .expect("build");
3819 assert_eq!(
3820 auto.side_effect_type.as_ref().map(|s| s.as_str()),
3821 Some("AUTO_BORROW_REPAY")
3822 );
3823 assert_eq!(auto.auto_repay_at_cancel, Some(true));
3824
3825 let no_borrow = params(
3827 Side::Buy,
3828 Some(Decimal::from(1)),
3829 OrderKind::Limit,
3830 gtc(),
3831 MarginSideEffect::NoBorrow,
3832 )
3833 .expect("build");
3834 assert_eq!(
3835 no_borrow.side_effect_type.as_ref().map(|s| s.as_str()),
3836 Some("NO_SIDE_EFFECT")
3837 );
3838 assert_eq!(no_borrow.auto_repay_at_cancel, None);
3839 }
3840
3841 #[test]
3842 fn new_order_market_has_no_price_or_tif() {
3843 let p = params(
3844 Side::Buy,
3845 None,
3846 OrderKind::Market,
3847 gtc(),
3848 MarginSideEffect::AutoBorrowRepay,
3849 )
3850 .expect("build");
3851 assert_eq!(p.r#type.as_str(), "MARKET");
3852 assert_eq!(p.price, None);
3853 assert_eq!(p.stop_price, None);
3854 assert!(p.time_in_force.is_none());
3855 }
3856
3857 #[test]
3858 fn post_only_limit_maps_to_limit_maker() {
3859 let p = params(
3860 Side::Buy,
3861 Some(Decimal::from(10)),
3862 OrderKind::Limit,
3863 TimeInForce::GoodUntilCancelled { post_only: true },
3864 MarginSideEffect::AutoBorrowRepay,
3865 )
3866 .expect("build");
3867 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
3868 assert!(p.time_in_force.is_none());
3870 }
3871
3872 #[test]
3873 fn conditional_kinds_set_stop_price() {
3874 let trigger = Decimal::from(48_000);
3875
3876 let stop = params(
3877 Side::Sell,
3878 None,
3879 OrderKind::Stop {
3880 trigger_price: trigger,
3881 },
3882 gtc(),
3883 MarginSideEffect::AutoBorrowRepay,
3884 )
3885 .expect("build");
3886 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
3887 assert_eq!(stop.stop_price, Some(trigger));
3888 assert_eq!(stop.price, None);
3889
3890 let stop_limit = params(
3891 Side::Sell,
3892 Some(Decimal::from(47_900)),
3893 OrderKind::StopLimit {
3894 trigger_price: trigger,
3895 },
3896 gtc(),
3897 MarginSideEffect::AutoBorrowRepay,
3898 )
3899 .expect("build");
3900 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
3901 assert_eq!(stop_limit.stop_price, Some(trigger));
3902 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
3903 assert_eq!(
3904 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
3905 Some("GTC")
3906 );
3907
3908 let take_profit = params(
3909 Side::Sell,
3910 None,
3911 OrderKind::TakeProfit {
3912 trigger_price: trigger,
3913 },
3914 gtc(),
3915 MarginSideEffect::AutoBorrowRepay,
3916 )
3917 .expect("build");
3918 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
3919 assert_eq!(take_profit.stop_price, Some(trigger));
3920 }
3921
3922 #[test]
3923 fn trailing_kinds_are_rejected() {
3924 let trailing_stop = params(
3925 Side::Sell,
3926 None,
3927 OrderKind::TrailingStop {
3928 offset: Decimal::from(100),
3929 offset_type: TrailingOffsetType::BasisPoints,
3930 },
3931 gtc(),
3932 MarginSideEffect::AutoBorrowRepay,
3933 );
3934 assert!(matches!(trailing_stop, Err(BuildOrderError::Unsupported)));
3935
3936 let trailing_stop_limit = params(
3937 Side::Sell,
3938 Some(Decimal::from(100)),
3939 OrderKind::TrailingStopLimit {
3940 offset: Decimal::from(100),
3941 offset_type: TrailingOffsetType::BasisPoints,
3942 limit_offset: Decimal::from(10),
3943 },
3944 gtc(),
3945 MarginSideEffect::AutoBorrowRepay,
3946 );
3947 assert!(matches!(
3948 trailing_stop_limit,
3949 Err(BuildOrderError::Unsupported)
3950 ));
3951 }
3952
3953 #[test]
3954 fn tif_variants_map_to_margin_enum() {
3955 for (tif, expected) in [
3956 (TimeInForce::ImmediateOrCancel, "IOC"),
3957 (TimeInForce::FillOrKill, "FOK"),
3958 (gtc(), "GTC"),
3959 ] {
3960 let p = params(
3961 Side::Buy,
3962 Some(Decimal::from(1)),
3963 OrderKind::Limit,
3964 tif,
3965 MarginSideEffect::AutoBorrowRepay,
3966 )
3967 .expect("build");
3968 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
3969 }
3970 }
3971
3972 #[test]
3973 fn avg_price_from_cumulative_quote_qty() {
3974 assert_eq!(
3976 margin_avg_price(Some("100"), Decimal::from(4)),
3977 Some(Decimal::from(25))
3978 );
3979 assert_eq!(margin_avg_price(Some("100"), Decimal::ZERO), None);
3981 assert_eq!(margin_avg_price(None, Decimal::from(4)), None);
3983 assert_eq!(
3984 margin_avg_price(Some("not-a-number"), Decimal::from(4)),
3985 None
3986 );
3987 }
3988
3989 fn user_asset(
3994 asset: &str,
3995 free: &str,
3996 locked: &str,
3997 borrowed: &str,
3998 interest: &str,
3999 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4000 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4001 a.asset = Some(asset.to_string());
4002 a.free = Some(free.to_string());
4003 a.locked = Some(locked.to_string());
4004 a.borrowed = Some(borrowed.to_string());
4005 a.interest = Some(interest.to_string());
4006 a
4007 }
4008
4009 #[test]
4010 fn margin_balance_entry_maps_debt() {
4011 let ab =
4013 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4014 .expect("convert");
4015 assert_eq!(ab.asset.name().as_str(), "USDT");
4016 assert_eq!(ab.balance.total, Decimal::from(12));
4017 assert_eq!(ab.balance.free, Decimal::from(10));
4018 assert!(
4019 ab.balance.margin.is_some(),
4020 "REST snapshot must populate margin"
4021 );
4022 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4024 }
4025
4026 #[test]
4027 fn margin_balance_short_is_negative_net() {
4028 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4030 .expect("convert");
4031 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4032 }
4033
4034 #[test]
4035 fn margin_balance_missing_debt_defaults_to_zero() {
4036 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4039 a.asset = Some("ETH".to_string());
4040 a.free = Some("4".to_string());
4041 a.locked = Some("0".to_string());
4042 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4043 assert!(ab.balance.margin.is_some());
4044 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4045 }
4046
4047 #[test]
4048 fn margin_balance_missing_free_is_dropped() {
4049 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4051 a.asset = Some("USDT".to_string());
4052 a.locked = Some("0".to_string());
4053 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4054 }
4055
4056 #[test]
4057 fn margin_balance_filtering() {
4058 let assets = vec![
4059 user_asset("USDT", "10", "0", "0", "0"),
4060 user_asset("BTC", "1", "0", "0", "0"),
4061 user_asset("ETH", "5", "0", "0", "0"),
4062 ];
4063 assert_eq!(
4065 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4066 3
4067 );
4068 let filtered = filter_and_convert_margin_balances(
4070 assets,
4071 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4072 );
4073 assert_eq!(filtered.len(), 2);
4074 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4075 }
4076
4077 #[test]
4078 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4079 let assets = vec![
4081 user_asset("BTC", "1", "0", "0", "0"),
4082 user_asset("ETH", "5", "0", "0", "0"),
4083 user_asset("DOGE", "9", "0", "0", "0"), ];
4085 let mut requested: Vec<AssetNameExchange> = (0..17)
4087 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4088 .collect();
4089 requested.push(AssetNameExchange::new("BTC"));
4090 requested.push(AssetNameExchange::new("ETH"));
4091 assert!(
4092 requested.len() > 16,
4093 "must exceed the linear-scan threshold"
4094 );
4095
4096 let filtered = filter_and_convert_margin_balances(assets, &requested);
4097 assert_eq!(filtered.len(), 2);
4098 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4099 }
4100
4101 use binance_sdk::margin_trading::rest_api::{
4106 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4107 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4108 };
4109
4110 fn d(s: &str) -> Decimal {
4111 Decimal::from_str(s).unwrap()
4112 }
4113
4114 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4115 let mut b = IsoBase::new();
4116 b.asset = Some(asset.to_string());
4117 b.free = Some(free.to_string());
4118 b.locked = Some(locked.to_string());
4119 b.borrowed = Some(borrowed.to_string());
4120 b.interest = Some(interest.to_string());
4121 b
4122 }
4123
4124 fn iso_quote(
4125 asset: &str,
4126 free: &str,
4127 locked: &str,
4128 borrowed: &str,
4129 interest: &str,
4130 ) -> IsoQuote {
4131 let mut q = IsoQuote::new();
4132 q.asset = Some(asset.to_string());
4133 q.free = Some(free.to_string());
4134 q.locked = Some(locked.to_string());
4135 q.borrowed = Some(borrowed.to_string());
4136 q.interest = Some(interest.to_string());
4137 q
4138 }
4139
4140 fn iso_entry(
4141 symbol: &str,
4142 base: IsoBase,
4143 quote: IsoQuote,
4144 margin_level: Option<&str>,
4145 margin_ratio: Option<&str>,
4146 liquidate_price: Option<&str>,
4147 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4148 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4149 e.symbol = Some(symbol.to_string());
4150 e.base_asset = Some(Box::new(base));
4151 e.quote_asset = Some(Box::new(quote));
4152 e.margin_level = margin_level.map(str::to_string);
4153 e.margin_ratio = margin_ratio.map(str::to_string);
4154 e.liquidate_price = liquidate_price.map(str::to_string);
4155 e
4156 }
4157
4158 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4159 let config = BinanceMarginConfig::isolated(
4160 "k".to_string(),
4161 "s".to_string(),
4162 symbols
4163 .iter()
4164 .map(|s| InstrumentNameExchange::new(*s))
4165 .collect(),
4166 );
4167 BinanceMargin::new(config)
4168 }
4169
4170 #[test]
4171 fn isolated_assets_map_base_quote_and_risk() {
4172 let entry = iso_entry(
4175 "BTCUSDT",
4176 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4177 iso_quote("USDT", "1000", "50", "300", "1.5"),
4178 Some("3.5"),
4179 Some("0.12"),
4180 Some("48000"),
4181 );
4182 let map = convert_isolated_margin_assets(vec![entry]);
4183 let state = map
4184 .get(&InstrumentNameExchange::new("BTCUSDT"))
4185 .expect("entry present");
4186
4187 assert_eq!(state.base.asset.name().as_str(), "BTC");
4188 assert_eq!(state.base.balance.total, d("0.6"));
4189 assert_eq!(state.base.balance.free, d("0.5"));
4190 assert_eq!(state.base.balance.net_asset(), d("0.4"));
4191
4192 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4193 assert_eq!(state.quote.balance.total, d("1050"));
4194 assert_eq!(state.quote.balance.net_asset(), d("750"));
4195
4196 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4197 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4198 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4199 }
4200
4201 #[test]
4202 fn isolated_base_short_is_negative_net() {
4203 let entry = iso_entry(
4205 "BTCUSDT",
4206 iso_base("BTC", "0", "0", "1.5", "0.001"),
4207 iso_quote("USDT", "100", "0", "0", "0"),
4208 None,
4209 None,
4210 None,
4211 );
4212 let map = convert_isolated_margin_assets(vec![entry]);
4213 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4214 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4215 }
4216
4217 #[test]
4218 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4219 let mut base = IsoBase::new();
4222 base.asset = Some("BTC".to_string());
4223 base.free = Some("0.5".to_string());
4224 base.locked = Some("0".to_string());
4225 let mut quote = IsoQuote::new();
4226 quote.asset = Some("USDT".to_string());
4227 quote.free = Some("100".to_string());
4228 quote.locked = Some("0".to_string());
4229
4230 let map = convert_isolated_margin_assets(vec![iso_entry(
4231 "BTCUSDT", base, quote, None, None, None,
4232 )]);
4233 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4234 assert!(state.base.balance.margin.is_some());
4235 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4236 assert_eq!(state.risk.margin_level, None);
4237 assert_eq!(state.risk.liquidation_price, None);
4238 }
4239
4240 #[test]
4241 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4242 let entry = iso_entry(
4243 "BTCUSDT",
4244 iso_base("BTC", "1", "0", "0", "0"),
4245 iso_quote("USDT", "1", "0", "0", "0"),
4246 Some("not_a_number"),
4247 None,
4248 None,
4249 );
4250 let map = convert_isolated_margin_assets(vec![entry]);
4251 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4252 assert_eq!(state.risk.margin_level, None);
4253 }
4254
4255 #[test]
4256 fn isolated_missing_base_free_drops_entry() {
4257 let mut base = IsoBase::new();
4259 base.asset = Some("BTC".to_string());
4260 base.locked = Some("0".to_string()); let entry = iso_entry(
4262 "BTCUSDT",
4263 base,
4264 iso_quote("USDT", "100", "0", "0", "0"),
4265 None,
4266 None,
4267 None,
4268 );
4269 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4270 }
4271
4272 #[test]
4273 fn isolated_missing_symbol_drops_entry() {
4274 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4275 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4276 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4277 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4279 }
4280
4281 #[test]
4282 fn chunk_symbols_batches_by_five() {
4283 let syms: Vec<InstrumentNameExchange> = (0..12)
4284 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4285 .collect();
4286 let chunks = chunk_symbols(&syms);
4287 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4288 assert_eq!(chunks[0].split(',').count(), 5);
4289 assert_eq!(chunks[1].split(',').count(), 5);
4290 assert_eq!(chunks[2].split(',').count(), 2);
4291
4292 let three: Vec<_> = (0..3)
4294 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4295 .collect();
4296 assert_eq!(chunk_symbols(&three).len(), 1);
4297
4298 let five: Vec<_> = (0..5)
4300 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4301 .collect();
4302 let c = chunk_symbols(&five);
4303 assert_eq!(c.len(), 1);
4304 assert_eq!(c[0].split(',').count(), 5);
4305
4306 assert!(chunk_symbols(&[]).is_empty());
4308 }
4309
4310 #[test]
4311 fn effective_isolated_set_empty_returns_all_configured() {
4312 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4313 assert_eq!(
4314 client.effective_isolated_set(&[]),
4315 vec![
4316 InstrumentNameExchange::new("BTCUSDT"),
4317 InstrumentNameExchange::new("ETHUSDT"),
4318 ]
4319 );
4320 }
4321
4322 #[test]
4323 fn effective_isolated_set_intersects_requested() {
4324 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4325 assert_eq!(
4326 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4327 vec![InstrumentNameExchange::new("ETHUSDT")]
4328 );
4329 }
4330
4331 #[test]
4332 fn effective_isolated_set_skips_out_of_set() {
4333 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4334 assert_eq!(
4336 client.effective_isolated_set(&[
4337 InstrumentNameExchange::new("BTCUSDT"),
4338 InstrumentNameExchange::new("DOGEUSDT"),
4339 ]),
4340 vec![InstrumentNameExchange::new("BTCUSDT")]
4341 );
4342 }
4343
4344 #[tokio::test]
4345 async fn fetch_balances_isolated_returns_empty() {
4346 let client = isolated_client(&["BTCUSDT"]);
4349 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4350 }
4351
4352 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4353 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4354 o.order_id = order_id;
4355 o.client_order_id = Some("cid-9".to_string());
4356 o.side = Some(side.to_string());
4357 o.price = Some("50000".to_string());
4358 o.orig_qty = Some("2".to_string());
4359 o.executed_qty = Some("0.5".to_string());
4360 o.r#type = Some("LIMIT".to_string());
4361 o.time_in_force = Some("GTC".to_string());
4362 o.time = Some(1_700_000_000_000);
4363 o.status = Some("PARTIALLY_FILLED".to_string());
4364 o
4365 }
4366
4367 #[test]
4368 fn margin_open_order_converts() {
4369 let inst = InstrumentNameExchange::new("BTCUSDT");
4370 let order = convert_open_order(
4371 &open_order(Some(42), "BUY"),
4372 ExchangeId::BinanceMargin,
4373 &inst,
4374 )
4375 .expect("convert");
4376 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4377 assert_eq!(
4378 order.state.id.assigned().map(|id| id.0.as_str()),
4379 Some("42")
4380 );
4381 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4382 assert_eq!(order.side, Side::Buy);
4383 assert_eq!(order.price, Some(Decimal::from(50_000)));
4384 assert_eq!(order.quantity, Decimal::from(2));
4385 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4386 assert_eq!(order.kind, OrderKind::Limit);
4387 }
4388
4389 #[test]
4392 fn margin_open_order_that_is_not_live_is_dropped() {
4393 let inst = InstrumentNameExchange::new("BTCUSDT");
4394 let mut o = open_order(Some(42), "BUY");
4395 o.status = Some("CANCELED".to_string());
4396 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst).is_none());
4397 }
4398
4399 #[test]
4400 fn margin_open_order_missing_order_id_is_dropped() {
4401 let inst = InstrumentNameExchange::new("BTCUSDT");
4402 assert!(
4403 convert_open_order(&open_order(None, "BUY"), ExchangeId::BinanceMargin, &inst)
4404 .is_none()
4405 );
4406 }
4407
4408 #[test]
4414 fn margin_open_order_prefers_update_time_over_creation_time() {
4415 let inst = InstrumentNameExchange::new("BTCUSDT");
4416
4417 let mut updated = open_order(Some(42), "BUY");
4418 updated.update_time = Some(1_700_000_005_000);
4419 let order =
4420 convert_open_order(&updated, ExchangeId::BinanceMargin, &inst).expect("convert");
4421 assert_eq!(
4422 order.state.time_exchange,
4423 Utc.timestamp_millis_opt(1_700_000_005_000)
4424 .single()
4425 .unwrap(),
4426 "update_time must win over time"
4427 );
4428 }
4429
4430 #[test]
4433 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
4434 let inst = InstrumentNameExchange::new("BTCUSDT");
4435
4436 let base = open_order(Some(42), "BUY");
4437 assert!(base.update_time.is_none(), "fixture must omit update_time");
4438 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst).expect("convert");
4439 assert_eq!(
4440 order.state.time_exchange,
4441 Utc.timestamp_millis_opt(1_700_000_000_000)
4442 .single()
4443 .unwrap(),
4444 "absent update_time falls back to time"
4445 );
4446 }
4447
4448 #[test]
4449 fn margin_open_order_owned_symbol_recovers_instrument() {
4450 let mut o = open_order(Some(42), "SELL");
4452 o.symbol = Some("ETHUSDT".to_string());
4453 let order =
4454 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).expect("convert");
4455 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4456 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4457 assert_eq!(order.side, Side::Sell);
4458 }
4459
4460 #[test]
4461 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4462 let o = open_order(Some(42), "BUY");
4464 assert!(o.symbol.is_none());
4465 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).is_none());
4466 }
4467
4468 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4469 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4470 t.id = id;
4471 t.order_id = Some(7);
4472 t.is_buyer = is_buyer;
4473 t.price = Some("48000".to_string());
4474 t.qty = Some("0.25".to_string());
4475 t.commission = Some("0.001".to_string());
4476 t.commission_asset = Some("BNB".to_string());
4477 t.time = Some(1_700_000_000_000);
4478 t
4479 }
4480
4481 #[test]
4482 fn margin_trade_converts() {
4483 let inst = InstrumentNameExchange::new("BTCUSDT");
4484 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4485 assert_eq!(tr.id.0.as_str(), "11");
4486 assert_eq!(tr.order_id.0.as_str(), "7");
4487 assert_eq!(tr.side, Side::Sell);
4488 assert_eq!(tr.price, Decimal::from(48_000));
4489 assert_eq!(tr.quantity, Decimal::new(25, 2));
4490 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4492 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4493 }
4494
4495 #[test]
4496 fn margin_trade_missing_fields_are_dropped() {
4497 let inst = InstrumentNameExchange::new("BTCUSDT");
4498 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4500 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4501 }
4502
4503 #[test]
4506 fn token_renew_after_applies_margin_and_floor() {
4507 let now_ms = Utc::now().timestamp_millis();
4508 let far = token_renew_after(now_ms + 86_400_000).as_secs();
4510 assert!(
4511 far > 80_000 && far <= 86_400,
4512 "expected ~24h-minus-margin, got {far}"
4513 );
4514 assert_eq!(
4516 token_renew_after(now_ms - 10_000).as_secs(),
4517 TOKEN_MIN_LIFETIME_SECS
4518 );
4519 assert_eq!(
4521 token_renew_after(now_ms + 60_000).as_secs(),
4522 TOKEN_MIN_LIFETIME_SECS
4523 );
4524 }
4525
4526 fn push(event: serde_json::Value) -> String {
4531 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
4532 }
4533
4534 #[test]
4535 fn margin_ws_rpc_ack_yields_no_events() {
4536 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
4538 let mut buf = Vec::new();
4539 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4540 assert!(buf.is_empty());
4541 }
4542
4543 #[test]
4551 fn margin_ws_execution_report_trade_maps_to_trade_and_order_snapshot() {
4552 let frame = push(serde_json::json!({
4553 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4554 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
4555 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4556 "p": "48000", "q": "2", "f": "GTC",
4557 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
4558 }));
4559 let mut buf = Vec::new();
4560 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4561 assert_eq!(
4562 buf.len(),
4563 2,
4564 "a fill must produce the execution and the order snapshot, got: {buf:?}"
4565 );
4566 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
4567 match &buf[0].kind {
4568 AccountEventKind::Trade(t) => {
4569 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
4570 assert_eq!(t.side, Side::Buy);
4571 assert_eq!(t.price, Decimal::from(48_000));
4572 assert_eq!(t.quantity, Decimal::new(5, 1));
4573 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
4575 }
4576 other => panic!("expected Trade, got {other:?}"),
4577 }
4578 assert_eq!(buf[1].exchange, ExchangeId::BinanceMargin);
4579 match &buf[1].kind {
4580 AccountEventKind::OrderSnapshot(snap) => {
4581 assert_eq!(
4582 snap.0.quantity,
4583 Decimal::from(2),
4584 "the order's total quantity"
4585 );
4586 match &snap.0.state {
4587 OrderState::Active(ActiveOrderState::Open(open)) => {
4588 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("12345"));
4589 assert_eq!(
4590 open.filled_quantity,
4591 Decimal::new(5, 1),
4592 "the snapshot must carry the order's cumulative filled quantity (z)"
4593 );
4594 assert_eq!(
4595 open.time_exchange.timestamp_millis(),
4596 1_700_000_000_000,
4597 "stamped with the execution's transaction time (T), which is what \
4598 gives the engine's staleness gate an ordering key"
4599 );
4600 }
4601 other => panic!("expected an Open order, got {other:?}"),
4602 }
4603 }
4604 other => panic!("expected OrderSnapshot, got {other:?}"),
4605 }
4606 }
4607
4608 #[test]
4612 fn margin_trade_for_an_order_no_longer_working_emits_no_snapshot() {
4613 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
4614 let frame = push(serde_json::json!({
4615 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4616 "x": "TRADE", "X": status, "i": 12_345_i64, "c": "cid-1",
4617 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4618 "p": "48000", "q": "2", "f": "GTC", "T": 1_700_000_000_000_i64,
4619 }));
4620 let mut buf = Vec::new();
4621 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4622 assert_eq!(
4623 buf.len(),
4624 1,
4625 "status {status} must produce the execution alone, got: {buf:?}"
4626 );
4627 assert!(
4628 matches!(buf[0].kind, AccountEventKind::Trade(_)),
4629 "the execution must still be reported for status {status}"
4630 );
4631 }
4632 }
4633
4634 #[test]
4635 fn margin_ws_new_report_maps_to_active_order_snapshot() {
4636 let frame = push(serde_json::json!({
4637 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
4638 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
4639 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
4640 }));
4641 let mut buf = Vec::new();
4642 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4643 assert_eq!(buf.len(), 1);
4644 match &buf[0].kind {
4645 AccountEventKind::OrderSnapshot(snap) => {
4646 assert_eq!(snap.0.side, Side::Sell);
4647 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
4648 assert_eq!(snap.0.quantity, Decimal::from(1));
4649 assert!(
4650 matches!(snap.0.state, OrderState::Active(_)),
4651 "NEW should be an active (resting) order"
4652 );
4653 }
4654 other => panic!("expected OrderSnapshot, got {other:?}"),
4655 }
4656 }
4657
4658 #[test]
4659 fn margin_ws_canceled_report_maps_to_order_cancelled() {
4660 let frame = push(serde_json::json!({
4661 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4662 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
4663 "z": "0", "T": 1_700_000_000_000_i64,
4664 }));
4665 let mut buf = Vec::new();
4666 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4667 assert_eq!(buf.len(), 1);
4668 match &buf[0].kind {
4669 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
4670 other => panic!("expected OrderCancelled, got {other:?}"),
4671 }
4672 }
4673
4674 #[test]
4675 fn margin_ws_execution_report_missing_symbol_is_dropped() {
4676 let frame = push(serde_json::json!({
4678 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
4679 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
4680 }));
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]
4687 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
4688 let frame = push(serde_json::json!({
4689 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4690 "B": [
4691 { "a": "USDT", "f": "100.0", "l": "5.0" },
4692 { "a": "BTC", "f": "0.5", "l": "0" },
4693 ],
4694 }));
4695 let mut buf = Vec::new();
4696 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4697 assert_eq!(buf.len(), 2);
4698 for ev in &buf {
4700 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
4701 }
4702 }
4703
4704 #[test]
4705 fn margin_ws_stream_terminated_signals_reconnect() {
4706 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
4707 let mut buf = Vec::new();
4708 assert!(
4709 convert_margin_user_data_events(&frame, &mut buf),
4710 "eventStreamTerminated must signal reconnect"
4711 );
4712 assert!(buf.is_empty());
4713 }
4714
4715 #[test]
4716 fn margin_ws_margin_specific_events_are_observable_only() {
4717 let mut buf = Vec::new();
4720 let liability = push(serde_json::json!({
4721 "e": "userLiabilityChange", "a": "USDT", "t": "BORROW", "p": "100", "i": "0.01",
4722 }));
4723 assert!(!convert_margin_user_data_events(&liability, &mut buf));
4724 let level = push(serde_json::json!({
4725 "e": "marginLevelStatusChange", "l": "1.5", "s": "MARGIN_LEVEL_2",
4726 }));
4727 assert!(!convert_margin_user_data_events(&level, &mut buf));
4728 assert!(buf.is_empty());
4729 }
4730
4731 #[test]
4734 fn margin_trade_event_dedup_by_key() {
4735 let frame = push(serde_json::json!({
4738 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4739 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
4740 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
4741 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
4742 }));
4743 let mut buf = Vec::new();
4744 convert_margin_user_data_events(&frame, &mut buf);
4745 let event = buf.first().expect("a Trade event");
4748 assert!(
4749 matches!(event.kind, AccountEventKind::Trade(_)),
4750 "the execution is the first of the two events a fill produces"
4751 );
4752
4753 let cache = new_dedup_cache();
4754 let first = dedup_key_from_event(event).expect("Trade events have a dedup key");
4756 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
4757 let second = dedup_key_from_event(event).expect("Trade events have a dedup key");
4758 assert!(
4759 is_duplicate(&cache, second),
4760 "second sighting is a duplicate"
4761 );
4762 }
4763
4764 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
4772 let cache = new_dedup_cache();
4773 let mut delivered = Vec::new();
4774 let mut buf = Vec::new();
4775 for frame in frames {
4776 let _ = convert_margin_user_data_events(frame, &mut buf);
4777 for ev in buf.drain(..) {
4778 if let Some(key) = dedup_key_from_event(&ev)
4779 && is_duplicate(&cache, key)
4780 {
4781 continue;
4782 }
4783 delivered.push(ev);
4784 }
4785 }
4786 delivered
4787 }
4788
4789 fn ws_new(cumulative_filled: &str) -> String {
4790 push(serde_json::json!({
4791 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4792 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "client-1",
4793 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
4794 "T": 1_700_000_000_000_i64,
4795 }))
4796 }
4797
4798 fn ws_partial_fill(cumulative_filled: &str) -> String {
4799 push(serde_json::json!({
4800 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4801 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "client-1",
4802 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
4803 "l": "1", "L": "100", "t": 555_i64, "n": "0.1", "N": "USDT",
4804 "T": 1_700_000_001_000_i64,
4805 }))
4806 }
4807
4808 fn open_filled_quantities(events: &[UnindexedAccountEvent]) -> Vec<Decimal> {
4809 events
4810 .iter()
4811 .filter_map(|ev| match &ev.kind {
4812 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
4813 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
4814 _ => None,
4815 },
4816 _ => None,
4817 })
4818 .collect()
4819 }
4820
4821 #[test]
4829 fn margin_ws_fill_snapshot_survives_the_dedup_gate() {
4830 let delivered = drive_ws_pipeline(&[&ws_new("0"), &ws_partial_fill("1")]);
4831
4832 assert_eq!(
4833 delivered.len(),
4834 3,
4835 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
4836 );
4837 assert_eq!(
4838 open_filled_quantities(&delivered),
4839 vec![Decimal::ZERO, Decimal::ONE],
4840 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
4841 );
4842 }
4843
4844 #[test]
4847 fn margin_ws_replayed_frame_is_still_deduplicated() {
4848 let delivered = drive_ws_pipeline(&[
4849 &ws_new("0"),
4850 &ws_partial_fill("1"),
4851 &ws_new("0"),
4852 &ws_partial_fill("1"),
4853 ]);
4854
4855 assert_eq!(
4856 delivered.len(),
4857 3,
4858 "re-delivering both frames must add nothing, got: {delivered:?}"
4859 );
4860 }
4861
4862 #[test]
4868 fn margin_ws_an_ack_and_its_fill_reporting_one_state_deliver_that_state_once() {
4869 let delivered = drive_ws_pipeline(&[&ws_new("1"), &ws_partial_fill("1")]);
4870
4871 assert_eq!(
4872 delivered.len(),
4873 2,
4874 "the ack snapshot and the execution, with the fill's duplicate snapshot collapsed, \
4875 got: {delivered:?}"
4876 );
4877 assert!(
4878 delivered
4879 .iter()
4880 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
4881 "the execution must reach the consumer regardless"
4882 );
4883 assert_eq!(
4884 open_filled_quantities(&delivered),
4885 vec![Decimal::ONE],
4886 "one snapshot, carrying the state both reports agree on"
4887 );
4888 }
4889
4890 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
4895 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
4896 }
4897
4898 fn isolated_handler(
4901 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
4902 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
4903 ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
4904 move |position, subscription_id, buf| {
4905 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
4906 }
4907 }
4908
4909 fn btcusdt() -> InstrumentNameExchange {
4910 InstrumentNameExchange::new("BTCUSDT")
4911 }
4912
4913 fn token(expiration_time_ms: i64) -> UserListenToken {
4914 UserListenToken {
4915 token: "t".to_string(),
4916 expiration_time_ms,
4917 }
4918 }
4919
4920 #[test]
4921 fn listen_token_query_cross_vs_isolated() {
4922 assert!(build_listen_token_query(None).is_empty());
4924
4925 let q = build_listen_token_query(Some(&btcusdt()));
4927 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
4928 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
4929 assert_eq!(q.len(), 2);
4930 }
4931
4932 #[test]
4933 fn earliest_token_expiry_picks_min() {
4934 let tokens = vec![
4935 (btcusdt(), token(3_000)),
4936 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
4937 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
4938 ];
4939 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
4940 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
4942 }
4943
4944 #[test]
4945 fn base_quote_map_extracts_base_and_quote() {
4946 let entries = vec![
4947 iso_entry(
4948 "BTCUSDT",
4949 iso_base("BTC", "0", "0", "0", "0"),
4950 iso_quote("USDT", "0", "0", "0", "0"),
4951 None,
4952 None,
4953 None,
4954 ),
4955 iso_entry(
4956 "ETHBTC",
4957 iso_base("ETH", "0", "0", "0", "0"),
4958 iso_quote("BTC", "0", "0", "0", "0"),
4959 None,
4960 None,
4961 None,
4962 ),
4963 ];
4964 let map = build_base_quote_map(&entries);
4965 assert_eq!(
4966 map.get(&btcusdt())
4967 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4968 Some(("BTC", "USDT"))
4969 );
4970 assert_eq!(
4973 map.get(&InstrumentNameExchange::new("ETHBTC"))
4974 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4975 Some(("ETH", "BTC"))
4976 );
4977 }
4978
4979 #[test]
4980 fn isolated_outbound_position_routes_to_instrument_balance_update() {
4981 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
4982 let base_quote = Arc::new(HashMap::from([(
4983 btcusdt(),
4984 (
4985 AssetNameExchange::new("BTC"),
4986 AssetNameExchange::new("USDT"),
4987 ),
4988 )]));
4989 let mut handler = isolated_handler(sub_map, base_quote);
4990
4991 let frame = push_with_sub(
4993 7,
4994 serde_json::json!({
4995 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4996 "B": [
4997 { "a": "USDT", "f": "1000.0", "l": "50.0" },
4998 { "a": "BTC", "f": "0.5", "l": "0.1" },
4999 ],
5000 }),
5001 );
5002 let mut buf = Vec::new();
5003 assert!(!convert_margin_user_data_events_with(
5004 &frame,
5005 &mut buf,
5006 &mut handler
5007 ));
5008 assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
5009 match &buf[0].kind {
5010 AccountEventKind::InstrumentBalanceUpdate(ibu) => {
5011 assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
5012 assert_eq!(ibu.base.asset.name().as_str(), "BTC");
5013 assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
5014 assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
5015 assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
5016 assert_eq!(ibu.quote.update.free, Decimal::from(1000));
5017 assert_eq!(ibu.quote.update.locked, Decimal::from(50));
5018 }
5019 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
5020 }
5021 assert!(!matches!(
5023 buf[0].kind,
5024 AccountEventKind::BalanceStreamUpdate(_)
5025 ));
5026 }
5027
5028 #[test]
5029 fn isolated_outbound_position_unknown_subscription_dropped() {
5030 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5032 let base_quote = Arc::new(HashMap::from([(
5033 btcusdt(),
5034 (
5035 AssetNameExchange::new("BTC"),
5036 AssetNameExchange::new("USDT"),
5037 ),
5038 )]));
5039 let mut handler = isolated_handler(sub_map, base_quote);
5040
5041 let frame = push_with_sub(
5042 99,
5043 serde_json::json!({
5044 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5045 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
5046 }),
5047 );
5048 let mut buf = Vec::new();
5049 assert!(!convert_margin_user_data_events_with(
5050 &frame,
5051 &mut buf,
5052 &mut handler
5053 ));
5054 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
5055 }
5056
5057 #[test]
5058 fn isolated_outbound_position_missing_side_dropped() {
5059 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5062 let base_quote = Arc::new(HashMap::from([(
5063 btcusdt(),
5064 (
5065 AssetNameExchange::new("BTC"),
5066 AssetNameExchange::new("USDT"),
5067 ),
5068 )]));
5069 let mut handler = isolated_handler(sub_map, base_quote);
5070
5071 let frame = push_with_sub(
5072 7,
5073 serde_json::json!({
5074 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5075 "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
5076 }),
5077 );
5078 let mut buf = Vec::new();
5079 assert!(!convert_margin_user_data_events_with(
5080 &frame,
5081 &mut buf,
5082 &mut handler
5083 ));
5084 assert!(buf.is_empty(), "missing quote side → frame dropped");
5085 }
5086
5087 #[test]
5088 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
5089 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
5093 let base_quote = Arc::new(HashMap::new());
5094 let mut handler = isolated_handler(sub_map, base_quote);
5095
5096 let frame = push_with_sub(
5097 42,
5098 serde_json::json!({
5099 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5100 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
5101 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
5102 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5103 }),
5104 );
5105 let mut buf = Vec::new();
5106 assert!(!convert_margin_user_data_events_with(
5107 &frame,
5108 &mut buf,
5109 &mut handler
5110 ));
5111 assert_eq!(buf.len(), 2, "the execution and its order snapshot");
5112 match &buf[0].kind {
5113 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
5114 other => panic!("expected Trade, got {other:?}"),
5115 }
5116 match &buf[1].kind {
5117 AccountEventKind::OrderSnapshot(snap) => {
5118 assert_eq!(snap.0.key.instrument.name().as_str(), "BTCUSDT");
5119 }
5120 other => panic!("expected OrderSnapshot, got {other:?}"),
5121 }
5122 }
5123}