1use super::shared::{
38 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
39 CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
40 RateLimitTracker, SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, classify_order_kind_tif,
41 classify_rest_order_error, connectivity_error, dedup_key_from_event, is_duplicate,
42 new_dedup_cache, parse_order_kind, parse_side, parse_time_in_force, rest_call_with_retry,
43};
44use crate::{
45 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, InstrumentBalanceUpdate,
46 IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent, UnindexedAccountSnapshot,
47 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
48 client::ExecutionClient,
49 emit_stream_terminated,
50 error::{
51 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
52 UnindexedOrderError,
53 },
54 order::{
55 Order, OrderKey, OrderKind, TimeInForce,
56 id::{ClientOrderId, OrderId, StrategyId},
57 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
58 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
59 },
60 trade::{AssetFees, Trade, TradeId},
61};
62use binance_sdk::{
63 common::{
64 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
65 constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
66 models::WebsocketEvent,
67 websocket::{
68 SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
69 WebsocketMessageSendOptions,
70 },
71 },
72 margin_trading::{
73 MarginTradingRestApi,
74 rest_api::{
75 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
76 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
77 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
78 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
79 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
80 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
81 QueryIsolatedMarginAccountInfoParams,
82 QueryIsolatedMarginAccountInfoResponseAssetsInner,
83 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
84 QueryMarginAccountsOpenOrdersResponseInner, QueryMarginAccountsTradeListIsIsolatedEnum,
85 QueryMarginAccountsTradeListParams, QueryMarginAccountsTradeListResponseInner, RestApi,
86 },
87 websocket_streams::{
88 ExecutionReport, MarginLevelStatusChange, OutboundAccountPosition, UserLiabilityChange,
89 },
90 },
91};
92use chrono::{DateTime, TimeZone, Utc};
93use futures::stream::BoxStream;
94use rust_decimal::Decimal;
95use rustrade_instrument::{
96 Side,
97 asset::name::AssetNameExchange,
98 exchange::ExchangeId,
99 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
100};
101use serde::{Deserialize, Serialize};
102use smol_str::{SmolStr, format_smolstr};
103use std::{
104 collections::{BTreeMap, HashMap},
105 str::FromStr,
106 sync::{
107 Arc, Mutex,
108 atomic::{AtomicBool, Ordering},
109 },
110 time::Duration,
111};
112use tokio::sync::{mpsc, oneshot};
113use tracing::{debug, error, info, trace, warn};
114
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
133#[serde(rename_all = "snake_case")]
134pub enum MarginSideEffect {
135 #[default]
143 AutoBorrowRepay,
144 NoBorrow,
148}
149
150impl MarginSideEffect {
151 pub fn as_binance_str(self) -> &'static str {
153 match self {
154 MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
155 MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
156 }
157 }
158
159 fn as_sdk_enum(self) -> MarginAccountNewOrderSideEffectTypeEnum {
165 match self {
166 MarginSideEffect::AutoBorrowRepay => {
167 MarginAccountNewOrderSideEffectTypeEnum::AutoBorrowRepay
168 }
169 MarginSideEffect::NoBorrow => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
170 }
171 }
172}
173
174#[derive(Clone, Deserialize)]
184pub struct BinanceMarginConfig {
185 api_key: String,
188 secret_key: String,
189 pub testnet: bool,
195 #[serde(default)]
202 pub is_isolated: bool,
203 #[serde(default)]
214 pub isolated_symbols: Vec<InstrumentNameExchange>,
215 #[serde(default)]
219 pub side_effect: MarginSideEffect,
220}
221
222impl std::fmt::Debug for BinanceMarginConfig {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 f.debug_struct("BinanceMarginConfig")
226 .field("api_key", &"***")
227 .field("secret_key", &"***")
228 .field("testnet", &self.testnet)
229 .field("is_isolated", &self.is_isolated)
230 .field("isolated_symbols", &self.isolated_symbols)
231 .field("side_effect", &self.side_effect)
232 .finish()
233 }
234}
235
236impl BinanceMarginConfig {
237 pub fn new(
247 api_key: String,
248 secret_key: String,
249 testnet: bool,
250 is_isolated: bool,
251 isolated_symbols: Vec<InstrumentNameExchange>,
252 side_effect: MarginSideEffect,
253 ) -> Self {
254 Self {
255 api_key,
256 secret_key,
257 testnet,
258 is_isolated,
259 isolated_symbols,
260 side_effect,
261 }
262 }
263
264 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
271 Self::new(
272 api_key,
273 secret_key,
274 false,
275 false,
276 Vec::new(),
277 MarginSideEffect::default(),
278 )
279 }
280
281 pub fn isolated(
289 api_key: String,
290 secret_key: String,
291 symbols: Vec<InstrumentNameExchange>,
292 ) -> Self {
293 Self::new(
294 api_key,
295 secret_key,
296 false,
297 true,
298 symbols,
299 MarginSideEffect::default(),
300 )
301 }
302
303 pub fn api_key(&self) -> &str {
305 &self.api_key
306 }
307}
308
309#[derive(Clone)]
368pub struct BinanceMargin {
369 config: Arc<BinanceMarginConfig>,
370 rest: Arc<RestApi>,
372 rest_config: Arc<ConfigurationRestApi>,
376 ws_config: ConfigurationWebsocketApi,
380 rate_limiter: Arc<RateLimitTracker>,
382}
383
384impl std::fmt::Debug for BinanceMargin {
385 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
386 f.debug_struct("BinanceMargin")
387 .field("testnet", &self.config.testnet)
388 .field("is_isolated", &self.config.is_isolated)
389 .field("side_effect", &self.config.side_effect)
390 .finish_non_exhaustive()
391 }
392}
393
394impl BinanceMargin {
395 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
405 ConfigurationRestApi::builder()
406 .api_key(config.api_key.clone())
407 .api_secret(config.secret_key.clone())
408 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
409 .build()
410 .expect("failed to build Binance margin REST configuration")
411 }
412
413 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
423 ConfigurationWebsocketApi::builder()
424 .api_key(config.api_key.clone())
425 .api_secret(config.secret_key.clone())
426 .ws_url(SPOT_WS_API_PROD_URL)
430 .build()
431 .expect("failed to build Binance margin WebSocket configuration")
432 }
433
434 fn effective_isolated_set(
447 &self,
448 instruments: &[InstrumentNameExchange],
449 ) -> Vec<InstrumentNameExchange> {
450 if instruments.is_empty() {
451 return self.config.isolated_symbols.clone();
452 }
453 instruments
454 .iter()
455 .filter(|inst| {
456 let in_set = self.config.isolated_symbols.contains(*inst);
457 if !in_set {
458 warn!(
459 instrument = %inst.name(),
460 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
461 );
462 }
463 in_set
464 })
465 .cloned()
466 .collect()
467 }
468
469 async fn isolated_account_snapshot(
473 &self,
474 instruments: &[InstrumentNameExchange],
475 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
476 use futures::{StreamExt as _, TryStreamExt as _};
477
478 let effective = self.effective_isolated_set(instruments);
479 if effective.is_empty() {
480 return Ok(AccountSnapshot::new(
483 ExchangeId::BinanceMargin,
484 Vec::new(),
485 Vec::new(),
486 ));
487 }
488
489 let mut balances_by_symbol = convert_isolated_margin_assets(
491 fetch_isolated_margin_account_info(
492 self.rest.clone(),
493 self.rate_limiter.clone(),
494 effective.clone(),
495 )
496 .await?,
497 );
498
499 let instrument_snapshots: Vec<_> =
502 futures::stream::iter(effective.into_iter().map(|instrument| {
503 fetch_margin_open_orders_for_instrument(
504 self.rest.clone(),
505 self.rate_limiter.clone(),
506 instrument,
507 true,
508 )
509 }))
510 .buffer_unordered(8)
511 .map(|result| {
512 let (inst, orders) = result?;
513 let wrapped = orders.into_iter().map(active_order_snapshot).collect();
514 let isolated = balances_by_symbol.remove(&inst);
515 if isolated.is_none() {
516 warn!(
520 instrument = %inst.name(),
521 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
522 );
523 }
524 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
525 inst, wrapped, None, isolated,
526 ))
527 })
528 .try_collect()
529 .await?;
530
531 Ok(AccountSnapshot::new(
534 ExchangeId::BinanceMargin,
535 Vec::new(),
536 instrument_snapshots,
537 ))
538 }
539}
540
541impl ExecutionClient for BinanceMargin {
542 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
543
544 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
547 &[InstrumentKindDiscriminant::Spot];
548 type Config = BinanceMarginConfig;
549 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
550
551 fn new(config: Self::Config) -> Self {
564 if config.testnet {
565 warn!(
566 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
567 using production endpoints"
568 );
569 }
570 assert!(
571 !(config.is_isolated && config.isolated_symbols.is_empty()),
572 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
573 );
574 let rest_config = Self::build_rest_config(&config);
579 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
580 let rest_config = Arc::new(rest_config);
581 let ws_config = Self::build_ws_config(&config);
582 Self {
583 config: Arc::new(config),
584 rest,
585 rest_config,
586 ws_config,
587 rate_limiter: Arc::new(RateLimitTracker::new()),
588 }
589 }
590
591 async fn open_order(
605 &self,
606 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
607 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
608 let instrument = request.key.instrument.clone();
609 let side = request.state.side;
610 let price = request.state.price;
611 let quantity = request.state.quantity;
612 let kind = request.state.kind;
613 let time_in_force = request.state.time_in_force;
614 let cid = request.key.cid.clone();
615
616 let order_key = OrderKey::new(
617 ExchangeId::BinanceMargin,
618 instrument.clone(),
619 request.key.strategy.clone(),
620 cid.clone(),
621 );
622
623 let inactive = |state: UnindexedOrderState| {
626 Some(Order {
627 key: order_key.clone(),
628 side,
629 price,
630 quantity,
631 kind,
632 time_in_force,
633 state,
634 })
635 };
636
637 let params = match build_new_order_params(
638 instrument.name().to_string(),
639 side,
640 price,
641 quantity,
642 kind,
643 time_in_force,
644 cid.0.to_string(),
645 self.config.side_effect,
646 self.config.is_isolated,
647 ) {
648 Ok(params) => params,
649 Err(BuildOrderError::Unsupported) => {
650 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
651 format!(
652 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
653 ),
654 )));
655 }
656 Err(BuildOrderError::Build(msg)) => {
657 error!(%msg, "BinanceMargin failed to build new order params");
658 return inactive(OrderState::inactive(OrderError::Rejected(
659 ApiError::OrderRejected(msg),
660 )));
661 }
662 };
663
664 let response = match rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
665 let params = params.clone();
666 Box::pin(async move { rest.margin_account_new_order(params).await })
667 })
668 .await
669 {
670 Ok(response) => response,
671 Err(e) => {
672 return inactive(OrderState::inactive(classify_rest_order_error(
673 &e,
674 &instrument,
675 )));
676 }
677 };
678
679 let data = match response.data().await {
680 Ok(data) => data,
681 Err(e) => {
682 return inactive(OrderState::inactive(OrderError::Rejected(
685 ApiError::OrderRejected(e.to_string()),
686 )));
687 }
688 };
689
690 let time_exchange = data
691 .transact_time
692 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
693 .unwrap_or_else(Utc::now);
694
695 let exchange_order_id = match data.order_id {
696 Some(id) => OrderId(format_smolstr!("{id}")),
697 None => {
698 error!("BinanceMargin open_order response missing orderId");
699 return inactive(OrderState::inactive(OrderError::Rejected(
700 ApiError::OrderRejected("open_order response missing orderId".into()),
701 )));
702 }
703 };
704
705 let filled_qty = match data.executed_qty.as_deref() {
706 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
707 warn!(
711 executed_qty = q,
712 "BinanceMargin: failed to parse executedQty; treating as zero"
713 );
714 Decimal::ZERO
715 }),
716 None => {
717 warn!("BinanceMargin: executedQty missing in response; treating as zero");
720 Decimal::ZERO
721 }
722 };
723
724 let state = if filled_qty >= quantity {
725 let avg_price = margin_avg_price(data.cummulative_quote_qty.as_deref(), filled_qty);
728 OrderState::fully_filled(Filled::new(
729 exchange_order_id,
730 time_exchange,
731 filled_qty,
732 avg_price,
733 ))
734 } else {
735 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
736 };
737
738 Some(Order {
739 key: order_key,
740 side,
741 price,
742 quantity,
743 kind,
744 time_in_force,
745 state,
746 })
747 }
748
749 async fn cancel_order(
759 &self,
760 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
761 ) -> Option<UnindexedOrderResponseCancel> {
762 let instrument = request.key.instrument.clone();
763 let key = OrderKey {
764 exchange: request.key.exchange,
765 instrument: instrument.clone(),
766 strategy: request.key.strategy.clone(),
767 cid: request.key.cid.clone(),
768 };
769
770 let params = match build_cancel_order_params(
771 instrument.name().to_string(),
772 request.state.id.as_ref(),
773 &request.key.cid,
774 self.config.is_isolated,
775 ) {
776 Ok(p) => p,
777 Err(e) => {
778 error!(%e, "BinanceMargin failed to build cancel order params");
779 return Some(UnindexedOrderResponseCancel {
780 key,
781 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
782 });
783 }
784 };
785
786 let response = match rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
787 let params = params.clone();
788 Box::pin(async move { rest.margin_account_cancel_order(params).await })
789 })
790 .await
791 {
792 Ok(response) => response,
793 Err(e) => {
794 return Some(UnindexedOrderResponseCancel {
795 key,
796 state: Err(classify_rest_order_error(&e, &instrument)),
797 });
798 }
799 };
800
801 let data = match response.data().await {
802 Ok(data) => data,
803 Err(e) => {
804 return Some(UnindexedOrderResponseCancel {
807 key,
808 state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
809 });
810 }
811 };
812
813 let time_exchange = Utc::now();
815
816 let exchange_order_id = match data.order_id {
817 Some(id) => OrderId(SmolStr::new(id)),
820 None => {
821 error!("BinanceMargin cancel response missing orderId");
822 return Some(UnindexedOrderResponseCancel {
823 key,
824 state: Err(OrderError::Rejected(ApiError::OrderRejected(
825 "cancel response missing orderId".into(),
826 ))),
827 });
828 }
829 };
830
831 let filled_qty = match data.executed_qty.as_deref() {
832 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
833 warn!(
837 executed_qty = q,
838 "BinanceMargin: failed to parse executedQty; treating as zero"
839 );
840 Decimal::ZERO
841 }),
842 None => {
843 warn!("BinanceMargin: executedQty missing in response; treating as zero");
846 Decimal::ZERO
847 }
848 };
849
850 Some(UnindexedOrderResponseCancel {
851 key,
852 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
853 })
854 }
855
856 async fn account_snapshot(
871 &self,
872 assets: &[AssetNameExchange],
873 instruments: &[InstrumentNameExchange],
874 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
875 if self.config.is_isolated {
876 return self.isolated_account_snapshot(instruments).await;
877 }
878 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
879 Box::pin(async move {
880 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
881 rest.query_cross_margin_account_details(params).await
882 })
883 })
884 .await
885 .map_err(connectivity_error)?;
886
887 let account = response
888 .data()
889 .await
890 .map_err(|e| connectivity_error(e.into()))?;
891
892 let balances =
893 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
894
895 use futures::{StreamExt as _, TryStreamExt as _};
900 let instrument_snapshots: Vec<_> =
901 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
902 fetch_margin_open_orders_for_instrument(
903 self.rest.clone(),
904 self.rate_limiter.clone(),
905 instrument,
906 false,
908 )
909 }))
910 .buffer_unordered(8)
911 .map(|result| {
912 let (inst, orders) = result?;
913 let wrapped = orders.into_iter().map(active_order_snapshot).collect();
914 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
915 inst, wrapped, None, None,
916 ))
917 })
918 .try_collect()
919 .await?;
920
921 Ok(AccountSnapshot::new(
922 ExchangeId::BinanceMargin,
923 balances,
924 instrument_snapshots,
925 ))
926 }
927
928 async fn fetch_balances(
938 &self,
939 assets: &[AssetNameExchange],
940 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
941 if self.config.is_isolated {
942 return Ok(Vec::new());
943 }
944 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
945 Box::pin(async move {
946 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
947 rest.query_cross_margin_account_details(params).await
948 })
949 })
950 .await
951 .map_err(connectivity_error)?;
952
953 let account = response
954 .data()
955 .await
956 .map_err(|e| connectivity_error(e.into()))?;
957
958 Ok(filter_and_convert_margin_balances(
959 account.user_assets.unwrap_or_default(),
960 assets,
961 ))
962 }
963
964 async fn fetch_open_orders(
975 &self,
976 instruments: &[InstrumentNameExchange],
977 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
978 use futures::{StreamExt as _, TryStreamExt as _};
979
980 if self.config.is_isolated {
984 let effective = self.effective_isolated_set(instruments);
985 let capacity = effective.len();
986 return futures::stream::iter(effective.into_iter().map(|instrument| {
987 fetch_margin_open_orders_for_instrument(
988 self.rest.clone(),
989 self.rate_limiter.clone(),
990 instrument,
991 true,
992 )
993 }))
994 .buffer_unordered(8)
995 .try_fold(
996 Vec::with_capacity(capacity),
997 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
998 (_, orders)| async move {
999 acc.extend(orders);
1000 Ok(acc)
1001 },
1002 )
1003 .await;
1004 }
1005
1006 if instruments.is_empty() {
1009 return fetch_margin_all_open_orders(
1010 self.rest.clone(),
1011 self.rate_limiter.clone(),
1012 false,
1013 )
1014 .await;
1015 }
1016 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1017 fetch_margin_open_orders_for_instrument(
1018 self.rest.clone(),
1019 self.rate_limiter.clone(),
1020 instrument,
1021 false,
1022 )
1023 }))
1024 .buffer_unordered(8)
1025 .try_fold(
1026 Vec::with_capacity(instruments.len()),
1027 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, orders)| async move {
1028 acc.extend(orders);
1029 Ok(acc)
1030 },
1031 )
1032 .await
1033 }
1034
1035 async fn fetch_trades(
1048 &self,
1049 time_since: DateTime<Utc>,
1050 instruments: &[InstrumentNameExchange],
1051 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1052 use futures::StreamExt as _;
1053
1054 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1057 self.effective_isolated_set(instruments)
1058 } else {
1059 instruments.to_vec()
1060 };
1061 if effective.is_empty() {
1062 debug!(
1065 is_isolated = self.config.is_isolated,
1066 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1067 );
1068 return Ok(Vec::new());
1069 }
1070 let start_time_ms = time_since.timestamp_millis();
1071 let is_isolated = self.config.is_isolated;
1072 let mut all_trades = Vec::new();
1073
1074 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1077 let rest = self.rest.clone();
1078 let rate_limiter = self.rate_limiter.clone();
1079 async move {
1080 let pages = paginate_margin_my_trades(
1081 &rest,
1082 &rate_limiter,
1083 &inst,
1084 start_time_ms,
1085 is_isolated,
1086 )
1087 .await?;
1088 Ok::<_, UnindexedClientError>((inst, pages))
1089 }
1090 }))
1091 .buffer_unordered(8);
1092 while let Some(result) = stream.next().await {
1093 let (instrument, trades_data) = result?;
1094 for t in trades_data {
1095 if let Some(trade) = convert_margin_trade(&t, &instrument) {
1096 all_trades.push(trade);
1097 }
1098 }
1099 }
1100
1101 Ok(all_trades)
1102 }
1103
1104 async fn account_stream(
1171 &self,
1172 _assets: &[AssetNameExchange],
1175 instruments: &[InstrumentNameExchange],
1176 ) -> Result<Self::AccountStream, UnindexedClientError> {
1177 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1178 let dedup = new_dedup_cache();
1179 let rest = self.rest.clone();
1180 let rest_config = self.rest_config.clone();
1181 let ws_config = self.ws_config.clone();
1182 let rate_limiter = self.rate_limiter.clone();
1183
1184 let cm_handle = if self.config.is_isolated {
1185 let symbols = self.config.isolated_symbols.clone();
1189 debug_assert!(
1192 symbols.iter().all(|i| i.name().len() <= 23),
1193 "instrument name exceeds SmolStr inline capacity: {:?}",
1194 symbols.iter().find(|i| i.name().len() > 23)
1195 );
1196
1197 let assets = fetch_isolated_margin_account_info(
1203 rest.clone(),
1204 rate_limiter.clone(),
1205 symbols.clone(),
1206 )
1207 .await?;
1208 let base_quote = Arc::new(build_base_quote_map(&assets));
1209 for sym in &symbols {
1210 if !base_quote.contains_key(sym) {
1211 warn!(
1212 symbol = %sym.name(),
1213 "BinanceMargin isolated: account info returned no base/quote for configured \
1214 symbol — its live per-pair balance frames will be dropped (fills/orders \
1215 unaffected; balances available via account_snapshot)"
1216 );
1217 }
1218 }
1219
1220 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1224 let live =
1225 isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
1226 .await
1227 .map_err(|e| {
1228 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1229 })?;
1230
1231 tokio::spawn(isolated_connection_manager(
1232 tx,
1233 dedup,
1234 ws_config,
1235 rest_config,
1236 rest,
1237 rate_limiter,
1238 symbols,
1239 base_quote,
1240 Some(live),
1241 ))
1242 } else {
1243 let instruments = instruments.to_vec();
1245 debug_assert!(
1249 instruments.iter().all(|i| i.name().len() <= 23),
1250 "instrument name exceeds SmolStr inline capacity: {:?}",
1251 instruments.iter().find(|i| i.name().len() > 23)
1252 );
1253
1254 let initial_token =
1260 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1261 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1262 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1263 })?;
1264
1265 tokio::spawn(margin_connection_manager(
1266 tx,
1267 dedup,
1268 ws_config,
1269 rest_config,
1270 rest,
1271 rate_limiter,
1272 instruments,
1273 Some((initial_ws, initial_token)),
1274 ))
1275 };
1276
1277 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1278 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1279 Ok(futures::StreamExt::boxed(guarded_stream))
1280 }
1281}
1282
1283const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1291
1292struct UserListenToken {
1294 token: String,
1295 expiration_time_ms: i64,
1296}
1297
1298fn build_listen_token_query(
1302 symbol: Option<&InstrumentNameExchange>,
1303) -> BTreeMap<String, serde_json::Value> {
1304 let mut query = BTreeMap::new();
1305 if let Some(sym) = symbol {
1306 query.insert(
1307 "isIsolated".to_string(),
1308 serde_json::Value::String("TRUE".to_string()),
1309 );
1310 query.insert(
1311 "symbol".to_string(),
1312 serde_json::Value::String(sym.name().to_string()),
1313 );
1314 }
1315 query
1316}
1317
1318#[derive(Deserialize)]
1320struct UserListenTokenResponse {
1321 #[serde(alias = "listenToken")]
1323 token: String,
1324 #[serde(rename = "expirationTime")]
1325 expiration_time: i64,
1326}
1327
1328async fn acquire_user_listen_token(
1342 rest_config: &Arc<ConfigurationRestApi>,
1343 rate_limiter: &RateLimitTracker,
1344 symbol: Option<&InstrumentNameExchange>,
1345) -> Result<UserListenToken, UnindexedClientError> {
1346 let query = build_listen_token_query(symbol);
1349 let response = rest_call_with_retry(rest_config, rate_limiter, |cfg| {
1350 let query = query.clone();
1351 Box::pin(async move {
1352 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1353 &cfg,
1354 "/sapi/v1/userListenToken",
1355 reqwest::Method::POST,
1356 query,
1359 BTreeMap::new(),
1360 None,
1361 true, )
1363 .await
1364 })
1365 })
1366 .await
1367 .map_err(connectivity_error)?;
1368
1369 let data = response
1370 .data()
1371 .await
1372 .map_err(|e| connectivity_error(e.into()))?;
1373 Ok(UserListenToken {
1374 token: data.token,
1375 expiration_time_ms: data.expiration_time,
1376 })
1377}
1378
1379fn token_renew_after(expiration_time_ms: i64) -> Duration {
1382 let now_ms = Utc::now().timestamp_millis();
1383 let remaining_secs = expiration_time_ms
1387 .saturating_sub(now_ms)
1388 .saturating_div(1_000)
1389 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1390 let secs = u64::try_from(remaining_secs).unwrap_or(0);
1391 if secs < TOKEN_MIN_LIFETIME_SECS {
1394 warn!(
1395 computed_secs = secs,
1396 floor_secs = TOKEN_MIN_LIFETIME_SECS,
1397 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1398 );
1399 }
1400 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1401}
1402
1403async fn connect_margin_ws(
1410 ws_config: &ConfigurationWebsocketApi,
1411) -> anyhow::Result<Arc<WsApiBase>> {
1412 let ws = WsApiBase::new(ws_config.clone(), vec![]);
1413 tokio::time::timeout(
1416 Duration::from_secs(CONNECT_TIMEOUT_SECS),
1417 ws.clone().connect(),
1418 )
1419 .await
1420 .map_err(|_| {
1421 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1422 })??;
1423 Ok(ws)
1424}
1425
1426async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1433 let mut payload = BTreeMap::new();
1436 payload.insert(
1437 "listenToken".to_string(),
1438 serde_json::Value::String(token.to_string()),
1439 );
1440 ws.send_message::<serde_json::Value>(
1441 "userDataStream.subscribe.listenToken",
1442 payload,
1443 WebsocketMessageSendOptions::new(),
1444 )
1445 .await
1446 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1447 Ok(())
1448}
1449
1450async fn subscribe_listen_token_capture(
1463 ws: &Arc<WsApiBase>,
1464 token: &str,
1465) -> anyhow::Result<Option<i64>> {
1466 #[derive(Deserialize)]
1469 struct SubscribeAck {
1470 #[serde(rename = "subscriptionId", default)]
1471 subscription_id: Option<i64>,
1472 }
1473
1474 let mut payload = BTreeMap::new();
1475 payload.insert(
1476 "listenToken".to_string(),
1477 serde_json::Value::String(token.to_string()),
1478 );
1479 let result = ws
1480 .send_message::<SubscribeAck>(
1481 "userDataStream.subscribe.listenToken",
1482 payload,
1483 WebsocketMessageSendOptions::new(),
1484 )
1485 .await
1486 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1487 let ack = match result {
1488 SendWebsocketMessageResult::Single(resp) => resp.data()?,
1489 SendWebsocketMessageResult::Multiple(responses) => responses
1491 .into_iter()
1492 .next()
1493 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
1494 .data()?,
1495 };
1496 Ok(ack.subscription_id)
1497}
1498
1499fn cross_account_position_handler(
1503 position: OutboundAccountPosition,
1504 _subscription_id: Option<i64>,
1505 buf: &mut Vec<UnindexedAccountEvent>,
1506) {
1507 convert_margin_account_position(position, buf);
1508}
1509
1510#[cfg(test)]
1515fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1516 convert_margin_user_data_events_with(frame, buf, &mut cross_account_position_handler)
1517}
1518
1519fn convert_margin_user_data_events_with(
1533 frame: &str,
1534 buf: &mut Vec<UnindexedAccountEvent>,
1535 handle_position: &mut impl FnMut(
1536 OutboundAccountPosition,
1537 Option<i64>,
1538 &mut Vec<UnindexedAccountEvent>,
1539 ),
1540) -> bool {
1541 use serde_json::value::RawValue;
1542
1543 #[derive(Deserialize)]
1549 struct Envelope<'a> {
1550 #[serde(borrow, default)]
1551 id: Option<&'a RawValue>,
1552 #[serde(borrow, default)]
1553 event: Option<&'a RawValue>,
1554 #[serde(rename = "subscriptionId", default)]
1555 subscription_id: Option<i64>,
1556 }
1557 #[derive(Deserialize)]
1559 struct EventTag<'a> {
1560 #[serde(borrow, default)]
1561 e: Option<&'a str>,
1562 }
1563
1564 let envelope = match serde_json::from_str::<Envelope<'_>>(frame) {
1565 Ok(env) => env,
1566 Err(e) => {
1567 trace!(error = %e, "BinanceMargin WS: skipped unparseable frame");
1568 return false;
1569 }
1570 };
1571 if envelope.id.is_some() {
1573 return false;
1574 }
1575 let Some(event) = envelope.event else {
1577 trace!("BinanceMargin WS: ignoring frame without `event` or `id`");
1578 return false;
1579 };
1580 let subscription_id = envelope.subscription_id;
1581 let event_raw = event.get();
1582 let event_type = serde_json::from_str::<EventTag<'_>>(event_raw)
1590 .ok()
1591 .and_then(|tag| tag.e)
1592 .unwrap_or_default();
1593 match event_type {
1594 "executionReport" => {
1595 match serde_json::from_str::<ExecutionReport>(event_raw) {
1598 Ok(report) => {
1599 if let Some(ev) = convert_margin_execution_report(report) {
1600 buf.push(ev);
1601 }
1602 }
1603 Err(e) => {
1604 warn!(error = %e, "BinanceMargin: undeserializable executionReport, dropping")
1605 }
1606 }
1607 false
1608 }
1609 "outboundAccountPosition" => {
1610 match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
1611 Ok(position) => handle_position(position, subscription_id, buf),
1613 Err(e) => {
1614 warn!(error = %e, "BinanceMargin: undeserializable outboundAccountPosition, dropping")
1615 }
1616 }
1617 false
1618 }
1619 "balanceUpdate" => {
1620 false
1624 }
1625 "userLiabilityChange" => {
1626 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
1633 Ok(c) => {
1634 if tracing::enabled!(tracing::Level::INFO) {
1635 info!(
1636 asset = c.a.as_deref().unwrap_or("?"),
1637 kind = c.t.as_deref().unwrap_or("?"),
1638 principal = c.p.as_deref().unwrap_or("?"),
1639 interest = c.i.as_deref().unwrap_or("?"),
1640 "BinanceMargin userLiabilityChange (observable; not applied to balance state)"
1641 );
1642 }
1643 }
1644 Err(e) => warn!(
1645 error = %e,
1646 "BinanceMargin: undeserializable userLiabilityChange, dropping"
1647 ),
1648 }
1649 false
1650 }
1651 "marginLevelStatusChange" => {
1652 if tracing::enabled!(tracing::Level::WARN) {
1657 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
1658 Ok(c) => warn!(
1659 margin_level = c.l.as_deref().unwrap_or("?"),
1660 status = c.s.as_deref().unwrap_or("?"),
1661 "BinanceMargin marginLevelStatusChange (liquidation risk; observable, no policy)"
1662 ),
1663 Err(e) => warn!(
1664 error = %e,
1665 "BinanceMargin: undeserializable marginLevelStatusChange, dropping"
1666 ),
1667 }
1668 }
1669 false
1670 }
1671 "eventStreamTerminated" => {
1672 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
1673 true
1674 }
1675 other => {
1676 trace!(
1677 event_type = other,
1678 "BinanceMargin ignoring unhandled user data event"
1679 );
1680 false
1681 }
1682 }
1683}
1684
1685#[allow(clippy::cognitive_complexity)] fn convert_margin_execution_report(report: ExecutionReport) -> Option<UnindexedAccountEvent> {
1693 let exec_type = match report.x.as_deref() {
1694 Some(t) => t,
1695 None => {
1696 warn!("BinanceMargin executionReport missing execution type (x), dropping");
1697 return None;
1698 }
1699 };
1700 let symbol = match report.s.as_deref() {
1701 Some(s) => InstrumentNameExchange::new(s),
1702 None => {
1703 warn!("BinanceMargin executionReport missing symbol (s), dropping");
1704 return None;
1705 }
1706 };
1707 let order_id = match report.i {
1708 Some(id) => OrderId(format_smolstr!("{id}")),
1709 None => {
1710 warn!(%symbol, "BinanceMargin executionReport missing orderId (i), dropping");
1711 return None;
1712 }
1713 };
1714 let cid = match report.c.as_deref() {
1715 Some(c) => ClientOrderId::new(c),
1716 None => ClientOrderId::new(order_id.0.as_str()),
1717 };
1718 let time_exchange = match report
1719 .t_uppercase
1720 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1721 {
1722 Some(t) => t,
1723 None => {
1724 warn!(%symbol, "BinanceMargin executionReport missing/unparseable transaction time (T), using now");
1725 Utc::now()
1726 }
1727 };
1728
1729 match exec_type {
1730 "NEW" => convert_margin_new_order(&report, symbol, cid, order_id, time_exchange),
1731 "TRADE" => {
1732 let trade_id = match report.t {
1733 Some(id) => TradeId(format_smolstr!("{id}")),
1734 None => {
1735 warn!(%symbol, "BinanceMargin TRADE event missing trade ID (t), dropping");
1736 return None;
1737 }
1738 };
1739 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
1740 Some(s) => s,
1741 None => {
1742 warn!(%symbol, "BinanceMargin TRADE event missing/unknown side (S), dropping");
1743 return None;
1744 }
1745 };
1746 let last_price = match report.l_uppercase.as_deref() {
1747 Some(s) => match Decimal::from_str(s) {
1748 Ok(v) => v,
1749 Err(e) => {
1750 warn!(%symbol, error = %e, raw = s, "BinanceMargin TRADE event unparseable last price (L), dropping fill");
1751 return None;
1752 }
1753 },
1754 None => {
1755 warn!(%symbol, "BinanceMargin TRADE event missing last price (L), dropping fill");
1756 return None;
1757 }
1758 };
1759 let last_qty = match report.l.as_deref() {
1760 Some(s) => match Decimal::from_str(s) {
1761 Ok(v) => v,
1762 Err(e) => {
1763 warn!(%symbol, error = %e, raw = s, "BinanceMargin TRADE event unparseable last qty (l), dropping fill");
1764 return None;
1765 }
1766 },
1767 None => {
1768 warn!(%symbol, "BinanceMargin TRADE event missing last qty (l), dropping fill");
1769 return None;
1770 }
1771 };
1772 let commission = match Decimal::from_str(report.n.as_deref().unwrap_or("0")) {
1773 Ok(v) => v,
1774 Err(e) => {
1775 warn!(%symbol, error = %e, "BinanceMargin TRADE event unparseable commission (n), defaulting to 0");
1776 Decimal::ZERO
1777 }
1778 };
1779 let fee_asset = report
1780 .n_uppercase
1781 .as_deref()
1782 .map(AssetNameExchange::from)
1783 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
1784
1785 let order_filled_quantity = report.z.as_deref().and_then(|s| Decimal::from_str(s).ok());
1788
1789 let trade = Trade::new(
1790 trade_id,
1791 order_id,
1792 symbol,
1793 StrategyId::unknown(), time_exchange,
1795 side,
1796 last_price,
1797 last_qty,
1798 order_filled_quantity,
1799 AssetFees::new(fee_asset, commission, None),
1800 );
1801 Some(UnindexedAccountEvent::new(
1802 ExchangeId::BinanceMargin,
1803 AccountEventKind::Trade(trade),
1804 ))
1805 }
1806 "CANCELED" | "EXPIRED" | "EXPIRED_IN_MATCH" => {
1807 let filled_qty = report
1808 .z
1809 .as_deref()
1810 .and_then(|s| Decimal::from_str(s).ok())
1811 .unwrap_or(Decimal::ZERO);
1812 let response = UnindexedOrderResponseCancel {
1813 key: OrderKey::new(
1814 ExchangeId::BinanceMargin,
1815 symbol,
1816 StrategyId::unknown(),
1817 cid,
1818 ),
1819 state: Ok(Cancelled::new(order_id, time_exchange, filled_qty)),
1820 };
1821 Some(UnindexedAccountEvent::new(
1822 ExchangeId::BinanceMargin,
1823 AccountEventKind::OrderCancelled(response),
1824 ))
1825 }
1826 "REJECTED" => {
1827 let reject_reason = report.r.unwrap_or_else(|| "unknown".to_string());
1828 warn!(%symbol, %order_id, reason = %reject_reason, "BinanceMargin order REJECTED by matching engine");
1829 let response = UnindexedOrderResponseCancel {
1830 key: OrderKey::new(
1831 ExchangeId::BinanceMargin,
1832 symbol,
1833 StrategyId::unknown(),
1834 cid,
1835 ),
1836 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1837 reject_reason,
1838 ))),
1839 };
1840 Some(UnindexedAccountEvent::new(
1841 ExchangeId::BinanceMargin,
1842 AccountEventKind::OrderCancelled(response),
1843 ))
1844 }
1845 "REPLACE" => {
1846 let filled_qty = report
1848 .z
1849 .as_deref()
1850 .and_then(|s| Decimal::from_str(s).ok())
1851 .unwrap_or(Decimal::ZERO);
1852 let response = UnindexedOrderResponseCancel {
1853 key: OrderKey::new(
1854 ExchangeId::BinanceMargin,
1855 symbol,
1856 StrategyId::unknown(),
1857 cid,
1858 ),
1859 state: Ok(Cancelled::new(order_id, time_exchange, filled_qty)),
1860 };
1861 Some(UnindexedAccountEvent::new(
1862 ExchangeId::BinanceMargin,
1863 AccountEventKind::OrderCancelled(response),
1864 ))
1865 }
1866 _ => {
1867 trace!(exec_type, "BinanceMargin ignoring execution type");
1869 None
1870 }
1871 }
1872}
1873
1874fn convert_margin_new_order(
1876 report: &ExecutionReport,
1877 symbol: InstrumentNameExchange,
1878 cid: ClientOrderId,
1879 order_id: OrderId,
1880 time_exchange: DateTime<Utc>,
1881) -> Option<UnindexedAccountEvent> {
1882 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
1883 Some(s) => s,
1884 None => {
1885 warn!(%symbol, "BinanceMargin NEW event missing/unknown side (S), dropping");
1886 return None;
1887 }
1888 };
1889 let kind = parse_order_kind(report.o.as_deref().unwrap_or("LIMIT"))?;
1890 let price: Option<Decimal> = match (report.p.as_deref(), &kind) {
1891 (Some(p), _) => match Decimal::from_str(p) {
1892 Ok(v) if !v.is_zero() => Some(v),
1893 Ok(_) => {
1894 if matches!(
1895 kind,
1896 OrderKind::Limit
1897 | OrderKind::StopLimit { .. }
1898 | OrderKind::TakeProfitLimit { .. }
1899 | OrderKind::TrailingStopLimit { .. }
1900 ) {
1901 trace!(%symbol, %kind, "BinanceMargin NEW event has zero price (p) on limit-type order, treating as no limit price");
1902 }
1903 None
1904 }
1905 Err(e) => {
1906 warn!(%symbol, price = p, error = %e, "BinanceMargin NEW event unparseable price (p), dropping");
1907 return None;
1908 }
1909 },
1910 (
1911 None,
1912 OrderKind::Market
1913 | OrderKind::Stop { .. }
1914 | OrderKind::TakeProfit { .. }
1915 | OrderKind::TrailingStop { .. },
1916 ) => None,
1917 (
1918 None,
1919 OrderKind::Limit
1920 | OrderKind::StopLimit { .. }
1921 | OrderKind::TakeProfitLimit { .. }
1922 | OrderKind::TrailingStopLimit { .. },
1923 ) => {
1924 warn!(%symbol, "BinanceMargin NEW limit-type order missing price (p), dropping");
1925 return None;
1926 }
1927 };
1928 let quantity = match report.q.as_deref() {
1929 Some(q) => match Decimal::from_str(q) {
1930 Ok(v) => v,
1931 Err(e) => {
1932 warn!(%symbol, qty = q, error = %e, "BinanceMargin NEW event unparseable quantity (q), dropping");
1933 return None;
1934 }
1935 },
1936 None => {
1937 warn!(%symbol, "BinanceMargin NEW order missing quantity (q), dropping");
1938 return None;
1939 }
1940 };
1941 let time_in_force = parse_time_in_force(report.f.as_deref().unwrap_or("GTC"));
1942 let filled_qty = report
1943 .z
1944 .as_deref()
1945 .and_then(|s| Decimal::from_str(s).ok())
1946 .unwrap_or(Decimal::ZERO);
1947
1948 let order = Order {
1949 key: OrderKey::new(
1950 ExchangeId::BinanceMargin,
1951 symbol,
1952 StrategyId::unknown(),
1953 cid,
1954 ),
1955 side,
1956 price,
1957 quantity,
1958 kind,
1959 time_in_force,
1960 state: OrderState::active(Open::new(order_id, time_exchange, filled_qty)),
1961 };
1962 Some(UnindexedAccountEvent::new(
1963 ExchangeId::BinanceMargin,
1964 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot::new(
1965 order,
1966 )),
1967 ))
1968}
1969
1970fn convert_margin_account_position(
1976 position: OutboundAccountPosition,
1977 buf: &mut Vec<UnindexedAccountEvent>,
1978) {
1979 let time_exchange = position
1980 .u
1981 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1982 .unwrap_or_else(Utc::now);
1983
1984 for b in position.b_uppercase.unwrap_or_default() {
1985 let asset = match b.a {
1986 Some(a) => AssetNameExchange::new(a),
1987 None => {
1988 warn!("BinanceMargin account position entry missing asset name");
1989 continue;
1990 }
1991 };
1992 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1993 Some(v) => v,
1994 None => {
1995 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
1996 continue;
1997 }
1998 };
1999 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2000 Some(v) => v,
2001 None => {
2002 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
2003 continue;
2004 }
2005 };
2006 let update =
2007 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2008 buf.push(UnindexedAccountEvent::new(
2009 ExchangeId::BinanceMargin,
2010 AccountEventKind::BalanceStreamUpdate(
2011 rustrade_integration::collection::snapshot::Snapshot::new(update),
2012 ),
2013 ));
2014 }
2015}
2016
2017fn route_isolated_account_position(
2028 position: OutboundAccountPosition,
2029 subscription_id: Option<i64>,
2030 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
2031 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
2032 buf: &mut Vec<UnindexedAccountEvent>,
2033) {
2034 let Some(sub_id) = subscription_id else {
2035 warn!(
2036 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
2037 (per-instrument balance routing requires it)"
2038 );
2039 return;
2040 };
2041 let instrument = {
2042 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
2045 match map.get(&sub_id) {
2046 Some(inst) => inst.clone(),
2047 None => {
2048 warn!(
2049 subscription_id = sub_id,
2050 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
2051 );
2052 return;
2053 }
2054 }
2055 };
2056 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
2057 warn!(
2058 instrument = %instrument.name(),
2059 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
2060 );
2061 return;
2062 };
2063
2064 let time_exchange = position
2065 .u
2066 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2067 .unwrap_or_else(Utc::now);
2068
2069 let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
2070 let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
2071 for b in position.b_uppercase.unwrap_or_default() {
2072 let Some(asset) = b.a.as_deref() else {
2073 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
2074 continue;
2075 };
2076 let side = if asset == base_asset.name().as_str() {
2078 &mut base_update
2079 } else if asset == quote_asset.name().as_str() {
2080 &mut quote_update
2081 } else {
2082 continue;
2083 };
2084 let (Some(free), Some(locked)) = (
2085 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2086 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2087 ) else {
2088 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
2089 continue;
2090 };
2091 *side = Some(AssetBalanceUpdate::new(
2092 AssetNameExchange::new(asset),
2093 BalanceUpdate::new(free, locked),
2094 time_exchange,
2095 ));
2096 }
2097
2098 let (Some(base), Some(quote)) = (base_update, quote_update) else {
2102 warn!(
2103 instrument = %instrument.name(),
2104 "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
2105 );
2106 return;
2107 };
2108 buf.push(UnindexedAccountEvent::new(
2109 ExchangeId::BinanceMargin,
2110 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
2111 instrument, base, quote,
2112 )),
2113 ));
2114}
2115
2116fn register_user_data_listener(
2127 ws: &Arc<WsApiBase>,
2128 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2129 dedup: SharedDedupCache,
2130 heartbeat_flag: Arc<AtomicBool>,
2131 signal_tx: oneshot::Sender<()>,
2132 mut handle_position: impl FnMut(
2133 OutboundAccountPosition,
2134 Option<i64>,
2135 &mut Vec<UnindexedAccountEvent>,
2136 ) + Send
2137 + 'static,
2138) -> Subscription {
2139 let mut signal_tx_opt = Some(signal_tx);
2140 let mut event_tx = Some(tx);
2141 let mut event_buf = Vec::with_capacity(32);
2145
2146 ws.common.events.subscribe(move |event| {
2150 let Some(ref sender) = event_tx else { return };
2151 match event {
2152 WebsocketEvent::Message(json_str) => {
2153 heartbeat_flag.store(true, Ordering::Release);
2154 let terminated = convert_margin_user_data_events_with(
2157 &json_str,
2158 &mut event_buf,
2159 &mut handle_position,
2160 );
2161 for ev in event_buf.drain(..) {
2162 if let Some(key) = dedup_key_from_event(&ev)
2163 && is_duplicate(&dedup, key)
2164 {
2165 trace!("BinanceMargin dedup: skipping duplicate event");
2166 continue;
2167 }
2168 if sender.send(ev).is_err() {
2169 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
2170 event_tx.take();
2171 if let Some(s) = signal_tx_opt.take() {
2172 let _ = s.send(());
2173 }
2174 return;
2175 }
2176 }
2177 if terminated {
2178 event_tx.take();
2179 if let Some(s) = signal_tx_opt.take() {
2180 let _ = s.send(());
2181 }
2182 }
2183 }
2184 WebsocketEvent::Ping | WebsocketEvent::Pong => {
2185 heartbeat_flag.store(true, Ordering::Release);
2186 }
2187 WebsocketEvent::Error(e) => {
2188 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
2189 event_tx.take();
2190 if let Some(s) = signal_tx_opt.take() {
2191 let _ = s.send(());
2192 }
2193 }
2194 WebsocketEvent::Close(code, reason) => {
2195 warn!(code, %reason, "BinanceMargin WebSocket closed");
2196 event_tx.take();
2197 if let Some(s) = signal_tx_opt.take() {
2198 let _ = s.send(());
2199 }
2200 }
2201 _ => {
2202 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
2203 }
2204 }
2205 })
2206}
2207
2208async fn recover_margin_fills(
2217 rest: &Arc<RestApi>,
2218 rate_limiter: &Arc<RateLimitTracker>,
2219 instruments: &[InstrumentNameExchange],
2220 disconnect_time: DateTime<Utc>,
2221 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2222 dedup: &SharedDedupCache,
2223 is_isolated: bool,
2224) {
2225 use futures::StreamExt as _;
2226
2227 if instruments.is_empty() {
2228 debug!("BinanceMargin recover_fills: empty instruments — no fills recovered");
2229 return;
2230 }
2231 info!(
2232 since = %disconnect_time,
2233 instruments = instruments.len(),
2234 "BinanceMargin recovering fills after reconnect"
2235 );
2236
2237 let start_time_ms = disconnect_time.timestamp_millis();
2238 let mut recovered = 0u32;
2239 let mut duplicates = 0u32;
2240 let mut failed_instruments = 0u32;
2241
2242 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
2243 let rest = rest.clone();
2244 let rl = rate_limiter.clone();
2245 async move {
2246 match paginate_margin_my_trades(&rest, &rl, &inst, start_time_ms, is_isolated).await {
2247 Ok(pages) => Some(
2248 pages
2249 .iter()
2250 .filter_map(|t| convert_margin_trade(t, &inst))
2251 .collect::<Vec<_>>(),
2252 ),
2253 Err(e) => {
2254 warn!(%e, %inst, "BinanceMargin fill recovery: REST request failed");
2255 None
2256 }
2257 }
2258 }
2259 }))
2260 .buffer_unordered(8);
2261 while let Some(result) = stream.next().await {
2262 let trades = match result {
2263 Some(t) => t,
2264 None => {
2265 failed_instruments += 1;
2266 continue;
2267 }
2268 };
2269 for trade in trades {
2270 let event = UnindexedAccountEvent::new(
2271 ExchangeId::BinanceMargin,
2272 AccountEventKind::Trade(trade),
2273 );
2274 if let Some(key) = dedup_key_from_event(&event)
2275 && is_duplicate(dedup, key)
2276 {
2277 duplicates += 1;
2278 continue;
2279 }
2280 if tx.send(event).is_err() {
2281 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2282 return;
2283 }
2284 recovered += 1;
2285 }
2286 }
2287
2288 if failed_instruments > 0 {
2289 error!(
2290 recovered,
2291 duplicates,
2292 failed_instruments,
2293 "BinanceMargin fill recovery complete with failures — some fills may be permanently missed"
2294 );
2295 } else {
2296 info!(
2297 recovered,
2298 duplicates, "BinanceMargin fill recovery complete"
2299 );
2300 }
2301}
2302
2303#[allow(
2310 clippy::cognitive_complexity,
2311 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2312 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2313)]
2314async fn margin_connection_manager(
2315 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2316 dedup: SharedDedupCache,
2317 ws_config: ConfigurationWebsocketApi,
2318 rest_config: Arc<ConfigurationRestApi>,
2319 rest: Arc<RestApi>,
2320 rate_limiter: Arc<RateLimitTracker>,
2321 instruments: Vec<InstrumentNameExchange>,
2322 initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2323) {
2324 enum DisconnectReason {
2325 Signal,
2326 HeartbeatTimeout,
2327 TokenRefresh,
2328 ConsumerDropped,
2329 }
2330
2331 let mut backoff = ExponentialBackoff::new();
2332 let mut disconnect_time: Option<DateTime<Utc>> = None;
2333 let (mut current_ws, mut current_token) = match initial {
2334 Some((ws, token)) => (Some(ws), Some(token)),
2335 None => (None, None),
2336 };
2337
2338 loop {
2339 let token = match current_token.take() {
2341 Some(t) => t,
2342 None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2343 Ok(t) => t,
2344 Err(e) => {
2345 error!(%e, "BinanceMargin userListenToken acquisition failed");
2346 if !backoff.wait().await {
2347 error!("BinanceMargin max reconnect attempts exhausted");
2348 emit_stream_terminated(
2349 &tx,
2350 ExchangeId::BinanceMargin,
2351 StreamTerminationReason::ReconnectBudgetExhausted {
2352 attempts: backoff.attempts(),
2353 last_error: e.to_string(),
2354 },
2355 );
2356 break;
2357 }
2358 continue;
2359 }
2360 },
2361 };
2362
2363 let ws = match current_ws.take() {
2365 Some(ws) => ws,
2366 None => match connect_margin_ws(&ws_config).await {
2367 Ok(ws) => ws,
2368 Err(e) => {
2369 error!(%e, "BinanceMargin WS connect failed");
2370 if !backoff.wait().await {
2371 error!("BinanceMargin max reconnect attempts exhausted");
2372 emit_stream_terminated(
2373 &tx,
2374 ExchangeId::BinanceMargin,
2375 StreamTerminationReason::ReconnectBudgetExhausted {
2376 attempts: backoff.attempts(),
2377 last_error: e.to_string(),
2378 },
2379 );
2380 break;
2381 }
2382 continue;
2383 }
2384 },
2385 };
2386
2387 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2389 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2391 let subscription = register_user_data_listener(
2394 &ws,
2395 tx.clone(),
2396 dedup.clone(),
2397 heartbeat_flag.clone(),
2398 signal_tx,
2399 cross_account_position_handler,
2400 );
2401
2402 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2404 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2405 subscription.unsubscribe();
2406 if let Err(de) = ws.disconnect().await {
2407 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2408 }
2409 if !backoff.wait().await {
2411 error!("BinanceMargin max reconnect attempts exhausted");
2412 emit_stream_terminated(
2413 &tx,
2414 ExchangeId::BinanceMargin,
2415 StreamTerminationReason::ReconnectBudgetExhausted {
2416 attempts: backoff.attempts(),
2417 last_error: e.to_string(),
2418 },
2419 );
2420 break;
2421 }
2422 continue;
2423 }
2424 info!("BinanceMargin account_stream connected and subscribed");
2425 backoff.reset();
2429
2430 let token_deadline =
2433 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2434
2435 if let Some(dt) = disconnect_time.take()
2437 && tokio::time::timeout(
2438 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2439 recover_margin_fills(&rest, &rate_limiter, &instruments, dt, &tx, &dedup, false),
2442 )
2443 .await
2444 .is_err()
2445 {
2446 warn!(
2447 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2448 "BinanceMargin fill recovery timed out — remaining instruments not queried"
2449 );
2450 }
2451
2452 let reason = {
2454 let mut signal_rx = signal_rx;
2455 loop {
2456 tokio::select! {
2457 biased;
2460 _ = tx.closed() => {
2461 debug!("BinanceMargin account_stream consumer dropped, terminating");
2462 break DisconnectReason::ConsumerDropped;
2463 }
2464 _ = &mut signal_rx => {
2465 warn!("BinanceMargin WS disconnected, will attempt reconnect");
2466 break DisconnectReason::Signal;
2467 }
2468 () = tokio::time::sleep_until(token_deadline) => {
2469 info!("BinanceMargin userListenToken nearing expiry, renewing");
2470 break DisconnectReason::TokenRefresh;
2471 }
2472 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2473 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2474 continue;
2478 }
2479 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2480 break DisconnectReason::HeartbeatTimeout;
2481 }
2482 }
2483 }
2484 };
2485 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2486
2487 if should_reconnect {
2488 disconnect_time = Some(match reason {
2489 DisconnectReason::HeartbeatTimeout => {
2490 Utc::now()
2491 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2492 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2493 }
2494 DisconnectReason::TokenRefresh => {
2495 Utc::now()
2500 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2501 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2502 }
2503 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2504 });
2505 }
2506
2507 subscription.unsubscribe();
2509 if let Err(e) = ws.disconnect().await {
2510 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2511 }
2512
2513 if !should_reconnect || tx.is_closed() {
2514 debug!("BinanceMargin connection manager exiting");
2516 break;
2517 }
2518
2519 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2522 error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
2523 let last_error = match reason {
2526 DisconnectReason::HeartbeatTimeout => {
2527 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2528 }
2529 _ => "WebSocket disconnected (server close/error)".to_string(),
2530 };
2531 emit_stream_terminated(
2532 &tx,
2533 ExchangeId::BinanceMargin,
2534 StreamTerminationReason::ReconnectBudgetExhausted {
2535 attempts: backoff.attempts(),
2536 last_error,
2537 },
2538 );
2539 break;
2540 }
2541 }
2542}
2543
2544fn build_base_quote_map(
2556 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2557) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2558 let mut map = HashMap::with_capacity(assets.len());
2559 for entry in assets {
2560 let (Some(symbol), Some(base), Some(quote)) = (
2561 entry.symbol.as_deref(),
2562 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2563 entry
2564 .quote_asset
2565 .as_deref()
2566 .and_then(|q| q.asset.as_deref()),
2567 ) else {
2568 warn!(
2569 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2570 skipping base/quote map entry"
2571 );
2572 continue;
2573 };
2574 map.insert(
2575 InstrumentNameExchange::new(symbol),
2576 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2577 );
2578 }
2579 map
2580}
2581
2582async fn acquire_all_isolated_tokens(
2595 rest_config: &Arc<ConfigurationRestApi>,
2596 rate_limiter: &Arc<RateLimitTracker>,
2597 symbols: &[InstrumentNameExchange],
2598) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
2599 use futures::{StreamExt as _, TryStreamExt as _};
2600
2601 futures::stream::iter(symbols.iter().cloned().map(|sym| {
2602 let rest_config = rest_config.clone();
2603 let rate_limiter = rate_limiter.clone();
2604 async move {
2605 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
2606 Ok::<_, UnindexedClientError>((sym, token))
2607 }
2608 }))
2609 .buffer_unordered(8)
2610 .try_collect()
2611 .await
2612}
2613
2614fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
2619 tokens
2620 .iter()
2621 .map(|(_, t)| t.expiration_time_ms)
2622 .min()
2623 .unwrap_or(i64::MAX)
2624}
2625
2626struct IsolatedLiveConn {
2629 ws: Arc<WsApiBase>,
2630 subscription: Subscription,
2631 signal_rx: oneshot::Receiver<()>,
2632 heartbeat_flag: Arc<AtomicBool>,
2633 earliest_expiry_ms: i64,
2636}
2637
2638async fn isolated_connect_and_subscribe(
2648 ws_config: &ConfigurationWebsocketApi,
2649 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2650 dedup: &SharedDedupCache,
2651 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2652 tokens: &[(InstrumentNameExchange, UserListenToken)],
2653) -> anyhow::Result<IsolatedLiveConn> {
2654 let ws = connect_margin_ws(ws_config).await?;
2655 let sub_map = Arc::new(Mutex::new(
2661 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
2662 ));
2663 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2664 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2666
2667 let handle_position = {
2668 let sub_map = sub_map.clone();
2669 let base_quote = base_quote.clone();
2670 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
2671 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
2672 }
2673 };
2674 let subscription = register_user_data_listener(
2676 &ws,
2677 tx.clone(),
2678 dedup.clone(),
2679 heartbeat_flag.clone(),
2680 signal_tx,
2681 handle_position,
2682 );
2683
2684 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
2685 for (sym, token) in tokens {
2686 match subscribe_listen_token_capture(&ws, &token.token).await {
2687 Ok(Some(id)) => {
2688 sub_map
2689 .lock()
2690 .unwrap_or_else(|p| p.into_inner())
2691 .insert(id, sym.clone());
2692 }
2693 Ok(None) => warn!(
2694 symbol = %sym.name(),
2695 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
2696 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
2697 ),
2698 Err(e) => {
2699 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
2702 subscription.unsubscribe();
2703 if let Err(de) = ws.disconnect().await {
2704 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
2705 }
2706 return Err(e);
2707 }
2708 }
2709 }
2710
2711 Ok(IsolatedLiveConn {
2712 ws,
2713 subscription,
2714 signal_rx,
2715 heartbeat_flag,
2716 earliest_expiry_ms,
2717 })
2718}
2719
2720#[allow(
2729 clippy::cognitive_complexity,
2730 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
2731 + backoff); mirrors the cross manager, not worth splitting further"
2732)]
2733async fn isolated_connection_manager(
2734 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2735 dedup: SharedDedupCache,
2736 ws_config: ConfigurationWebsocketApi,
2737 rest_config: Arc<ConfigurationRestApi>,
2738 rest: Arc<RestApi>,
2739 rate_limiter: Arc<RateLimitTracker>,
2740 symbols: Vec<InstrumentNameExchange>,
2741 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2742 initial: Option<IsolatedLiveConn>,
2743) {
2744 enum DisconnectReason {
2745 Signal,
2746 HeartbeatTimeout,
2747 TokenRefresh,
2748 ConsumerDropped,
2749 }
2750
2751 let mut backoff = ExponentialBackoff::new();
2752 let mut disconnect_time: Option<DateTime<Utc>> = None;
2753 let mut current = initial;
2754
2755 loop {
2756 let IsolatedLiveConn {
2758 ws,
2759 subscription,
2760 signal_rx,
2761 heartbeat_flag,
2762 earliest_expiry_ms,
2763 } = match current.take() {
2764 Some(live) => live,
2766 None => {
2767 let tokens = match acquire_all_isolated_tokens(
2770 &rest_config,
2771 &rate_limiter,
2772 &symbols,
2773 )
2774 .await
2775 {
2776 Ok(t) => t,
2777 Err(e) => {
2778 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
2779 if !backoff.wait().await {
2780 error!("BinanceMargin isolated max reconnect attempts exhausted");
2781 emit_stream_terminated(
2782 &tx,
2783 ExchangeId::BinanceMargin,
2784 StreamTerminationReason::ReconnectBudgetExhausted {
2785 attempts: backoff.attempts(),
2786 last_error: e.to_string(),
2787 },
2788 );
2789 break;
2790 }
2791 continue;
2792 }
2793 };
2794 match isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
2795 .await
2796 {
2797 Ok(live) => live,
2798 Err(e) => {
2799 error!(%e, "BinanceMargin isolated connect/subscribe failed");
2800 if !backoff.wait().await {
2801 error!("BinanceMargin isolated max reconnect attempts exhausted");
2802 emit_stream_terminated(
2803 &tx,
2804 ExchangeId::BinanceMargin,
2805 StreamTerminationReason::ReconnectBudgetExhausted {
2806 attempts: backoff.attempts(),
2807 last_error: e.to_string(),
2808 },
2809 );
2810 break;
2811 }
2812 continue;
2813 }
2814 }
2815 }
2816 };
2817
2818 info!(
2819 symbols = symbols.len(),
2820 "BinanceMargin isolated account_stream connected and subscribed"
2821 );
2822 backoff.reset();
2825
2826 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
2829
2830 if let Some(dt) = disconnect_time.take()
2832 && tokio::time::timeout(
2833 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2834 recover_margin_fills(&rest, &rate_limiter, &symbols, dt, &tx, &dedup, true),
2836 )
2837 .await
2838 .is_err()
2839 {
2840 warn!(
2841 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2842 "BinanceMargin isolated fill recovery timed out — remaining instruments not queried"
2843 );
2844 }
2845
2846 let reason = {
2849 let mut signal_rx = signal_rx;
2850 loop {
2851 tokio::select! {
2852 biased;
2853 _ = tx.closed() => {
2854 debug!("BinanceMargin isolated consumer dropped, terminating");
2855 break DisconnectReason::ConsumerDropped;
2856 }
2857 _ = &mut signal_rx => {
2858 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
2859 break DisconnectReason::Signal;
2860 }
2861 () = tokio::time::sleep_until(token_deadline) => {
2862 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
2863 break DisconnectReason::TokenRefresh;
2864 }
2865 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2866 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2867 continue;
2868 }
2869 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2870 break DisconnectReason::HeartbeatTimeout;
2871 }
2872 }
2873 }
2874 };
2875 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2876
2877 if should_reconnect {
2878 disconnect_time = Some(match reason {
2879 DisconnectReason::HeartbeatTimeout => {
2880 Utc::now()
2881 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2882 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2883 }
2884 DisconnectReason::TokenRefresh => {
2885 Utc::now()
2889 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2890 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2891 }
2892 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2893 });
2894 }
2895
2896 subscription.unsubscribe();
2898 if let Err(e) = ws.disconnect().await {
2899 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
2900 }
2901
2902 if !should_reconnect || tx.is_closed() {
2903 debug!("BinanceMargin isolated connection manager exiting");
2905 break;
2906 }
2907
2908 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2911 error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
2912 let last_error = match reason {
2915 DisconnectReason::HeartbeatTimeout => {
2916 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2917 }
2918 _ => "WebSocket disconnected (server close/error)".to_string(),
2919 };
2920 emit_stream_terminated(
2921 &tx,
2922 ExchangeId::BinanceMargin,
2923 StreamTerminationReason::ReconnectBudgetExhausted {
2924 attempts: backoff.attempts(),
2925 last_error,
2926 },
2927 );
2928 break;
2929 }
2930 }
2931}
2932
2933async fn fetch_margin_open_orders_for_instrument(
2940 rest: Arc<RestApi>,
2941 rate_limiter: Arc<RateLimitTracker>,
2942 instrument: InstrumentNameExchange,
2943 is_isolated: bool,
2944) -> Result<
2945 (
2946 InstrumentNameExchange,
2947 Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
2948 ),
2949 UnindexedClientError,
2950> {
2951 let symbol_str = instrument.name().to_string();
2953 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2954 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
2955 let sym = symbol_str.clone();
2956 let isolated = isolated.clone();
2957 Box::pin(async move {
2958 let params = QueryMarginAccountsOpenOrdersParams::builder()
2959 .symbol(sym)
2960 .is_isolated(isolated)
2961 .build()?;
2962 rest.query_margin_accounts_open_orders(params).await
2963 })
2964 })
2965 .await
2966 .map_err(connectivity_error)?;
2967
2968 let orders_data = response
2969 .data()
2970 .await
2971 .map_err(|e| connectivity_error(e.into()))?;
2972
2973 let orders = orders_data
2974 .into_iter()
2975 .filter_map(|o| convert_margin_open_order(&o, &instrument))
2976 .collect();
2977
2978 Ok((instrument, orders))
2979}
2980
2981async fn fetch_margin_all_open_orders(
2988 rest: Arc<RestApi>,
2989 rate_limiter: Arc<RateLimitTracker>,
2990 is_isolated: bool,
2991) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2992 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
2993 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
2994 let isolated = isolated.clone();
2995 Box::pin(async move {
2996 let params = QueryMarginAccountsOpenOrdersParams::builder()
2997 .is_isolated(isolated)
2998 .build()?;
2999 rest.query_margin_accounts_open_orders(params).await
3000 })
3001 })
3002 .await
3003 .map_err(connectivity_error)?;
3004
3005 let orders_data = response
3006 .data()
3007 .await
3008 .map_err(|e| connectivity_error(e.into()))?;
3009
3010 let orders = orders_data
3011 .into_iter()
3012 .filter_map(|o| convert_margin_open_order_owned_symbol(&o))
3013 .collect();
3014
3015 Ok(orders)
3016}
3017
3018async fn paginate_margin_my_trades(
3027 rest: &Arc<RestApi>,
3028 rate_limiter: &Arc<RateLimitTracker>,
3029 instrument: &InstrumentNameExchange,
3030 start_time_ms: i64,
3031 is_isolated: bool,
3032) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
3033 let symbol_str = instrument.name().to_string();
3035 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
3036 #[allow(clippy::cast_possible_wrap)]
3041 let limit = BINANCE_MAX_TRADES as i64;
3042 let mut all_pages = Vec::new();
3043 let mut cursor: Option<i64> = None;
3044 loop {
3045 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, |rest| {
3047 let sym = symbol_str.clone();
3048 let isolated = isolated.clone();
3049 let stm = start_time_ms;
3050 Box::pin(async move {
3051 let builder = QueryMarginAccountsTradeListParams::builder(sym)
3052 .is_isolated(isolated)
3053 .limit(limit);
3054 let params = if let Some(id) = fid {
3055 builder.from_id(id).build()?
3056 } else {
3057 builder.start_time(stm).build()?
3058 };
3059 rest.query_margin_accounts_trade_list(params).await
3060 })
3061 })
3062 .await
3063 .map_err(connectivity_error)?;
3064
3065 let page = response
3066 .data()
3067 .await
3068 .map_err(|e| connectivity_error(e.into()))?;
3069
3070 let page_len = page.len();
3071 let last_id = page.last().and_then(|t| t.id);
3072 all_pages.extend(page);
3073
3074 if page_len < BINANCE_MAX_TRADES {
3075 break;
3076 }
3077 match last_id {
3078 Some(id) => {
3079 debug!(%instrument, "BinanceMargin paginate_my_trades: fetching next page ({page_len} results)");
3080 match id.checked_add(1) {
3081 Some(next) => cursor = Some(next),
3082 None => break, }
3084 }
3085 None => {
3086 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, stopping pagination");
3087 break;
3088 }
3089 }
3090 }
3091 Ok(all_pages)
3092}
3093
3094fn convert_margin_balance_entry(
3104 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
3105 now: DateTime<Utc>,
3106) -> Option<AssetBalance<AssetNameExchange>> {
3107 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
3108 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3109 Some(v) => v,
3110 None => {
3111 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
3112 return None;
3113 }
3114 };
3115 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3116 Some(v) => v,
3117 None => {
3118 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
3119 return None;
3120 }
3121 };
3122 let borrowed = b
3127 .borrowed
3128 .as_deref()
3129 .and_then(|s| Decimal::from_str(s).ok())
3130 .unwrap_or(Decimal::ZERO);
3131 let interest = b
3132 .interest
3133 .as_deref()
3134 .and_then(|s| Decimal::from_str(s).ok())
3135 .unwrap_or(Decimal::ZERO);
3136 Some(AssetBalance::new(
3137 asset_name,
3138 Balance::new_margin(free + locked, free, borrowed, interest),
3139 now,
3140 ))
3141}
3142
3143fn filter_and_convert_margin_balances(
3146 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3147 assets: &[AssetNameExchange],
3148) -> Vec<AssetBalance<AssetNameExchange>> {
3149 let now = Utc::now();
3150 if assets.is_empty() {
3152 return user_assets
3153 .into_iter()
3154 .filter_map(|b| convert_margin_balance_entry(b, now))
3155 .collect();
3156 }
3157 if assets.len() <= 16 {
3159 return user_assets
3160 .into_iter()
3161 .filter_map(|b| {
3162 let asset_name_str = b.asset.as_deref()?;
3163 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3164 return None;
3165 }
3166 convert_margin_balance_entry(b, now)
3167 })
3168 .collect();
3169 }
3170 use std::collections::HashSet;
3171 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3172 user_assets
3173 .into_iter()
3174 .filter_map(|b| {
3175 let asset_name_str = b.asset.as_deref()?;
3176 if !asset_set.contains(asset_name_str) {
3177 return None;
3178 }
3179 convert_margin_balance_entry(b, now)
3180 })
3181 .collect()
3182}
3183
3184fn active_order_snapshot(
3191 o: Order<ExchangeId, InstrumentNameExchange, Open>,
3192) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3193{
3194 Order {
3195 key: o.key,
3196 side: o.side,
3197 price: o.price,
3198 quantity: o.quantity,
3199 kind: o.kind,
3200 time_in_force: o.time_in_force,
3201 state: OrderState::active(o.state),
3202 }
3203}
3204
3205fn convert_isolated_asset_balance(
3215 asset: Option<&str>,
3216 free: Option<&str>,
3217 locked: Option<&str>,
3218 borrowed: Option<&str>,
3219 interest: Option<&str>,
3220 now: DateTime<Utc>,
3221) -> Option<AssetBalance<AssetNameExchange>> {
3222 let asset_name = AssetNameExchange::new(asset?);
3223 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3224 Some(v) => v,
3225 None => {
3226 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3227 return None;
3228 }
3229 };
3230 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3231 Some(v) => v,
3232 None => {
3233 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3234 return None;
3235 }
3236 };
3237 let borrowed = borrowed
3238 .and_then(|s| Decimal::from_str(s).ok())
3239 .unwrap_or(Decimal::ZERO);
3240 let interest = interest
3241 .and_then(|s| Decimal::from_str(s).ok())
3242 .unwrap_or(Decimal::ZERO);
3243 Some(AssetBalance::new(
3244 asset_name,
3245 Balance::new_margin(free + locked, free, borrowed, interest),
3246 now,
3247 ))
3248}
3249
3250fn convert_isolated_margin_assets(
3258 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3259) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3260 let now = Utc::now();
3263 let mut map = HashMap::with_capacity(assets.len());
3264 for entry in assets {
3265 let Some(symbol) = entry.symbol.as_deref() else {
3266 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3267 continue;
3268 };
3269 let instrument = InstrumentNameExchange::new(symbol);
3270
3271 let Some(base_raw) = entry.base_asset.as_deref() else {
3272 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3273 continue;
3274 };
3275 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3276 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3277 continue;
3278 };
3279
3280 let Some(base) = convert_isolated_asset_balance(
3281 base_raw.asset.as_deref(),
3282 base_raw.free.as_deref(),
3283 base_raw.locked.as_deref(),
3284 base_raw.borrowed.as_deref(),
3285 base_raw.interest.as_deref(),
3286 now,
3287 ) else {
3288 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3289 continue;
3290 };
3291 let Some(quote) = convert_isolated_asset_balance(
3292 quote_raw.asset.as_deref(),
3293 quote_raw.free.as_deref(),
3294 quote_raw.locked.as_deref(),
3295 quote_raw.borrowed.as_deref(),
3296 quote_raw.interest.as_deref(),
3297 now,
3298 ) else {
3299 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3300 continue;
3301 };
3302
3303 let risk = IsolatedMarginRisk {
3304 margin_level: entry
3305 .margin_level
3306 .as_deref()
3307 .and_then(|s| Decimal::from_str(s).ok()),
3308 margin_ratio: entry
3309 .margin_ratio
3310 .as_deref()
3311 .and_then(|s| Decimal::from_str(s).ok()),
3312 liquidation_price: entry
3313 .liquidate_price
3314 .as_deref()
3315 .and_then(|s| Decimal::from_str(s).ok()),
3316 };
3317
3318 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3319 }
3320 map
3321}
3322
3323fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3326 symbols
3327 .chunks(5)
3328 .map(|chunk| {
3329 chunk
3330 .iter()
3331 .map(|s| s.name().as_str())
3332 .collect::<Vec<_>>()
3333 .join(",")
3334 })
3335 .collect()
3336}
3337
3338async fn fetch_isolated_margin_account_info(
3344 rest: Arc<RestApi>,
3345 rate_limiter: Arc<RateLimitTracker>,
3346 symbols: Vec<InstrumentNameExchange>,
3347) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
3348 use futures::{StreamExt as _, TryStreamExt as _};
3349
3350 let chunks = chunk_symbols(&symbols);
3351
3352 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3356 let rest = rest.clone();
3357 let rate_limiter = rate_limiter.clone();
3358 async move {
3359 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
3360 let symbols_param = symbols_param.clone();
3361 Box::pin(async move {
3362 let params = QueryIsolatedMarginAccountInfoParams::builder()
3363 .symbols(symbols_param)
3364 .build()?;
3365 rest.query_isolated_margin_account_info(params).await
3366 })
3367 })
3368 .await
3369 .map_err(connectivity_error)?;
3370
3371 let info = response
3372 .data()
3373 .await
3374 .map_err(|e| connectivity_error(e.into()))?;
3375 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3376 }
3377 }))
3378 .buffer_unordered(8)
3379 .try_fold(
3380 Vec::with_capacity(symbols.len()),
3381 |mut acc, assets| async move {
3382 acc.extend(assets);
3383 Ok(acc)
3384 },
3385 )
3386 .await
3387}
3388
3389fn convert_margin_open_order(
3396 o: &QueryMarginAccountsOpenOrdersResponseInner,
3397 instrument: &InstrumentNameExchange,
3398) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3399 let order_id_raw = match o.order_id {
3400 Some(id) => id,
3401 None => {
3402 warn!(%instrument, "BinanceMargin open order missing orderId");
3403 return None;
3404 }
3405 };
3406 let order_id = OrderId(format_smolstr!("{order_id_raw}"));
3407 if o.client_order_id.is_none() {
3408 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing clientOrderId, using orderId as fallback — order may not reconcile with engine state");
3409 }
3410 let cid = ClientOrderId::new(
3411 o.client_order_id
3412 .as_deref()
3413 .unwrap_or(&format_smolstr!("{order_id_raw}")),
3414 );
3415 let side = match o.side.as_deref() {
3416 Some(s) => parse_side(s)?,
3418 None => {
3419 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing side");
3420 return None;
3421 }
3422 };
3423 let price = o.price.as_deref().and_then(|s| Decimal::from_str(s).ok());
3424 let quantity = match o
3425 .orig_qty
3426 .as_deref()
3427 .and_then(|s| Decimal::from_str(s).ok())
3428 {
3429 Some(v) => v,
3430 None => {
3431 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing/unparseable origQty");
3432 return None;
3433 }
3434 };
3435 let filled_qty = match o.executed_qty.as_deref() {
3436 Some(s) => match Decimal::from_str(s) {
3437 Ok(v) => v,
3438 Err(_) => {
3439 warn!(%instrument, order_id = %order_id_raw, executed_qty = s, "BinanceMargin open order unparseable executedQty, defaulting to 0");
3440 Decimal::ZERO
3441 }
3442 },
3443 None => Decimal::ZERO,
3444 };
3445 let kind = match o.r#type.as_deref() {
3446 Some(t) => parse_order_kind(t)?,
3448 None => {
3449 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing type");
3450 return None;
3451 }
3452 };
3453 let time_in_force = parse_time_in_force(o.time_in_force.as_deref().unwrap_or("GTC"));
3454 let time_exchange = match o.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3455 Some(ts) => ts,
3456 None => {
3457 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing/unparseable time, using now");
3458 Utc::now()
3459 }
3460 };
3461
3462 Some(Order {
3463 key: OrderKey::new(
3464 ExchangeId::BinanceMargin,
3465 instrument.clone(),
3466 StrategyId::unknown(),
3469 cid,
3470 ),
3471 side,
3472 price,
3473 quantity,
3474 kind,
3475 time_in_force,
3476 state: Open::new(order_id, time_exchange, filled_qty),
3477 })
3478}
3479
3480fn convert_margin_open_order_owned_symbol(
3486 o: &QueryMarginAccountsOpenOrdersResponseInner,
3487) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3488 let instrument = match o.symbol.as_deref() {
3489 Some(s) => InstrumentNameExchange::new(s),
3490 None => {
3491 warn!("BinanceMargin open order missing symbol in return-all query, dropping order");
3492 return None;
3493 }
3494 };
3495 convert_margin_open_order(o, &instrument)
3496}
3497
3498fn convert_margin_trade(
3505 t: &QueryMarginAccountsTradeListResponseInner,
3506 instrument: &InstrumentNameExchange,
3507) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3508 let trade_id_raw = match t.id {
3509 Some(id) => id,
3510 None => {
3511 warn!(%instrument, "BinanceMargin trade missing id");
3512 return None;
3513 }
3514 };
3515 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3516 let order_id = match t.order_id {
3517 Some(id) => OrderId(format_smolstr!("{id}")),
3518 None => {
3519 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3520 return None;
3521 }
3522 };
3523 let side = match t.is_buyer {
3524 Some(true) => Side::Buy,
3525 Some(false) => Side::Sell,
3526 None => {
3527 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3528 return None;
3529 }
3530 };
3531 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3532 Some(v) => v,
3533 None => {
3534 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3535 return None;
3536 }
3537 };
3538 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3539 Some(v) => v,
3540 None => {
3541 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3542 return None;
3543 }
3544 };
3545 let commission = t
3546 .commission
3547 .as_deref()
3548 .and_then(|s| Decimal::from_str(s).ok())
3549 .unwrap_or(Decimal::ZERO);
3550 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3551 Some(ts) => ts,
3552 None => {
3553 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3554 Utc::now()
3555 }
3556 };
3557
3558 let fee_asset = t
3562 .commission_asset
3563 .as_deref()
3564 .map(AssetNameExchange::from)
3565 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3566
3567 Some(Trade::new(
3568 trade_id,
3569 order_id,
3570 instrument.clone(),
3571 StrategyId::unknown(), time_exchange,
3573 side,
3574 price,
3575 quantity,
3576 None,
3578 AssetFees::new(fee_asset, commission, None),
3579 ))
3580}
3581
3582#[derive(Debug)]
3588enum BuildOrderError {
3589 Unsupported,
3591 Build(String),
3593}
3594
3595trait IsolatedFlag {
3606 fn from_flag(is_isolated: bool) -> Self;
3608}
3609
3610macro_rules! impl_isolated_flag {
3615 ($($t:ty),* $(,)?) => {
3616 $(
3617 impl IsolatedFlag for $t {
3618 fn from_flag(is_isolated: bool) -> Self {
3619 if is_isolated { Self::True } else { Self::False }
3620 }
3621 }
3622 )*
3623 };
3624}
3625
3626impl_isolated_flag!(
3627 MarginAccountNewOrderIsIsolatedEnum,
3628 MarginAccountCancelOrderIsIsolatedEnum,
3629 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
3630 QueryMarginAccountsTradeListIsIsolatedEnum,
3631);
3632
3633fn build_cancel_order_params(
3641 symbol: String,
3642 id: Option<&OrderId>,
3643 cid: &ClientOrderId,
3644 is_isolated: bool,
3645) -> Result<MarginAccountCancelOrderParams, String> {
3646 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
3647 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
3648 );
3649
3650 match id {
3651 Some(order_id) => match order_id.0.parse::<i64>() {
3652 Ok(id) => builder = builder.order_id(id),
3653 Err(_) => {
3654 error!(
3656 order_id = %order_id.0,
3657 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3658 );
3659 builder = builder.orig_client_order_id(cid.0.to_string());
3660 }
3661 },
3662 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3663 }
3664
3665 builder.build().map_err(|e| e.to_string())
3666}
3667
3668#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3677 symbol: String,
3678 side: Side,
3679 price: Option<Decimal>,
3680 quantity: Decimal,
3681 kind: OrderKind,
3682 time_in_force: TimeInForce,
3683 new_client_order_id: String,
3684 side_effect: MarginSideEffect,
3685 is_isolated: bool,
3686) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3687 let binance_side = match side {
3688 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3689 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3690 };
3691
3692 let (binance_type, binance_tif) =
3693 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3694
3695 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
3697 .quantity(quantity)
3698 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
3699 .side_effect_type(side_effect.as_sdk_enum())
3700 .new_client_order_id(new_client_order_id)
3701 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3702
3703 if side_effect == MarginSideEffect::AutoBorrowRepay {
3706 builder = builder.auto_repay_at_cancel(true);
3707 }
3708
3709 if let Some(tif) = binance_tif {
3710 builder = builder.time_in_force(tif);
3711 }
3712
3713 match kind {
3716 OrderKind::Limit => {
3717 builder = builder.price(price);
3718 }
3719 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3720 builder = builder.stop_price(trigger_price);
3721 }
3722 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3723 builder = builder.price(price).stop_price(trigger_price);
3724 }
3725 _ => {}
3729 }
3730
3731 builder
3732 .build()
3733 .map_err(|e| BuildOrderError::Build(e.to_string()))
3734}
3735
3736fn convert_order_kind_tif_margin(
3746 kind: OrderKind,
3747 tif: TimeInForce,
3748) -> Option<(
3749 MarginAccountNewOrderTypeEnum,
3750 Option<MarginAccountNewOrderTimeInForceEnum>,
3751)> {
3752 if matches!(
3753 kind,
3754 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. }
3755 ) {
3756 warn!(
3757 ?kind,
3758 "BinanceMargin does not support trailing-stop orders (SDK trailingDelta binding gap)"
3759 );
3760 return None;
3761 }
3762
3763 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3764 let margin_type = match binance_type {
3765 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
3766 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
3767 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
3768 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
3769 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
3770 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
3771 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
3772 };
3773 let margin_tif = binance_tif.map(|t| match t {
3774 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3775 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3776 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3777 });
3778 Some((margin_type, margin_tif))
3779}
3780
3781fn margin_avg_price(cummulative_quote_qty: Option<&str>, filled_qty: Decimal) -> Option<Decimal> {
3787 if filled_qty.is_zero() {
3788 return None;
3789 }
3790 let s = cummulative_quote_qty?;
3791 match Decimal::from_str(s) {
3792 Ok(cumulative) => cumulative.checked_div(filled_qty),
3793 Err(_) => {
3794 warn!(
3797 cummulative_quote_qty = s,
3798 "BinanceMargin: failed to parse cummulativeQuoteQty; avg price unavailable"
3799 );
3800 None
3801 }
3802 }
3803}
3804
3805#[cfg(test)]
3806#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3808 use super::*;
3809
3810 #[test]
3811 fn margin_side_effect_default_is_auto_borrow_repay() {
3812 assert_eq!(
3813 MarginSideEffect::default(),
3814 MarginSideEffect::AutoBorrowRepay
3815 );
3816 }
3817
3818 #[test]
3819 fn margin_side_effect_wire_strings() {
3820 assert_eq!(
3821 MarginSideEffect::AutoBorrowRepay.as_binance_str(),
3822 "AUTO_BORROW_REPAY"
3823 );
3824 assert_eq!(
3825 MarginSideEffect::NoBorrow.as_binance_str(),
3826 "NO_SIDE_EFFECT"
3827 );
3828 }
3829
3830 #[test]
3831 fn margin_side_effect_serde_round_trip() {
3832 assert_eq!(
3834 serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
3835 r#""auto_borrow_repay""#
3836 );
3837 assert_eq!(
3838 serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
3839 r#""no_borrow""#
3840 );
3841 assert_eq!(
3842 serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
3843 MarginSideEffect::AutoBorrowRepay
3844 );
3845 assert_eq!(
3846 serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
3847 MarginSideEffect::NoBorrow
3848 );
3849 }
3850
3851 #[test]
3852 fn config_debug_redacts_secrets() {
3853 let config = BinanceMarginConfig::new(
3854 "my_api_key".to_string(),
3855 "my_secret_key".to_string(),
3856 false,
3857 false,
3858 Vec::new(),
3859 MarginSideEffect::default(),
3860 );
3861 let debug = format!("{config:?}");
3862 assert!(!debug.contains("my_api_key"));
3863 assert!(!debug.contains("my_secret_key"));
3864 assert!(debug.contains("***"));
3865 }
3866
3867 #[test]
3868 fn cross_margin_uses_common_case_defaults() {
3869 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3870 assert!(!config.testnet);
3871 assert!(!config.is_isolated);
3872 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3873 assert_eq!(config.api_key(), "k");
3874 }
3875
3876 #[test]
3877 fn config_deserializes_with_defaults() {
3878 let config: BinanceMarginConfig =
3880 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3881 .expect("deserialize");
3882 assert!(!config.is_isolated);
3883 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3884 }
3885
3886 #[test]
3887 fn config_deserializes_explicit_side_effect() {
3888 let config: BinanceMarginConfig = serde_json::from_str(
3889 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
3890 )
3891 .expect("deserialize");
3892 assert!(config.is_isolated);
3893 assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
3894 }
3895
3896 #[test]
3897 fn isolated_ctor_sets_symbols_and_flag() {
3898 let symbols = vec![
3899 InstrumentNameExchange::new("BTCUSDT"),
3900 InstrumentNameExchange::new("ETHUSDT"),
3901 ];
3902 let config =
3903 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
3904 assert!(config.is_isolated);
3905 assert_eq!(config.isolated_symbols, symbols);
3906 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3907 }
3908
3909 #[test]
3910 fn isolated_config_with_symbols_constructs() {
3911 let config = BinanceMarginConfig::isolated(
3913 "k".to_string(),
3914 "s".to_string(),
3915 vec![InstrumentNameExchange::new("BTCUSDT")],
3916 );
3917 let _client = BinanceMargin::new(config); }
3919
3920 #[test]
3921 #[should_panic(expected = "non-empty isolated_symbols")]
3922 fn isolated_empty_symbols_panics_at_new() {
3923 let config = BinanceMarginConfig::new(
3925 "k".to_string(),
3926 "s".to_string(),
3927 false,
3928 true,
3929 Vec::new(),
3930 MarginSideEffect::default(),
3931 );
3932 let _ = BinanceMargin::new(config);
3933 }
3934
3935 #[test]
3936 #[should_panic(expected = "non-empty isolated_symbols")]
3937 fn isolated_empty_symbols_panics_via_deserialize_path() {
3938 let config: BinanceMarginConfig = serde_json::from_str(
3941 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
3942 )
3943 .expect("deserialize");
3944 assert!(config.isolated_symbols.is_empty());
3945 let _ = BinanceMargin::new(config);
3946 }
3947
3948 #[test]
3949 fn isolated_flag_maps_mode() {
3950 assert_eq!(
3953 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
3954 "TRUE"
3955 );
3956 assert_eq!(
3957 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
3958 "FALSE"
3959 );
3960 assert_eq!(
3961 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
3962 "TRUE"
3963 );
3964 assert_eq!(
3965 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
3966 "FALSE"
3967 );
3968 assert_eq!(
3969 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
3970 "TRUE"
3971 );
3972 assert_eq!(
3973 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
3974 "FALSE"
3975 );
3976 assert_eq!(
3977 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
3978 "TRUE"
3979 );
3980 assert_eq!(
3981 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
3982 "FALSE"
3983 );
3984 }
3985
3986 #[test]
3991 fn cancel_params_cross_with_parseable_order_id() {
3992 let p = build_cancel_order_params(
3993 "BTCUSDT".to_string(),
3994 Some(&OrderId::new("12345")),
3995 &ClientOrderId::new("cid-1"),
3996 false,
3997 )
3998 .expect("build");
3999 assert_eq!(p.symbol, "BTCUSDT");
4000 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
4001 assert_eq!(p.order_id, Some(12345));
4002 assert_eq!(p.orig_client_order_id, None);
4003 }
4004
4005 #[test]
4006 fn cancel_params_isolated_is_config_driven() {
4007 let p = build_cancel_order_params(
4008 "BTCUSDT".to_string(),
4009 Some(&OrderId::new("12345")),
4010 &ClientOrderId::new("cid-1"),
4011 true,
4012 )
4013 .expect("build");
4014 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
4015 assert_eq!(p.order_id, Some(12345));
4016 }
4017
4018 #[test]
4019 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
4020 let p = build_cancel_order_params(
4021 "BTCUSDT".to_string(),
4022 Some(&OrderId::new("not-an-i64")),
4023 &ClientOrderId::new("cid-1"),
4024 false,
4025 )
4026 .expect("build");
4027 assert_eq!(p.order_id, None);
4028 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4029 }
4030
4031 #[test]
4032 fn cancel_params_absent_order_id_uses_cid() {
4033 let p = build_cancel_order_params(
4034 "BTCUSDT".to_string(),
4035 None,
4036 &ClientOrderId::new("cid-1"),
4037 false,
4038 )
4039 .expect("build");
4040 assert_eq!(p.order_id, None);
4041 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
4042 }
4043
4044 use crate::order::TrailingOffsetType;
4049
4050 fn params(
4052 side: Side,
4053 price: Option<Decimal>,
4054 kind: OrderKind,
4055 tif: TimeInForce,
4056 side_effect: MarginSideEffect,
4057 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
4058 build_new_order_params(
4059 "BTCUSDT".to_string(),
4060 side,
4061 price,
4062 Decimal::from(2),
4063 kind,
4064 tif,
4065 "cid-1".to_string(),
4066 side_effect,
4067 false, )
4069 }
4070
4071 fn gtc() -> TimeInForce {
4072 TimeInForce::GoodUntilCancelled { post_only: false }
4073 }
4074
4075 #[test]
4076 fn new_order_limit_maps_core_fields() {
4077 let p = params(
4078 Side::Buy,
4079 Some(Decimal::from(50_000)),
4080 OrderKind::Limit,
4081 gtc(),
4082 MarginSideEffect::AutoBorrowRepay,
4083 )
4084 .expect("build");
4085
4086 assert_eq!(p.symbol, "BTCUSDT");
4087 assert_eq!(p.side.as_str(), "BUY");
4088 assert_eq!(p.r#type.as_str(), "LIMIT");
4089 assert_eq!(p.quantity, Some(Decimal::from(2)));
4090 assert_eq!(p.price, Some(Decimal::from(50_000)));
4091 assert_eq!(p.stop_price, None);
4092 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
4093 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
4094 assert_eq!(
4096 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
4097 Some("FULL")
4098 );
4099 }
4100
4101 #[test]
4102 fn new_order_is_isolated_is_config_driven() {
4103 let cross = build_new_order_params(
4105 "BTCUSDT".to_string(),
4106 Side::Sell,
4107 Some(Decimal::from(10)),
4108 Decimal::from(2),
4109 OrderKind::Limit,
4110 gtc(),
4111 "cid-1".to_string(),
4112 MarginSideEffect::AutoBorrowRepay,
4113 false,
4114 )
4115 .expect("build");
4116 assert_eq!(
4117 cross.is_isolated.as_ref().map(|i| i.as_str()),
4118 Some("FALSE")
4119 );
4120 assert_eq!(cross.side.as_str(), "SELL");
4121
4122 let isolated = build_new_order_params(
4124 "BTCUSDT".to_string(),
4125 Side::Sell,
4126 Some(Decimal::from(10)),
4127 Decimal::from(2),
4128 OrderKind::Limit,
4129 gtc(),
4130 "cid-1".to_string(),
4131 MarginSideEffect::AutoBorrowRepay,
4132 true,
4133 )
4134 .expect("build");
4135 assert_eq!(
4136 isolated.is_isolated.as_ref().map(|i| i.as_str()),
4137 Some("TRUE")
4138 );
4139 }
4140
4141 #[test]
4142 fn side_effect_and_auto_repay_gating() {
4143 let auto = params(
4145 Side::Buy,
4146 Some(Decimal::from(1)),
4147 OrderKind::Limit,
4148 gtc(),
4149 MarginSideEffect::AutoBorrowRepay,
4150 )
4151 .expect("build");
4152 assert_eq!(
4153 auto.side_effect_type.as_ref().map(|s| s.as_str()),
4154 Some("AUTO_BORROW_REPAY")
4155 );
4156 assert_eq!(auto.auto_repay_at_cancel, Some(true));
4157
4158 let no_borrow = params(
4160 Side::Buy,
4161 Some(Decimal::from(1)),
4162 OrderKind::Limit,
4163 gtc(),
4164 MarginSideEffect::NoBorrow,
4165 )
4166 .expect("build");
4167 assert_eq!(
4168 no_borrow.side_effect_type.as_ref().map(|s| s.as_str()),
4169 Some("NO_SIDE_EFFECT")
4170 );
4171 assert_eq!(no_borrow.auto_repay_at_cancel, None);
4172 }
4173
4174 #[test]
4175 fn new_order_market_has_no_price_or_tif() {
4176 let p = params(
4177 Side::Buy,
4178 None,
4179 OrderKind::Market,
4180 gtc(),
4181 MarginSideEffect::AutoBorrowRepay,
4182 )
4183 .expect("build");
4184 assert_eq!(p.r#type.as_str(), "MARKET");
4185 assert_eq!(p.price, None);
4186 assert_eq!(p.stop_price, None);
4187 assert!(p.time_in_force.is_none());
4188 }
4189
4190 #[test]
4191 fn post_only_limit_maps_to_limit_maker() {
4192 let p = params(
4193 Side::Buy,
4194 Some(Decimal::from(10)),
4195 OrderKind::Limit,
4196 TimeInForce::GoodUntilCancelled { post_only: true },
4197 MarginSideEffect::AutoBorrowRepay,
4198 )
4199 .expect("build");
4200 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
4201 assert!(p.time_in_force.is_none());
4203 }
4204
4205 #[test]
4206 fn conditional_kinds_set_stop_price() {
4207 let trigger = Decimal::from(48_000);
4208
4209 let stop = params(
4210 Side::Sell,
4211 None,
4212 OrderKind::Stop {
4213 trigger_price: trigger,
4214 },
4215 gtc(),
4216 MarginSideEffect::AutoBorrowRepay,
4217 )
4218 .expect("build");
4219 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
4220 assert_eq!(stop.stop_price, Some(trigger));
4221 assert_eq!(stop.price, None);
4222
4223 let stop_limit = params(
4224 Side::Sell,
4225 Some(Decimal::from(47_900)),
4226 OrderKind::StopLimit {
4227 trigger_price: trigger,
4228 },
4229 gtc(),
4230 MarginSideEffect::AutoBorrowRepay,
4231 )
4232 .expect("build");
4233 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
4234 assert_eq!(stop_limit.stop_price, Some(trigger));
4235 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4236 assert_eq!(
4237 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4238 Some("GTC")
4239 );
4240
4241 let take_profit = params(
4242 Side::Sell,
4243 None,
4244 OrderKind::TakeProfit {
4245 trigger_price: trigger,
4246 },
4247 gtc(),
4248 MarginSideEffect::AutoBorrowRepay,
4249 )
4250 .expect("build");
4251 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
4252 assert_eq!(take_profit.stop_price, Some(trigger));
4253 }
4254
4255 #[test]
4256 fn trailing_kinds_are_rejected() {
4257 let trailing_stop = params(
4258 Side::Sell,
4259 None,
4260 OrderKind::TrailingStop {
4261 offset: Decimal::from(100),
4262 offset_type: TrailingOffsetType::BasisPoints,
4263 },
4264 gtc(),
4265 MarginSideEffect::AutoBorrowRepay,
4266 );
4267 assert!(matches!(trailing_stop, Err(BuildOrderError::Unsupported)));
4268
4269 let trailing_stop_limit = params(
4270 Side::Sell,
4271 Some(Decimal::from(100)),
4272 OrderKind::TrailingStopLimit {
4273 offset: Decimal::from(100),
4274 offset_type: TrailingOffsetType::BasisPoints,
4275 limit_offset: Decimal::from(10),
4276 },
4277 gtc(),
4278 MarginSideEffect::AutoBorrowRepay,
4279 );
4280 assert!(matches!(
4281 trailing_stop_limit,
4282 Err(BuildOrderError::Unsupported)
4283 ));
4284 }
4285
4286 #[test]
4287 fn tif_variants_map_to_margin_enum() {
4288 for (tif, expected) in [
4289 (TimeInForce::ImmediateOrCancel, "IOC"),
4290 (TimeInForce::FillOrKill, "FOK"),
4291 (gtc(), "GTC"),
4292 ] {
4293 let p = params(
4294 Side::Buy,
4295 Some(Decimal::from(1)),
4296 OrderKind::Limit,
4297 tif,
4298 MarginSideEffect::AutoBorrowRepay,
4299 )
4300 .expect("build");
4301 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4302 }
4303 }
4304
4305 #[test]
4306 fn avg_price_from_cumulative_quote_qty() {
4307 assert_eq!(
4309 margin_avg_price(Some("100"), Decimal::from(4)),
4310 Some(Decimal::from(25))
4311 );
4312 assert_eq!(margin_avg_price(Some("100"), Decimal::ZERO), None);
4314 assert_eq!(margin_avg_price(None, Decimal::from(4)), None);
4316 assert_eq!(
4317 margin_avg_price(Some("not-a-number"), Decimal::from(4)),
4318 None
4319 );
4320 }
4321
4322 fn user_asset(
4327 asset: &str,
4328 free: &str,
4329 locked: &str,
4330 borrowed: &str,
4331 interest: &str,
4332 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4333 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4334 a.asset = Some(asset.to_string());
4335 a.free = Some(free.to_string());
4336 a.locked = Some(locked.to_string());
4337 a.borrowed = Some(borrowed.to_string());
4338 a.interest = Some(interest.to_string());
4339 a
4340 }
4341
4342 #[test]
4343 fn margin_balance_entry_maps_debt() {
4344 let ab =
4346 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4347 .expect("convert");
4348 assert_eq!(ab.asset.name().as_str(), "USDT");
4349 assert_eq!(ab.balance.total, Decimal::from(12));
4350 assert_eq!(ab.balance.free, Decimal::from(10));
4351 assert!(
4352 ab.balance.margin.is_some(),
4353 "REST snapshot must populate margin"
4354 );
4355 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4357 }
4358
4359 #[test]
4360 fn margin_balance_short_is_negative_net() {
4361 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4363 .expect("convert");
4364 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4365 }
4366
4367 #[test]
4368 fn margin_balance_missing_debt_defaults_to_zero() {
4369 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4372 a.asset = Some("ETH".to_string());
4373 a.free = Some("4".to_string());
4374 a.locked = Some("0".to_string());
4375 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4376 assert!(ab.balance.margin.is_some());
4377 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4378 }
4379
4380 #[test]
4381 fn margin_balance_missing_free_is_dropped() {
4382 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4384 a.asset = Some("USDT".to_string());
4385 a.locked = Some("0".to_string());
4386 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4387 }
4388
4389 #[test]
4390 fn margin_balance_filtering() {
4391 let assets = vec![
4392 user_asset("USDT", "10", "0", "0", "0"),
4393 user_asset("BTC", "1", "0", "0", "0"),
4394 user_asset("ETH", "5", "0", "0", "0"),
4395 ];
4396 assert_eq!(
4398 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4399 3
4400 );
4401 let filtered = filter_and_convert_margin_balances(
4403 assets,
4404 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4405 );
4406 assert_eq!(filtered.len(), 2);
4407 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4408 }
4409
4410 #[test]
4411 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4412 let assets = vec![
4414 user_asset("BTC", "1", "0", "0", "0"),
4415 user_asset("ETH", "5", "0", "0", "0"),
4416 user_asset("DOGE", "9", "0", "0", "0"), ];
4418 let mut requested: Vec<AssetNameExchange> = (0..17)
4420 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4421 .collect();
4422 requested.push(AssetNameExchange::new("BTC"));
4423 requested.push(AssetNameExchange::new("ETH"));
4424 assert!(
4425 requested.len() > 16,
4426 "must exceed the linear-scan threshold"
4427 );
4428
4429 let filtered = filter_and_convert_margin_balances(assets, &requested);
4430 assert_eq!(filtered.len(), 2);
4431 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4432 }
4433
4434 use binance_sdk::margin_trading::rest_api::{
4439 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4440 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4441 };
4442
4443 fn d(s: &str) -> Decimal {
4444 Decimal::from_str(s).unwrap()
4445 }
4446
4447 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4448 let mut b = IsoBase::new();
4449 b.asset = Some(asset.to_string());
4450 b.free = Some(free.to_string());
4451 b.locked = Some(locked.to_string());
4452 b.borrowed = Some(borrowed.to_string());
4453 b.interest = Some(interest.to_string());
4454 b
4455 }
4456
4457 fn iso_quote(
4458 asset: &str,
4459 free: &str,
4460 locked: &str,
4461 borrowed: &str,
4462 interest: &str,
4463 ) -> IsoQuote {
4464 let mut q = IsoQuote::new();
4465 q.asset = Some(asset.to_string());
4466 q.free = Some(free.to_string());
4467 q.locked = Some(locked.to_string());
4468 q.borrowed = Some(borrowed.to_string());
4469 q.interest = Some(interest.to_string());
4470 q
4471 }
4472
4473 fn iso_entry(
4474 symbol: &str,
4475 base: IsoBase,
4476 quote: IsoQuote,
4477 margin_level: Option<&str>,
4478 margin_ratio: Option<&str>,
4479 liquidate_price: Option<&str>,
4480 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4481 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4482 e.symbol = Some(symbol.to_string());
4483 e.base_asset = Some(Box::new(base));
4484 e.quote_asset = Some(Box::new(quote));
4485 e.margin_level = margin_level.map(str::to_string);
4486 e.margin_ratio = margin_ratio.map(str::to_string);
4487 e.liquidate_price = liquidate_price.map(str::to_string);
4488 e
4489 }
4490
4491 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4492 let config = BinanceMarginConfig::isolated(
4493 "k".to_string(),
4494 "s".to_string(),
4495 symbols
4496 .iter()
4497 .map(|s| InstrumentNameExchange::new(*s))
4498 .collect(),
4499 );
4500 BinanceMargin::new(config)
4501 }
4502
4503 #[test]
4504 fn isolated_assets_map_base_quote_and_risk() {
4505 let entry = iso_entry(
4508 "BTCUSDT",
4509 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4510 iso_quote("USDT", "1000", "50", "300", "1.5"),
4511 Some("3.5"),
4512 Some("0.12"),
4513 Some("48000"),
4514 );
4515 let map = convert_isolated_margin_assets(vec![entry]);
4516 let state = map
4517 .get(&InstrumentNameExchange::new("BTCUSDT"))
4518 .expect("entry present");
4519
4520 assert_eq!(state.base.asset.name().as_str(), "BTC");
4521 assert_eq!(state.base.balance.total, d("0.6"));
4522 assert_eq!(state.base.balance.free, d("0.5"));
4523 assert_eq!(state.base.balance.net_asset(), d("0.4"));
4524
4525 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4526 assert_eq!(state.quote.balance.total, d("1050"));
4527 assert_eq!(state.quote.balance.net_asset(), d("750"));
4528
4529 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4530 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4531 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4532 }
4533
4534 #[test]
4535 fn isolated_base_short_is_negative_net() {
4536 let entry = iso_entry(
4538 "BTCUSDT",
4539 iso_base("BTC", "0", "0", "1.5", "0.001"),
4540 iso_quote("USDT", "100", "0", "0", "0"),
4541 None,
4542 None,
4543 None,
4544 );
4545 let map = convert_isolated_margin_assets(vec![entry]);
4546 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4547 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4548 }
4549
4550 #[test]
4551 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4552 let mut base = IsoBase::new();
4555 base.asset = Some("BTC".to_string());
4556 base.free = Some("0.5".to_string());
4557 base.locked = Some("0".to_string());
4558 let mut quote = IsoQuote::new();
4559 quote.asset = Some("USDT".to_string());
4560 quote.free = Some("100".to_string());
4561 quote.locked = Some("0".to_string());
4562
4563 let map = convert_isolated_margin_assets(vec![iso_entry(
4564 "BTCUSDT", base, quote, None, None, None,
4565 )]);
4566 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4567 assert!(state.base.balance.margin.is_some());
4568 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4569 assert_eq!(state.risk.margin_level, None);
4570 assert_eq!(state.risk.liquidation_price, None);
4571 }
4572
4573 #[test]
4574 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4575 let entry = iso_entry(
4576 "BTCUSDT",
4577 iso_base("BTC", "1", "0", "0", "0"),
4578 iso_quote("USDT", "1", "0", "0", "0"),
4579 Some("not_a_number"),
4580 None,
4581 None,
4582 );
4583 let map = convert_isolated_margin_assets(vec![entry]);
4584 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4585 assert_eq!(state.risk.margin_level, None);
4586 }
4587
4588 #[test]
4589 fn isolated_missing_base_free_drops_entry() {
4590 let mut base = IsoBase::new();
4592 base.asset = Some("BTC".to_string());
4593 base.locked = Some("0".to_string()); let entry = iso_entry(
4595 "BTCUSDT",
4596 base,
4597 iso_quote("USDT", "100", "0", "0", "0"),
4598 None,
4599 None,
4600 None,
4601 );
4602 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4603 }
4604
4605 #[test]
4606 fn isolated_missing_symbol_drops_entry() {
4607 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4608 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4609 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4610 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4612 }
4613
4614 #[test]
4615 fn chunk_symbols_batches_by_five() {
4616 let syms: Vec<InstrumentNameExchange> = (0..12)
4617 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4618 .collect();
4619 let chunks = chunk_symbols(&syms);
4620 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4621 assert_eq!(chunks[0].split(',').count(), 5);
4622 assert_eq!(chunks[1].split(',').count(), 5);
4623 assert_eq!(chunks[2].split(',').count(), 2);
4624
4625 let three: Vec<_> = (0..3)
4627 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4628 .collect();
4629 assert_eq!(chunk_symbols(&three).len(), 1);
4630
4631 let five: Vec<_> = (0..5)
4633 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4634 .collect();
4635 let c = chunk_symbols(&five);
4636 assert_eq!(c.len(), 1);
4637 assert_eq!(c[0].split(',').count(), 5);
4638
4639 assert!(chunk_symbols(&[]).is_empty());
4641 }
4642
4643 #[test]
4644 fn effective_isolated_set_empty_returns_all_configured() {
4645 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4646 assert_eq!(
4647 client.effective_isolated_set(&[]),
4648 vec![
4649 InstrumentNameExchange::new("BTCUSDT"),
4650 InstrumentNameExchange::new("ETHUSDT"),
4651 ]
4652 );
4653 }
4654
4655 #[test]
4656 fn effective_isolated_set_intersects_requested() {
4657 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4658 assert_eq!(
4659 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4660 vec![InstrumentNameExchange::new("ETHUSDT")]
4661 );
4662 }
4663
4664 #[test]
4665 fn effective_isolated_set_skips_out_of_set() {
4666 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4667 assert_eq!(
4669 client.effective_isolated_set(&[
4670 InstrumentNameExchange::new("BTCUSDT"),
4671 InstrumentNameExchange::new("DOGEUSDT"),
4672 ]),
4673 vec![InstrumentNameExchange::new("BTCUSDT")]
4674 );
4675 }
4676
4677 #[tokio::test]
4678 async fn fetch_balances_isolated_returns_empty() {
4679 let client = isolated_client(&["BTCUSDT"]);
4682 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4683 }
4684
4685 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4686 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4687 o.order_id = order_id;
4688 o.client_order_id = Some("cid-9".to_string());
4689 o.side = Some(side.to_string());
4690 o.price = Some("50000".to_string());
4691 o.orig_qty = Some("2".to_string());
4692 o.executed_qty = Some("0.5".to_string());
4693 o.r#type = Some("LIMIT".to_string());
4694 o.time_in_force = Some("GTC".to_string());
4695 o.time = Some(1_700_000_000_000);
4696 o
4697 }
4698
4699 #[test]
4700 fn margin_open_order_converts() {
4701 let inst = InstrumentNameExchange::new("BTCUSDT");
4702 let order =
4703 convert_margin_open_order(&open_order(Some(42), "BUY"), &inst).expect("convert");
4704 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4705 assert_eq!(order.state.id.0.as_str(), "42");
4706 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4707 assert_eq!(order.side, Side::Buy);
4708 assert_eq!(order.price, Some(Decimal::from(50_000)));
4709 assert_eq!(order.quantity, Decimal::from(2));
4710 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4711 assert_eq!(order.kind, OrderKind::Limit);
4712 }
4713
4714 #[test]
4715 fn margin_open_order_missing_order_id_is_dropped() {
4716 let inst = InstrumentNameExchange::new("BTCUSDT");
4717 assert!(convert_margin_open_order(&open_order(None, "BUY"), &inst).is_none());
4718 }
4719
4720 #[test]
4721 fn margin_open_order_owned_symbol_recovers_instrument() {
4722 let mut o = open_order(Some(42), "SELL");
4724 o.symbol = Some("ETHUSDT".to_string());
4725 let order = convert_margin_open_order_owned_symbol(&o).expect("convert");
4726 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4727 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4728 assert_eq!(order.side, Side::Sell);
4729 }
4730
4731 #[test]
4732 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4733 let o = open_order(Some(42), "BUY");
4735 assert!(o.symbol.is_none());
4736 assert!(convert_margin_open_order_owned_symbol(&o).is_none());
4737 }
4738
4739 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4740 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4741 t.id = id;
4742 t.order_id = Some(7);
4743 t.is_buyer = is_buyer;
4744 t.price = Some("48000".to_string());
4745 t.qty = Some("0.25".to_string());
4746 t.commission = Some("0.001".to_string());
4747 t.commission_asset = Some("BNB".to_string());
4748 t.time = Some(1_700_000_000_000);
4749 t
4750 }
4751
4752 #[test]
4753 fn margin_trade_converts() {
4754 let inst = InstrumentNameExchange::new("BTCUSDT");
4755 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4756 assert_eq!(tr.id.0.as_str(), "11");
4757 assert_eq!(tr.order_id.0.as_str(), "7");
4758 assert_eq!(tr.side, Side::Sell);
4759 assert_eq!(tr.price, Decimal::from(48_000));
4760 assert_eq!(tr.quantity, Decimal::new(25, 2));
4761 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4763 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4764 }
4765
4766 #[test]
4767 fn margin_trade_missing_fields_are_dropped() {
4768 let inst = InstrumentNameExchange::new("BTCUSDT");
4769 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4771 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4772 }
4773
4774 #[test]
4777 fn token_renew_after_applies_margin_and_floor() {
4778 let now_ms = Utc::now().timestamp_millis();
4779 let far = token_renew_after(now_ms + 86_400_000).as_secs();
4781 assert!(
4782 far > 80_000 && far <= 86_400,
4783 "expected ~24h-minus-margin, got {far}"
4784 );
4785 assert_eq!(
4787 token_renew_after(now_ms - 10_000).as_secs(),
4788 TOKEN_MIN_LIFETIME_SECS
4789 );
4790 assert_eq!(
4792 token_renew_after(now_ms + 60_000).as_secs(),
4793 TOKEN_MIN_LIFETIME_SECS
4794 );
4795 }
4796
4797 fn push(event: serde_json::Value) -> String {
4802 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
4803 }
4804
4805 #[test]
4806 fn margin_ws_rpc_ack_yields_no_events() {
4807 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
4809 let mut buf = Vec::new();
4810 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4811 assert!(buf.is_empty());
4812 }
4813
4814 #[test]
4815 fn margin_ws_execution_report_trade_maps_to_trade() {
4816 let frame = push(serde_json::json!({
4817 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4818 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
4819 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4820 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
4821 }));
4822 let mut buf = Vec::new();
4823 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4824 assert_eq!(buf.len(), 1);
4825 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
4826 match &buf[0].kind {
4827 AccountEventKind::Trade(t) => {
4828 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
4829 assert_eq!(t.side, Side::Buy);
4830 assert_eq!(t.price, Decimal::from(48_000));
4831 assert_eq!(t.quantity, Decimal::new(5, 1));
4832 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
4834 }
4835 other => panic!("expected Trade, got {other:?}"),
4836 }
4837 }
4838
4839 #[test]
4840 fn margin_ws_new_report_maps_to_active_order_snapshot() {
4841 let frame = push(serde_json::json!({
4842 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
4843 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
4844 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
4845 }));
4846 let mut buf = Vec::new();
4847 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4848 assert_eq!(buf.len(), 1);
4849 match &buf[0].kind {
4850 AccountEventKind::OrderSnapshot(snap) => {
4851 assert_eq!(snap.0.side, Side::Sell);
4852 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
4853 assert_eq!(snap.0.quantity, Decimal::from(1));
4854 assert!(
4855 matches!(snap.0.state, OrderState::Active(_)),
4856 "NEW should be an active (resting) order"
4857 );
4858 }
4859 other => panic!("expected OrderSnapshot, got {other:?}"),
4860 }
4861 }
4862
4863 #[test]
4864 fn margin_ws_canceled_report_maps_to_order_cancelled() {
4865 let frame = push(serde_json::json!({
4866 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4867 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
4868 "z": "0", "T": 1_700_000_000_000_i64,
4869 }));
4870 let mut buf = Vec::new();
4871 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4872 assert_eq!(buf.len(), 1);
4873 match &buf[0].kind {
4874 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
4875 other => panic!("expected OrderCancelled, got {other:?}"),
4876 }
4877 }
4878
4879 #[test]
4880 fn margin_ws_execution_report_missing_symbol_is_dropped() {
4881 let frame = push(serde_json::json!({
4883 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
4884 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
4885 }));
4886 let mut buf = Vec::new();
4887 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4888 assert!(buf.is_empty());
4889 }
4890
4891 #[test]
4892 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
4893 let frame = push(serde_json::json!({
4894 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4895 "B": [
4896 { "a": "USDT", "f": "100.0", "l": "5.0" },
4897 { "a": "BTC", "f": "0.5", "l": "0" },
4898 ],
4899 }));
4900 let mut buf = Vec::new();
4901 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4902 assert_eq!(buf.len(), 2);
4903 for ev in &buf {
4905 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
4906 }
4907 }
4908
4909 #[test]
4910 fn margin_ws_stream_terminated_signals_reconnect() {
4911 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
4912 let mut buf = Vec::new();
4913 assert!(
4914 convert_margin_user_data_events(&frame, &mut buf),
4915 "eventStreamTerminated must signal reconnect"
4916 );
4917 assert!(buf.is_empty());
4918 }
4919
4920 #[test]
4921 fn margin_ws_margin_specific_events_are_observable_only() {
4922 let mut buf = Vec::new();
4925 let liability = push(serde_json::json!({
4926 "e": "userLiabilityChange", "a": "USDT", "t": "BORROW", "p": "100", "i": "0.01",
4927 }));
4928 assert!(!convert_margin_user_data_events(&liability, &mut buf));
4929 let level = push(serde_json::json!({
4930 "e": "marginLevelStatusChange", "l": "1.5", "s": "MARGIN_LEVEL_2",
4931 }));
4932 assert!(!convert_margin_user_data_events(&level, &mut buf));
4933 assert!(buf.is_empty());
4934 }
4935
4936 #[test]
4939 fn margin_trade_event_dedup_by_key() {
4940 let frame = push(serde_json::json!({
4943 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4944 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
4945 "l": "1", "L": "100", "z": "1", "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
4946 }));
4947 let mut buf = Vec::new();
4948 convert_margin_user_data_events(&frame, &mut buf);
4949 let event = buf.pop().expect("a Trade event");
4950
4951 let cache = new_dedup_cache();
4952 let first = dedup_key_from_event(&event).expect("Trade events have a dedup key");
4954 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
4955 let second = dedup_key_from_event(&event).expect("Trade events have a dedup key");
4956 assert!(
4957 is_duplicate(&cache, second),
4958 "second sighting is a duplicate"
4959 );
4960 }
4961
4962 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
4967 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
4968 }
4969
4970 fn isolated_handler(
4973 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
4974 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
4975 ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
4976 move |position, subscription_id, buf| {
4977 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
4978 }
4979 }
4980
4981 fn btcusdt() -> InstrumentNameExchange {
4982 InstrumentNameExchange::new("BTCUSDT")
4983 }
4984
4985 fn token(expiration_time_ms: i64) -> UserListenToken {
4986 UserListenToken {
4987 token: "t".to_string(),
4988 expiration_time_ms,
4989 }
4990 }
4991
4992 #[test]
4993 fn listen_token_query_cross_vs_isolated() {
4994 assert!(build_listen_token_query(None).is_empty());
4996
4997 let q = build_listen_token_query(Some(&btcusdt()));
4999 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
5000 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
5001 assert_eq!(q.len(), 2);
5002 }
5003
5004 #[test]
5005 fn earliest_token_expiry_picks_min() {
5006 let tokens = vec![
5007 (btcusdt(), token(3_000)),
5008 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
5009 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
5010 ];
5011 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
5012 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
5014 }
5015
5016 #[test]
5017 fn base_quote_map_extracts_base_and_quote() {
5018 let entries = vec![
5019 iso_entry(
5020 "BTCUSDT",
5021 iso_base("BTC", "0", "0", "0", "0"),
5022 iso_quote("USDT", "0", "0", "0", "0"),
5023 None,
5024 None,
5025 None,
5026 ),
5027 iso_entry(
5028 "ETHBTC",
5029 iso_base("ETH", "0", "0", "0", "0"),
5030 iso_quote("BTC", "0", "0", "0", "0"),
5031 None,
5032 None,
5033 None,
5034 ),
5035 ];
5036 let map = build_base_quote_map(&entries);
5037 assert_eq!(
5038 map.get(&btcusdt())
5039 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5040 Some(("BTC", "USDT"))
5041 );
5042 assert_eq!(
5045 map.get(&InstrumentNameExchange::new("ETHBTC"))
5046 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
5047 Some(("ETH", "BTC"))
5048 );
5049 }
5050
5051 #[test]
5052 fn isolated_outbound_position_routes_to_instrument_balance_update() {
5053 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5054 let base_quote = Arc::new(HashMap::from([(
5055 btcusdt(),
5056 (
5057 AssetNameExchange::new("BTC"),
5058 AssetNameExchange::new("USDT"),
5059 ),
5060 )]));
5061 let mut handler = isolated_handler(sub_map, base_quote);
5062
5063 let frame = push_with_sub(
5065 7,
5066 serde_json::json!({
5067 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5068 "B": [
5069 { "a": "USDT", "f": "1000.0", "l": "50.0" },
5070 { "a": "BTC", "f": "0.5", "l": "0.1" },
5071 ],
5072 }),
5073 );
5074 let mut buf = Vec::new();
5075 assert!(!convert_margin_user_data_events_with(
5076 &frame,
5077 &mut buf,
5078 &mut handler
5079 ));
5080 assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
5081 match &buf[0].kind {
5082 AccountEventKind::InstrumentBalanceUpdate(ibu) => {
5083 assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
5084 assert_eq!(ibu.base.asset.name().as_str(), "BTC");
5085 assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
5086 assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
5087 assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
5088 assert_eq!(ibu.quote.update.free, Decimal::from(1000));
5089 assert_eq!(ibu.quote.update.locked, Decimal::from(50));
5090 }
5091 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
5092 }
5093 assert!(!matches!(
5095 buf[0].kind,
5096 AccountEventKind::BalanceStreamUpdate(_)
5097 ));
5098 }
5099
5100 #[test]
5101 fn isolated_outbound_position_unknown_subscription_dropped() {
5102 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5104 let base_quote = Arc::new(HashMap::from([(
5105 btcusdt(),
5106 (
5107 AssetNameExchange::new("BTC"),
5108 AssetNameExchange::new("USDT"),
5109 ),
5110 )]));
5111 let mut handler = isolated_handler(sub_map, base_quote);
5112
5113 let frame = push_with_sub(
5114 99,
5115 serde_json::json!({
5116 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5117 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
5118 }),
5119 );
5120 let mut buf = Vec::new();
5121 assert!(!convert_margin_user_data_events_with(
5122 &frame,
5123 &mut buf,
5124 &mut handler
5125 ));
5126 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
5127 }
5128
5129 #[test]
5130 fn isolated_outbound_position_missing_side_dropped() {
5131 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5134 let base_quote = Arc::new(HashMap::from([(
5135 btcusdt(),
5136 (
5137 AssetNameExchange::new("BTC"),
5138 AssetNameExchange::new("USDT"),
5139 ),
5140 )]));
5141 let mut handler = isolated_handler(sub_map, base_quote);
5142
5143 let frame = push_with_sub(
5144 7,
5145 serde_json::json!({
5146 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5147 "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
5148 }),
5149 );
5150 let mut buf = Vec::new();
5151 assert!(!convert_margin_user_data_events_with(
5152 &frame,
5153 &mut buf,
5154 &mut handler
5155 ));
5156 assert!(buf.is_empty(), "missing quote side → frame dropped");
5157 }
5158
5159 #[test]
5160 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
5161 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
5165 let base_quote = Arc::new(HashMap::new());
5166 let mut handler = isolated_handler(sub_map, base_quote);
5167
5168 let frame = push_with_sub(
5169 42,
5170 serde_json::json!({
5171 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5172 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
5173 "l": "1", "L": "100", "z": "1", "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5174 }),
5175 );
5176 let mut buf = Vec::new();
5177 assert!(!convert_margin_user_data_events_with(
5178 &frame,
5179 &mut buf,
5180 &mut handler
5181 ));
5182 assert_eq!(buf.len(), 1);
5183 match &buf[0].kind {
5184 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
5185 other => panic!("expected Trade, got {other:?}"),
5186 }
5187 }
5188}