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 error::{ApiError, ConnectivityError, OrderError, UnindexedClientError, UnindexedOrderError},
50 order::{
51 Order, OrderKey, OrderKind, TimeInForce,
52 id::{ClientOrderId, OrderId, StrategyId},
53 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
54 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
55 },
56 trade::{AssetFees, Trade, TradeId},
57};
58use binance_sdk::{
59 common::{
60 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
61 constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
62 models::WebsocketEvent,
63 websocket::{
64 SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
65 WebsocketMessageSendOptions,
66 },
67 },
68 margin_trading::{
69 MarginTradingRestApi,
70 rest_api::{
71 MarginAccountCancelOrderParams, MarginAccountNewOrderNewOrderRespTypeEnum,
72 MarginAccountNewOrderParams, MarginAccountNewOrderSideEnum,
73 MarginAccountNewOrderTimeInForceEnum, QueryCrossMarginAccountDetailsParams,
74 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
75 QueryIsolatedMarginAccountInfoParams,
76 QueryIsolatedMarginAccountInfoResponseAssetsInner, QueryMarginAccountsOpenOrdersParams,
77 QueryMarginAccountsOpenOrdersResponseInner, QueryMarginAccountsTradeListParams,
78 QueryMarginAccountsTradeListResponseInner, RestApi,
79 },
80 websocket_streams::{
81 Executionreport, MarginLevelStatusChange, Outboundaccountposition, UserLiabilityChange,
82 },
83 },
84};
85use chrono::{DateTime, TimeZone, Utc};
86use futures::stream::BoxStream;
87use rust_decimal::Decimal;
88use rustrade_instrument::{
89 Side, asset::name::AssetNameExchange, exchange::ExchangeId,
90 instrument::name::InstrumentNameExchange,
91};
92use serde::{Deserialize, Serialize};
93use smol_str::{SmolStr, format_smolstr};
94use std::{
95 collections::{BTreeMap, HashMap},
96 str::FromStr,
97 sync::{
98 Arc, Mutex,
99 atomic::{AtomicBool, Ordering},
100 },
101 time::Duration,
102};
103use tokio::sync::{mpsc, oneshot};
104use tracing::{debug, error, info, trace, warn};
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
124#[serde(rename_all = "snake_case")]
125pub enum MarginSideEffect {
126 #[default]
134 AutoBorrowRepay,
135 NoBorrow,
139}
140
141impl MarginSideEffect {
142 pub fn as_binance_str(self) -> &'static str {
144 match self {
145 MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
146 MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
147 }
148 }
149}
150
151#[derive(Clone, Deserialize)]
161pub struct BinanceMarginConfig {
162 api_key: String,
165 secret_key: String,
166 pub testnet: bool,
172 #[serde(default)]
179 pub is_isolated: bool,
180 #[serde(default)]
191 pub isolated_symbols: Vec<InstrumentNameExchange>,
192 #[serde(default)]
196 pub side_effect: MarginSideEffect,
197}
198
199impl std::fmt::Debug for BinanceMarginConfig {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 f.debug_struct("BinanceMarginConfig")
203 .field("api_key", &"***")
204 .field("secret_key", &"***")
205 .field("testnet", &self.testnet)
206 .field("is_isolated", &self.is_isolated)
207 .field("isolated_symbols", &self.isolated_symbols)
208 .field("side_effect", &self.side_effect)
209 .finish()
210 }
211}
212
213impl BinanceMarginConfig {
214 pub fn new(
224 api_key: String,
225 secret_key: String,
226 testnet: bool,
227 is_isolated: bool,
228 isolated_symbols: Vec<InstrumentNameExchange>,
229 side_effect: MarginSideEffect,
230 ) -> Self {
231 Self {
232 api_key,
233 secret_key,
234 testnet,
235 is_isolated,
236 isolated_symbols,
237 side_effect,
238 }
239 }
240
241 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
248 Self::new(
249 api_key,
250 secret_key,
251 false,
252 false,
253 Vec::new(),
254 MarginSideEffect::default(),
255 )
256 }
257
258 pub fn isolated(
266 api_key: String,
267 secret_key: String,
268 symbols: Vec<InstrumentNameExchange>,
269 ) -> Self {
270 Self::new(
271 api_key,
272 secret_key,
273 false,
274 true,
275 symbols,
276 MarginSideEffect::default(),
277 )
278 }
279
280 pub fn api_key(&self) -> &str {
282 &self.api_key
283 }
284}
285
286#[derive(Clone)]
345pub struct BinanceMargin {
346 config: Arc<BinanceMarginConfig>,
347 rest: Arc<RestApi>,
349 rest_config: Arc<ConfigurationRestApi>,
353 ws_config: ConfigurationWebsocketApi,
357 rate_limiter: Arc<RateLimitTracker>,
359}
360
361impl std::fmt::Debug for BinanceMargin {
362 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363 f.debug_struct("BinanceMargin")
364 .field("testnet", &self.config.testnet)
365 .field("is_isolated", &self.config.is_isolated)
366 .field("side_effect", &self.config.side_effect)
367 .finish_non_exhaustive()
368 }
369}
370
371impl BinanceMargin {
372 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
382 ConfigurationRestApi::builder()
383 .api_key(config.api_key.clone())
384 .api_secret(config.secret_key.clone())
385 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
386 .build()
387 .expect("failed to build Binance margin REST configuration")
388 }
389
390 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
400 ConfigurationWebsocketApi::builder()
401 .api_key(config.api_key.clone())
402 .api_secret(config.secret_key.clone())
403 .ws_url(SPOT_WS_API_PROD_URL)
407 .build()
408 .expect("failed to build Binance margin WebSocket configuration")
409 }
410
411 fn effective_isolated_set(
424 &self,
425 instruments: &[InstrumentNameExchange],
426 ) -> Vec<InstrumentNameExchange> {
427 if instruments.is_empty() {
428 return self.config.isolated_symbols.clone();
429 }
430 instruments
431 .iter()
432 .filter(|inst| {
433 let in_set = self.config.isolated_symbols.contains(*inst);
434 if !in_set {
435 warn!(
436 instrument = %inst.name(),
437 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
438 );
439 }
440 in_set
441 })
442 .cloned()
443 .collect()
444 }
445
446 async fn isolated_account_snapshot(
450 &self,
451 instruments: &[InstrumentNameExchange],
452 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
453 use futures::{StreamExt as _, TryStreamExt as _};
454
455 let effective = self.effective_isolated_set(instruments);
456 if effective.is_empty() {
457 return Ok(AccountSnapshot::new(
460 ExchangeId::BinanceMargin,
461 Vec::new(),
462 Vec::new(),
463 ));
464 }
465
466 let mut balances_by_symbol = convert_isolated_margin_assets(
468 fetch_isolated_margin_account_info(
469 self.rest.clone(),
470 self.rate_limiter.clone(),
471 effective.clone(),
472 )
473 .await?,
474 );
475
476 let instrument_snapshots: Vec<_> =
479 futures::stream::iter(effective.into_iter().map(|instrument| {
480 fetch_margin_open_orders_for_instrument(
481 self.rest.clone(),
482 self.rate_limiter.clone(),
483 instrument,
484 true,
485 )
486 }))
487 .buffer_unordered(8)
488 .map(|result| {
489 let (inst, orders) = result?;
490 let wrapped = orders.into_iter().map(active_order_snapshot).collect();
491 let isolated = balances_by_symbol.remove(&inst);
492 if isolated.is_none() {
493 warn!(
497 instrument = %inst.name(),
498 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
499 );
500 }
501 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
502 inst, wrapped, None, isolated,
503 ))
504 })
505 .try_collect()
506 .await?;
507
508 Ok(AccountSnapshot::new(
511 ExchangeId::BinanceMargin,
512 Vec::new(),
513 instrument_snapshots,
514 ))
515 }
516}
517
518impl ExecutionClient for BinanceMargin {
519 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
520 type Config = BinanceMarginConfig;
521 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
522
523 fn new(config: Self::Config) -> Self {
536 if config.testnet {
537 warn!(
538 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
539 using production endpoints"
540 );
541 }
542 assert!(
543 !(config.is_isolated && config.isolated_symbols.is_empty()),
544 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
545 );
546 let rest_config = Self::build_rest_config(&config);
551 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
552 let rest_config = Arc::new(rest_config);
553 let ws_config = Self::build_ws_config(&config);
554 Self {
555 config: Arc::new(config),
556 rest,
557 rest_config,
558 ws_config,
559 rate_limiter: Arc::new(RateLimitTracker::new()),
560 }
561 }
562
563 async fn open_order(
577 &self,
578 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
579 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
580 let instrument = request.key.instrument.clone();
581 let side = request.state.side;
582 let price = request.state.price;
583 let quantity = request.state.quantity;
584 let kind = request.state.kind;
585 let time_in_force = request.state.time_in_force;
586 let cid = request.key.cid.clone();
587
588 let order_key = OrderKey::new(
589 ExchangeId::BinanceMargin,
590 instrument.clone(),
591 request.key.strategy.clone(),
592 cid.clone(),
593 );
594
595 let inactive = |state: UnindexedOrderState| {
598 Some(Order {
599 key: order_key.clone(),
600 side,
601 price,
602 quantity,
603 kind,
604 time_in_force,
605 state,
606 })
607 };
608
609 let params = match build_new_order_params(
610 instrument.name().to_string(),
611 side,
612 price,
613 quantity,
614 kind,
615 time_in_force,
616 cid.0.to_string(),
617 self.config.side_effect,
618 self.config.is_isolated,
619 ) {
620 Ok(params) => params,
621 Err(BuildOrderError::Unsupported) => {
622 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
623 format!(
624 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
625 ),
626 )));
627 }
628 Err(BuildOrderError::Build(msg)) => {
629 error!(%msg, "BinanceMargin failed to build new order params");
630 return inactive(OrderState::inactive(OrderError::Rejected(
631 ApiError::OrderRejected(msg),
632 )));
633 }
634 };
635
636 let response = match rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
637 let params = params.clone();
638 Box::pin(async move { rest.margin_account_new_order(params).await })
639 })
640 .await
641 {
642 Ok(response) => response,
643 Err(e) => {
644 return inactive(OrderState::inactive(classify_rest_order_error(
645 &e,
646 &instrument,
647 )));
648 }
649 };
650
651 let data = match response.data().await {
652 Ok(data) => data,
653 Err(e) => {
654 return inactive(OrderState::inactive(OrderError::Rejected(
657 ApiError::OrderRejected(e.to_string()),
658 )));
659 }
660 };
661
662 let time_exchange = data
663 .transact_time
664 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
665 .unwrap_or_else(Utc::now);
666
667 let exchange_order_id = match data.order_id {
668 Some(id) => OrderId(format_smolstr!("{id}")),
669 None => {
670 error!("BinanceMargin open_order response missing orderId");
671 return inactive(OrderState::inactive(OrderError::Rejected(
672 ApiError::OrderRejected("open_order response missing orderId".into()),
673 )));
674 }
675 };
676
677 let filled_qty = match data.executed_qty.as_deref() {
678 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
679 warn!(
683 executed_qty = q,
684 "BinanceMargin: failed to parse executedQty; treating as zero"
685 );
686 Decimal::ZERO
687 }),
688 None => {
689 warn!("BinanceMargin: executedQty missing in response; treating as zero");
692 Decimal::ZERO
693 }
694 };
695
696 let state = if filled_qty >= quantity {
697 let avg_price = margin_avg_price(data.cummulative_quote_qty.as_deref(), filled_qty);
700 OrderState::fully_filled(Filled::new(
701 exchange_order_id,
702 time_exchange,
703 filled_qty,
704 avg_price,
705 ))
706 } else {
707 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
708 };
709
710 Some(Order {
711 key: order_key,
712 side,
713 price,
714 quantity,
715 kind,
716 time_in_force,
717 state,
718 })
719 }
720
721 async fn cancel_order(
731 &self,
732 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
733 ) -> Option<UnindexedOrderResponseCancel> {
734 let instrument = request.key.instrument.clone();
735 let key = OrderKey {
736 exchange: request.key.exchange,
737 instrument: instrument.clone(),
738 strategy: request.key.strategy.clone(),
739 cid: request.key.cid.clone(),
740 };
741
742 let params = match build_cancel_order_params(
743 instrument.name().to_string(),
744 request.state.id.as_ref(),
745 &request.key.cid,
746 self.config.is_isolated,
747 ) {
748 Ok(p) => p,
749 Err(e) => {
750 error!(%e, "BinanceMargin failed to build cancel order params");
751 return Some(UnindexedOrderResponseCancel {
752 key,
753 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
754 });
755 }
756 };
757
758 let response = match rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
759 let params = params.clone();
760 Box::pin(async move { rest.margin_account_cancel_order(params).await })
761 })
762 .await
763 {
764 Ok(response) => response,
765 Err(e) => {
766 return Some(UnindexedOrderResponseCancel {
767 key,
768 state: Err(classify_rest_order_error(&e, &instrument)),
769 });
770 }
771 };
772
773 let data = match response.data().await {
774 Ok(data) => data,
775 Err(e) => {
776 return Some(UnindexedOrderResponseCancel {
779 key,
780 state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
781 });
782 }
783 };
784
785 let time_exchange = Utc::now();
787
788 let exchange_order_id = match data.order_id {
789 Some(id) => OrderId(SmolStr::new(id)),
792 None => {
793 error!("BinanceMargin cancel response missing orderId");
794 return Some(UnindexedOrderResponseCancel {
795 key,
796 state: Err(OrderError::Rejected(ApiError::OrderRejected(
797 "cancel response missing orderId".into(),
798 ))),
799 });
800 }
801 };
802
803 let filled_qty = match data.executed_qty.as_deref() {
804 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
805 warn!(
809 executed_qty = q,
810 "BinanceMargin: failed to parse executedQty; treating as zero"
811 );
812 Decimal::ZERO
813 }),
814 None => {
815 warn!("BinanceMargin: executedQty missing in response; treating as zero");
818 Decimal::ZERO
819 }
820 };
821
822 Some(UnindexedOrderResponseCancel {
823 key,
824 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
825 })
826 }
827
828 async fn account_snapshot(
843 &self,
844 assets: &[AssetNameExchange],
845 instruments: &[InstrumentNameExchange],
846 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
847 if self.config.is_isolated {
848 return self.isolated_account_snapshot(instruments).await;
849 }
850 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
851 Box::pin(async move {
852 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
853 rest.query_cross_margin_account_details(params).await
854 })
855 })
856 .await
857 .map_err(connectivity_error)?;
858
859 let account = response
860 .data()
861 .await
862 .map_err(|e| connectivity_error(e.into()))?;
863
864 let balances =
865 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
866
867 use futures::{StreamExt as _, TryStreamExt as _};
872 let instrument_snapshots: Vec<_> =
873 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
874 fetch_margin_open_orders_for_instrument(
875 self.rest.clone(),
876 self.rate_limiter.clone(),
877 instrument,
878 false,
880 )
881 }))
882 .buffer_unordered(8)
883 .map(|result| {
884 let (inst, orders) = result?;
885 let wrapped = orders.into_iter().map(active_order_snapshot).collect();
886 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
887 inst, wrapped, None, None,
888 ))
889 })
890 .try_collect()
891 .await?;
892
893 Ok(AccountSnapshot::new(
894 ExchangeId::BinanceMargin,
895 balances,
896 instrument_snapshots,
897 ))
898 }
899
900 async fn fetch_balances(
910 &self,
911 assets: &[AssetNameExchange],
912 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
913 if self.config.is_isolated {
914 return Ok(Vec::new());
915 }
916 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
917 Box::pin(async move {
918 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
919 rest.query_cross_margin_account_details(params).await
920 })
921 })
922 .await
923 .map_err(connectivity_error)?;
924
925 let account = response
926 .data()
927 .await
928 .map_err(|e| connectivity_error(e.into()))?;
929
930 Ok(filter_and_convert_margin_balances(
931 account.user_assets.unwrap_or_default(),
932 assets,
933 ))
934 }
935
936 async fn fetch_open_orders(
947 &self,
948 instruments: &[InstrumentNameExchange],
949 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
950 use futures::{StreamExt as _, TryStreamExt as _};
951
952 if self.config.is_isolated {
956 let effective = self.effective_isolated_set(instruments);
957 let capacity = effective.len();
958 return futures::stream::iter(effective.into_iter().map(|instrument| {
959 fetch_margin_open_orders_for_instrument(
960 self.rest.clone(),
961 self.rate_limiter.clone(),
962 instrument,
963 true,
964 )
965 }))
966 .buffer_unordered(8)
967 .try_fold(
968 Vec::with_capacity(capacity),
969 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
970 (_, orders)| async move {
971 acc.extend(orders);
972 Ok(acc)
973 },
974 )
975 .await;
976 }
977
978 if instruments.is_empty() {
981 return fetch_margin_all_open_orders(
982 self.rest.clone(),
983 self.rate_limiter.clone(),
984 false,
985 )
986 .await;
987 }
988 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
989 fetch_margin_open_orders_for_instrument(
990 self.rest.clone(),
991 self.rate_limiter.clone(),
992 instrument,
993 false,
994 )
995 }))
996 .buffer_unordered(8)
997 .try_fold(
998 Vec::with_capacity(instruments.len()),
999 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, orders)| async move {
1000 acc.extend(orders);
1001 Ok(acc)
1002 },
1003 )
1004 .await
1005 }
1006
1007 async fn fetch_trades(
1020 &self,
1021 time_since: DateTime<Utc>,
1022 instruments: &[InstrumentNameExchange],
1023 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1024 use futures::StreamExt as _;
1025
1026 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1029 self.effective_isolated_set(instruments)
1030 } else {
1031 instruments.to_vec()
1032 };
1033 if effective.is_empty() {
1034 debug!(
1037 is_isolated = self.config.is_isolated,
1038 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1039 );
1040 return Ok(Vec::new());
1041 }
1042 let start_time_ms = time_since.timestamp_millis();
1043 let is_isolated = self.config.is_isolated;
1044 let mut all_trades = Vec::new();
1045
1046 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1049 let rest = self.rest.clone();
1050 let rate_limiter = self.rate_limiter.clone();
1051 async move {
1052 let pages = paginate_margin_my_trades(
1053 &rest,
1054 &rate_limiter,
1055 &inst,
1056 start_time_ms,
1057 is_isolated,
1058 )
1059 .await?;
1060 Ok::<_, UnindexedClientError>((inst, pages))
1061 }
1062 }))
1063 .buffer_unordered(8);
1064 while let Some(result) = stream.next().await {
1065 let (instrument, trades_data) = result?;
1066 for t in trades_data {
1067 if let Some(trade) = convert_margin_trade(&t, &instrument) {
1068 all_trades.push(trade);
1069 }
1070 }
1071 }
1072
1073 Ok(all_trades)
1074 }
1075
1076 async fn account_stream(
1137 &self,
1138 _assets: &[AssetNameExchange],
1141 instruments: &[InstrumentNameExchange],
1142 ) -> Result<Self::AccountStream, UnindexedClientError> {
1143 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1144 let dedup = new_dedup_cache();
1145 let rest = self.rest.clone();
1146 let rest_config = self.rest_config.clone();
1147 let ws_config = self.ws_config.clone();
1148 let rate_limiter = self.rate_limiter.clone();
1149
1150 let cm_handle = if self.config.is_isolated {
1151 let symbols = self.config.isolated_symbols.clone();
1155 debug_assert!(
1158 symbols.iter().all(|i| i.name().len() <= 23),
1159 "instrument name exceeds SmolStr inline capacity: {:?}",
1160 symbols.iter().find(|i| i.name().len() > 23)
1161 );
1162
1163 let assets = fetch_isolated_margin_account_info(
1169 rest.clone(),
1170 rate_limiter.clone(),
1171 symbols.clone(),
1172 )
1173 .await?;
1174 let base_quote = Arc::new(build_base_quote_map(&assets));
1175 for sym in &symbols {
1176 if !base_quote.contains_key(sym) {
1177 warn!(
1178 symbol = %sym.name(),
1179 "BinanceMargin isolated: account info returned no base/quote for configured \
1180 symbol — its live per-pair balance frames will be dropped (fills/orders \
1181 unaffected; balances available via account_snapshot)"
1182 );
1183 }
1184 }
1185
1186 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1190 let live =
1191 isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
1192 .await
1193 .map_err(|e| {
1194 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1195 })?;
1196
1197 tokio::spawn(isolated_connection_manager(
1198 tx,
1199 dedup,
1200 ws_config,
1201 rest_config,
1202 rest,
1203 rate_limiter,
1204 symbols,
1205 base_quote,
1206 Some(live),
1207 ))
1208 } else {
1209 let instruments = instruments.to_vec();
1211 debug_assert!(
1215 instruments.iter().all(|i| i.name().len() <= 23),
1216 "instrument name exceeds SmolStr inline capacity: {:?}",
1217 instruments.iter().find(|i| i.name().len() > 23)
1218 );
1219
1220 let initial_token =
1226 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1227 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1228 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1229 })?;
1230
1231 tokio::spawn(margin_connection_manager(
1232 tx,
1233 dedup,
1234 ws_config,
1235 rest_config,
1236 rest,
1237 rate_limiter,
1238 instruments,
1239 Some((initial_ws, initial_token)),
1240 ))
1241 };
1242
1243 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1244 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1245 Ok(futures::StreamExt::boxed(guarded_stream))
1246 }
1247}
1248
1249const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1257
1258struct UserListenToken {
1260 token: String,
1261 expiration_time_ms: i64,
1262}
1263
1264fn build_listen_token_query(
1268 symbol: Option<&InstrumentNameExchange>,
1269) -> BTreeMap<String, serde_json::Value> {
1270 let mut query = BTreeMap::new();
1271 if let Some(sym) = symbol {
1272 query.insert(
1273 "isIsolated".to_string(),
1274 serde_json::Value::String("TRUE".to_string()),
1275 );
1276 query.insert(
1277 "symbol".to_string(),
1278 serde_json::Value::String(sym.name().to_string()),
1279 );
1280 }
1281 query
1282}
1283
1284#[derive(Deserialize)]
1286struct UserListenTokenResponse {
1287 #[serde(alias = "listenToken")]
1289 token: String,
1290 #[serde(rename = "expirationTime")]
1291 expiration_time: i64,
1292}
1293
1294async fn acquire_user_listen_token(
1308 rest_config: &Arc<ConfigurationRestApi>,
1309 rate_limiter: &RateLimitTracker,
1310 symbol: Option<&InstrumentNameExchange>,
1311) -> Result<UserListenToken, UnindexedClientError> {
1312 let query = build_listen_token_query(symbol);
1315 let response = rest_call_with_retry(rest_config, rate_limiter, |cfg| {
1316 let query = query.clone();
1317 Box::pin(async move {
1318 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1319 &cfg,
1320 "/sapi/v1/userListenToken",
1321 reqwest::Method::POST,
1322 query,
1325 BTreeMap::new(),
1326 None,
1327 true, )
1329 .await
1330 })
1331 })
1332 .await
1333 .map_err(connectivity_error)?;
1334
1335 let data = response
1336 .data()
1337 .await
1338 .map_err(|e| connectivity_error(e.into()))?;
1339 Ok(UserListenToken {
1340 token: data.token,
1341 expiration_time_ms: data.expiration_time,
1342 })
1343}
1344
1345fn token_renew_after(expiration_time_ms: i64) -> Duration {
1348 let now_ms = Utc::now().timestamp_millis();
1349 let remaining_secs = expiration_time_ms
1353 .saturating_sub(now_ms)
1354 .saturating_div(1_000)
1355 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1356 let secs = u64::try_from(remaining_secs).unwrap_or(0);
1357 if secs < TOKEN_MIN_LIFETIME_SECS {
1360 warn!(
1361 computed_secs = secs,
1362 floor_secs = TOKEN_MIN_LIFETIME_SECS,
1363 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1364 );
1365 }
1366 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1367}
1368
1369async fn connect_margin_ws(
1376 ws_config: &ConfigurationWebsocketApi,
1377) -> anyhow::Result<Arc<WsApiBase>> {
1378 let ws = WsApiBase::new(ws_config.clone(), vec![]);
1379 tokio::time::timeout(
1382 Duration::from_secs(CONNECT_TIMEOUT_SECS),
1383 ws.clone().connect(),
1384 )
1385 .await
1386 .map_err(|_| {
1387 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1388 })??;
1389 Ok(ws)
1390}
1391
1392async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1399 let mut payload = BTreeMap::new();
1402 payload.insert(
1403 "listenToken".to_string(),
1404 serde_json::Value::String(token.to_string()),
1405 );
1406 ws.send_message::<serde_json::Value>(
1407 "userDataStream.subscribe.listenToken",
1408 payload,
1409 WebsocketMessageSendOptions::new(),
1410 )
1411 .await
1412 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1413 Ok(())
1414}
1415
1416async fn subscribe_listen_token_capture(
1429 ws: &Arc<WsApiBase>,
1430 token: &str,
1431) -> anyhow::Result<Option<i64>> {
1432 #[derive(Deserialize)]
1435 struct SubscribeAck {
1436 #[serde(rename = "subscriptionId", default)]
1437 subscription_id: Option<i64>,
1438 }
1439
1440 let mut payload = BTreeMap::new();
1441 payload.insert(
1442 "listenToken".to_string(),
1443 serde_json::Value::String(token.to_string()),
1444 );
1445 let result = ws
1446 .send_message::<SubscribeAck>(
1447 "userDataStream.subscribe.listenToken",
1448 payload,
1449 WebsocketMessageSendOptions::new(),
1450 )
1451 .await
1452 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1453 let ack = match result {
1454 SendWebsocketMessageResult::Single(resp) => resp.data()?,
1455 SendWebsocketMessageResult::Multiple(responses) => responses
1457 .into_iter()
1458 .next()
1459 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
1460 .data()?,
1461 };
1462 Ok(ack.subscription_id)
1463}
1464
1465fn cross_account_position_handler(
1469 position: Outboundaccountposition,
1470 _subscription_id: Option<i64>,
1471 buf: &mut Vec<UnindexedAccountEvent>,
1472) {
1473 convert_margin_account_position(position, buf);
1474}
1475
1476#[cfg(test)]
1481fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1482 convert_margin_user_data_events_with(frame, buf, &mut cross_account_position_handler)
1483}
1484
1485fn convert_margin_user_data_events_with(
1499 frame: &str,
1500 buf: &mut Vec<UnindexedAccountEvent>,
1501 handle_position: &mut impl FnMut(
1502 Outboundaccountposition,
1503 Option<i64>,
1504 &mut Vec<UnindexedAccountEvent>,
1505 ),
1506) -> bool {
1507 use serde_json::value::RawValue;
1508
1509 #[derive(Deserialize)]
1515 struct Envelope<'a> {
1516 #[serde(borrow, default)]
1517 id: Option<&'a RawValue>,
1518 #[serde(borrow, default)]
1519 event: Option<&'a RawValue>,
1520 #[serde(rename = "subscriptionId", default)]
1521 subscription_id: Option<i64>,
1522 }
1523 #[derive(Deserialize)]
1525 struct EventTag<'a> {
1526 #[serde(borrow, default)]
1527 e: Option<&'a str>,
1528 }
1529
1530 let envelope = match serde_json::from_str::<Envelope<'_>>(frame) {
1531 Ok(env) => env,
1532 Err(e) => {
1533 trace!(error = %e, "BinanceMargin WS: skipped unparseable frame");
1534 return false;
1535 }
1536 };
1537 if envelope.id.is_some() {
1539 return false;
1540 }
1541 let Some(event) = envelope.event else {
1543 trace!("BinanceMargin WS: ignoring frame without `event` or `id`");
1544 return false;
1545 };
1546 let subscription_id = envelope.subscription_id;
1547 let event_raw = event.get();
1548 let event_type = serde_json::from_str::<EventTag<'_>>(event_raw)
1549 .ok()
1550 .and_then(|tag| tag.e)
1551 .unwrap_or_default();
1552 match event_type {
1553 "executionReport" => {
1554 match serde_json::from_str::<Executionreport>(event_raw) {
1557 Ok(report) => {
1558 if let Some(ev) = convert_margin_execution_report(report) {
1559 buf.push(ev);
1560 }
1561 }
1562 Err(e) => {
1563 warn!(error = %e, "BinanceMargin: undeserializable executionReport, dropping")
1564 }
1565 }
1566 false
1567 }
1568 "outboundAccountPosition" => {
1569 match serde_json::from_str::<Outboundaccountposition>(event_raw) {
1570 Ok(position) => handle_position(position, subscription_id, buf),
1572 Err(e) => {
1573 warn!(error = %e, "BinanceMargin: undeserializable outboundAccountPosition, dropping")
1574 }
1575 }
1576 false
1577 }
1578 "balanceUpdate" => {
1579 false
1583 }
1584 "userLiabilityChange" => {
1585 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
1592 Ok(c) => {
1593 if tracing::enabled!(tracing::Level::INFO) {
1594 info!(
1595 asset = c.a.as_deref().unwrap_or("?"),
1596 kind = c.t.as_deref().unwrap_or("?"),
1597 principal = c.p.as_deref().unwrap_or("?"),
1598 interest = c.i.as_deref().unwrap_or("?"),
1599 "BinanceMargin userLiabilityChange (observable; not applied to balance state)"
1600 );
1601 }
1602 }
1603 Err(e) => warn!(
1604 error = %e,
1605 "BinanceMargin: undeserializable userLiabilityChange, dropping"
1606 ),
1607 }
1608 false
1609 }
1610 "marginLevelStatusChange" => {
1611 if tracing::enabled!(tracing::Level::WARN) {
1616 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
1617 Ok(c) => warn!(
1618 margin_level = c.l.as_deref().unwrap_or("?"),
1619 status = c.s.as_deref().unwrap_or("?"),
1620 "BinanceMargin marginLevelStatusChange (liquidation risk; observable, no policy)"
1621 ),
1622 Err(e) => warn!(
1623 error = %e,
1624 "BinanceMargin: undeserializable marginLevelStatusChange, dropping"
1625 ),
1626 }
1627 }
1628 false
1629 }
1630 "eventStreamTerminated" => {
1631 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
1632 true
1633 }
1634 other => {
1635 trace!(
1636 event_type = other,
1637 "BinanceMargin ignoring unhandled user data event"
1638 );
1639 false
1640 }
1641 }
1642}
1643
1644#[allow(clippy::cognitive_complexity)] fn convert_margin_execution_report(report: Executionreport) -> Option<UnindexedAccountEvent> {
1652 let exec_type = match report.x.as_deref() {
1653 Some(t) => t,
1654 None => {
1655 warn!("BinanceMargin executionReport missing execution type (x), dropping");
1656 return None;
1657 }
1658 };
1659 let symbol = match report.s.as_deref() {
1660 Some(s) => InstrumentNameExchange::new(s),
1661 None => {
1662 warn!("BinanceMargin executionReport missing symbol (s), dropping");
1663 return None;
1664 }
1665 };
1666 let order_id = match report.i {
1667 Some(id) => OrderId(format_smolstr!("{id}")),
1668 None => {
1669 warn!(%symbol, "BinanceMargin executionReport missing orderId (i), dropping");
1670 return None;
1671 }
1672 };
1673 let cid = match report.c.as_deref() {
1674 Some(c) => ClientOrderId::new(c),
1675 None => ClientOrderId::new(order_id.0.as_str()),
1676 };
1677 let time_exchange = match report
1678 .t_uppercase
1679 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1680 {
1681 Some(t) => t,
1682 None => {
1683 warn!(%symbol, "BinanceMargin executionReport missing/unparseable transaction time (T), using now");
1684 Utc::now()
1685 }
1686 };
1687
1688 match exec_type {
1689 "NEW" => convert_margin_new_order(&report, symbol, cid, order_id, time_exchange),
1690 "TRADE" => {
1691 let trade_id = match report.t {
1692 Some(id) => TradeId(format_smolstr!("{id}")),
1693 None => {
1694 warn!(%symbol, "BinanceMargin TRADE event missing trade ID (t), dropping");
1695 return None;
1696 }
1697 };
1698 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
1699 Some(s) => s,
1700 None => {
1701 warn!(%symbol, "BinanceMargin TRADE event missing/unknown side (S), dropping");
1702 return None;
1703 }
1704 };
1705 let last_price = match report.l_uppercase.as_deref() {
1706 Some(s) => match Decimal::from_str(s) {
1707 Ok(v) => v,
1708 Err(e) => {
1709 warn!(%symbol, error = %e, raw = s, "BinanceMargin TRADE event unparseable last price (L), dropping fill");
1710 return None;
1711 }
1712 },
1713 None => {
1714 warn!(%symbol, "BinanceMargin TRADE event missing last price (L), dropping fill");
1715 return None;
1716 }
1717 };
1718 let last_qty = match report.l.as_deref() {
1719 Some(s) => match Decimal::from_str(s) {
1720 Ok(v) => v,
1721 Err(e) => {
1722 warn!(%symbol, error = %e, raw = s, "BinanceMargin TRADE event unparseable last qty (l), dropping fill");
1723 return None;
1724 }
1725 },
1726 None => {
1727 warn!(%symbol, "BinanceMargin TRADE event missing last qty (l), dropping fill");
1728 return None;
1729 }
1730 };
1731 let commission = match Decimal::from_str(report.n.as_deref().unwrap_or("0")) {
1732 Ok(v) => v,
1733 Err(e) => {
1734 warn!(%symbol, error = %e, "BinanceMargin TRADE event unparseable commission (n), defaulting to 0");
1735 Decimal::ZERO
1736 }
1737 };
1738 let fee_asset = report
1739 .n_uppercase
1740 .as_deref()
1741 .map(AssetNameExchange::from)
1742 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
1743
1744 let trade = Trade::new(
1745 trade_id,
1746 order_id,
1747 symbol,
1748 StrategyId::unknown(), time_exchange,
1750 side,
1751 last_price,
1752 last_qty,
1753 AssetFees::new(fee_asset, commission, None),
1754 );
1755 Some(UnindexedAccountEvent::new(
1756 ExchangeId::BinanceMargin,
1757 AccountEventKind::Trade(trade),
1758 ))
1759 }
1760 "CANCELED" | "EXPIRED" | "EXPIRED_IN_MATCH" => {
1761 let filled_qty = report
1762 .z
1763 .as_deref()
1764 .and_then(|s| Decimal::from_str(s).ok())
1765 .unwrap_or(Decimal::ZERO);
1766 let response = UnindexedOrderResponseCancel {
1767 key: OrderKey::new(
1768 ExchangeId::BinanceMargin,
1769 symbol,
1770 StrategyId::unknown(),
1771 cid,
1772 ),
1773 state: Ok(Cancelled::new(order_id, time_exchange, filled_qty)),
1774 };
1775 Some(UnindexedAccountEvent::new(
1776 ExchangeId::BinanceMargin,
1777 AccountEventKind::OrderCancelled(response),
1778 ))
1779 }
1780 "REJECTED" => {
1781 let reject_reason = report.r.unwrap_or_else(|| "unknown".to_string());
1782 warn!(%symbol, %order_id, reason = %reject_reason, "BinanceMargin order REJECTED by matching engine");
1783 let response = UnindexedOrderResponseCancel {
1784 key: OrderKey::new(
1785 ExchangeId::BinanceMargin,
1786 symbol,
1787 StrategyId::unknown(),
1788 cid,
1789 ),
1790 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1791 reject_reason,
1792 ))),
1793 };
1794 Some(UnindexedAccountEvent::new(
1795 ExchangeId::BinanceMargin,
1796 AccountEventKind::OrderCancelled(response),
1797 ))
1798 }
1799 "REPLACE" => {
1800 let filled_qty = report
1802 .z
1803 .as_deref()
1804 .and_then(|s| Decimal::from_str(s).ok())
1805 .unwrap_or(Decimal::ZERO);
1806 let response = UnindexedOrderResponseCancel {
1807 key: OrderKey::new(
1808 ExchangeId::BinanceMargin,
1809 symbol,
1810 StrategyId::unknown(),
1811 cid,
1812 ),
1813 state: Ok(Cancelled::new(order_id, time_exchange, filled_qty)),
1814 };
1815 Some(UnindexedAccountEvent::new(
1816 ExchangeId::BinanceMargin,
1817 AccountEventKind::OrderCancelled(response),
1818 ))
1819 }
1820 _ => {
1821 trace!(exec_type, "BinanceMargin ignoring execution type");
1823 None
1824 }
1825 }
1826}
1827
1828fn convert_margin_new_order(
1830 report: &Executionreport,
1831 symbol: InstrumentNameExchange,
1832 cid: ClientOrderId,
1833 order_id: OrderId,
1834 time_exchange: DateTime<Utc>,
1835) -> Option<UnindexedAccountEvent> {
1836 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
1837 Some(s) => s,
1838 None => {
1839 warn!(%symbol, "BinanceMargin NEW event missing/unknown side (S), dropping");
1840 return None;
1841 }
1842 };
1843 let kind = parse_order_kind(report.o.as_deref().unwrap_or("LIMIT"))?;
1844 let price: Option<Decimal> = match (report.p.as_deref(), &kind) {
1845 (Some(p), _) => match Decimal::from_str(p) {
1846 Ok(v) if !v.is_zero() => Some(v),
1847 Ok(_) => {
1848 if matches!(
1849 kind,
1850 OrderKind::Limit
1851 | OrderKind::StopLimit { .. }
1852 | OrderKind::TakeProfitLimit { .. }
1853 | OrderKind::TrailingStopLimit { .. }
1854 ) {
1855 trace!(%symbol, %kind, "BinanceMargin NEW event has zero price (p) on limit-type order, treating as no limit price");
1856 }
1857 None
1858 }
1859 Err(e) => {
1860 warn!(%symbol, price = p, error = %e, "BinanceMargin NEW event unparseable price (p), dropping");
1861 return None;
1862 }
1863 },
1864 (
1865 None,
1866 OrderKind::Market
1867 | OrderKind::Stop { .. }
1868 | OrderKind::TakeProfit { .. }
1869 | OrderKind::TrailingStop { .. },
1870 ) => None,
1871 (
1872 None,
1873 OrderKind::Limit
1874 | OrderKind::StopLimit { .. }
1875 | OrderKind::TakeProfitLimit { .. }
1876 | OrderKind::TrailingStopLimit { .. },
1877 ) => {
1878 warn!(%symbol, "BinanceMargin NEW limit-type order missing price (p), dropping");
1879 return None;
1880 }
1881 };
1882 let quantity = match report.q.as_deref() {
1883 Some(q) => match Decimal::from_str(q) {
1884 Ok(v) => v,
1885 Err(e) => {
1886 warn!(%symbol, qty = q, error = %e, "BinanceMargin NEW event unparseable quantity (q), dropping");
1887 return None;
1888 }
1889 },
1890 None => {
1891 warn!(%symbol, "BinanceMargin NEW order missing quantity (q), dropping");
1892 return None;
1893 }
1894 };
1895 let time_in_force = parse_time_in_force(report.f.as_deref().unwrap_or("GTC"));
1896 let filled_qty = report
1897 .z
1898 .as_deref()
1899 .and_then(|s| Decimal::from_str(s).ok())
1900 .unwrap_or(Decimal::ZERO);
1901
1902 let order = Order {
1903 key: OrderKey::new(
1904 ExchangeId::BinanceMargin,
1905 symbol,
1906 StrategyId::unknown(),
1907 cid,
1908 ),
1909 side,
1910 price,
1911 quantity,
1912 kind,
1913 time_in_force,
1914 state: OrderState::active(Open::new(order_id, time_exchange, filled_qty)),
1915 };
1916 Some(UnindexedAccountEvent::new(
1917 ExchangeId::BinanceMargin,
1918 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot::new(
1919 order,
1920 )),
1921 ))
1922}
1923
1924fn convert_margin_account_position(
1930 position: Outboundaccountposition,
1931 buf: &mut Vec<UnindexedAccountEvent>,
1932) {
1933 let time_exchange = position
1934 .u
1935 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1936 .unwrap_or_else(Utc::now);
1937
1938 for b in position.b_uppercase.unwrap_or_default() {
1939 let asset = match b.a {
1940 Some(a) => AssetNameExchange::new(a),
1941 None => {
1942 warn!("BinanceMargin account position entry missing asset name");
1943 continue;
1944 }
1945 };
1946 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1947 Some(v) => v,
1948 None => {
1949 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
1950 continue;
1951 }
1952 };
1953 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1954 Some(v) => v,
1955 None => {
1956 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
1957 continue;
1958 }
1959 };
1960 let update =
1961 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
1962 buf.push(UnindexedAccountEvent::new(
1963 ExchangeId::BinanceMargin,
1964 AccountEventKind::BalanceStreamUpdate(
1965 rustrade_integration::collection::snapshot::Snapshot::new(update),
1966 ),
1967 ));
1968 }
1969}
1970
1971fn route_isolated_account_position(
1982 position: Outboundaccountposition,
1983 subscription_id: Option<i64>,
1984 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
1985 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
1986 buf: &mut Vec<UnindexedAccountEvent>,
1987) {
1988 let Some(sub_id) = subscription_id else {
1989 warn!(
1990 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
1991 (per-instrument balance routing requires it)"
1992 );
1993 return;
1994 };
1995 let instrument = {
1996 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
1999 match map.get(&sub_id) {
2000 Some(inst) => inst.clone(),
2001 None => {
2002 warn!(
2003 subscription_id = sub_id,
2004 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
2005 );
2006 return;
2007 }
2008 }
2009 };
2010 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
2011 warn!(
2012 instrument = %instrument.name(),
2013 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
2014 );
2015 return;
2016 };
2017
2018 let time_exchange = position
2019 .u
2020 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2021 .unwrap_or_else(Utc::now);
2022
2023 let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
2024 let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
2025 for b in position.b_uppercase.unwrap_or_default() {
2026 let Some(asset) = b.a.as_deref() else {
2027 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
2028 continue;
2029 };
2030 let side = if asset == base_asset.name().as_str() {
2032 &mut base_update
2033 } else if asset == quote_asset.name().as_str() {
2034 &mut quote_update
2035 } else {
2036 continue;
2037 };
2038 let (Some(free), Some(locked)) = (
2039 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2040 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2041 ) else {
2042 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
2043 continue;
2044 };
2045 *side = Some(AssetBalanceUpdate::new(
2046 AssetNameExchange::new(asset),
2047 BalanceUpdate::new(free, locked),
2048 time_exchange,
2049 ));
2050 }
2051
2052 let (Some(base), Some(quote)) = (base_update, quote_update) else {
2056 warn!(
2057 instrument = %instrument.name(),
2058 "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
2059 );
2060 return;
2061 };
2062 buf.push(UnindexedAccountEvent::new(
2063 ExchangeId::BinanceMargin,
2064 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
2065 instrument, base, quote,
2066 )),
2067 ));
2068}
2069
2070fn register_user_data_listener(
2081 ws: &Arc<WsApiBase>,
2082 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2083 dedup: SharedDedupCache,
2084 heartbeat_flag: Arc<AtomicBool>,
2085 signal_tx: oneshot::Sender<()>,
2086 mut handle_position: impl FnMut(
2087 Outboundaccountposition,
2088 Option<i64>,
2089 &mut Vec<UnindexedAccountEvent>,
2090 ) + Send
2091 + 'static,
2092) -> Subscription {
2093 let mut signal_tx_opt = Some(signal_tx);
2094 let mut event_tx = Some(tx);
2095 let mut event_buf = Vec::with_capacity(32);
2099
2100 ws.common.events.subscribe(move |event| {
2104 let Some(ref sender) = event_tx else { return };
2105 match event {
2106 WebsocketEvent::Message(json_str) => {
2107 heartbeat_flag.store(true, Ordering::Release);
2108 let terminated = convert_margin_user_data_events_with(
2111 &json_str,
2112 &mut event_buf,
2113 &mut handle_position,
2114 );
2115 for ev in event_buf.drain(..) {
2116 if let Some(key) = dedup_key_from_event(&ev)
2117 && is_duplicate(&dedup, key)
2118 {
2119 trace!("BinanceMargin dedup: skipping duplicate event");
2120 continue;
2121 }
2122 if sender.send(ev).is_err() {
2123 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
2124 event_tx.take();
2125 if let Some(s) = signal_tx_opt.take() {
2126 let _ = s.send(());
2127 }
2128 return;
2129 }
2130 }
2131 if terminated {
2132 event_tx.take();
2133 if let Some(s) = signal_tx_opt.take() {
2134 let _ = s.send(());
2135 }
2136 }
2137 }
2138 WebsocketEvent::Ping | WebsocketEvent::Pong => {
2139 heartbeat_flag.store(true, Ordering::Release);
2140 }
2141 WebsocketEvent::Error(e) => {
2142 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
2143 event_tx.take();
2144 if let Some(s) = signal_tx_opt.take() {
2145 let _ = s.send(());
2146 }
2147 }
2148 WebsocketEvent::Close(code, reason) => {
2149 warn!(code, %reason, "BinanceMargin WebSocket closed");
2150 event_tx.take();
2151 if let Some(s) = signal_tx_opt.take() {
2152 let _ = s.send(());
2153 }
2154 }
2155 _ => {
2156 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
2157 }
2158 }
2159 })
2160}
2161
2162async fn recover_margin_fills(
2167 rest: &Arc<RestApi>,
2168 rate_limiter: &Arc<RateLimitTracker>,
2169 instruments: &[InstrumentNameExchange],
2170 disconnect_time: DateTime<Utc>,
2171 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2172 dedup: &SharedDedupCache,
2173 is_isolated: bool,
2174) {
2175 use futures::StreamExt as _;
2176
2177 if instruments.is_empty() {
2178 debug!("BinanceMargin recover_fills: empty instruments — no fills recovered");
2179 return;
2180 }
2181 info!(
2182 since = %disconnect_time,
2183 instruments = instruments.len(),
2184 "BinanceMargin recovering fills after reconnect"
2185 );
2186
2187 let start_time_ms = disconnect_time.timestamp_millis();
2188 let mut recovered = 0u32;
2189 let mut duplicates = 0u32;
2190 let mut failed_instruments = 0u32;
2191
2192 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
2193 let rest = rest.clone();
2194 let rl = rate_limiter.clone();
2195 async move {
2196 match paginate_margin_my_trades(&rest, &rl, &inst, start_time_ms, is_isolated).await {
2197 Ok(pages) => Some(
2198 pages
2199 .iter()
2200 .filter_map(|t| convert_margin_trade(t, &inst))
2201 .collect::<Vec<_>>(),
2202 ),
2203 Err(e) => {
2204 warn!(%e, %inst, "BinanceMargin fill recovery: REST request failed");
2205 None
2206 }
2207 }
2208 }
2209 }))
2210 .buffer_unordered(8);
2211 while let Some(result) = stream.next().await {
2212 let trades = match result {
2213 Some(t) => t,
2214 None => {
2215 failed_instruments += 1;
2216 continue;
2217 }
2218 };
2219 for trade in trades {
2220 let event = UnindexedAccountEvent::new(
2221 ExchangeId::BinanceMargin,
2222 AccountEventKind::Trade(trade),
2223 );
2224 if let Some(key) = dedup_key_from_event(&event)
2225 && is_duplicate(dedup, key)
2226 {
2227 duplicates += 1;
2228 continue;
2229 }
2230 if tx.send(event).is_err() {
2231 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2232 return;
2233 }
2234 recovered += 1;
2235 }
2236 }
2237
2238 if failed_instruments > 0 {
2239 error!(
2240 recovered,
2241 duplicates,
2242 failed_instruments,
2243 "BinanceMargin fill recovery complete with failures — some fills may be permanently missed"
2244 );
2245 } else {
2246 info!(
2247 recovered,
2248 duplicates, "BinanceMargin fill recovery complete"
2249 );
2250 }
2251}
2252
2253#[allow(
2260 clippy::cognitive_complexity,
2261 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2262 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2263)]
2264async fn margin_connection_manager(
2265 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2266 dedup: SharedDedupCache,
2267 ws_config: ConfigurationWebsocketApi,
2268 rest_config: Arc<ConfigurationRestApi>,
2269 rest: Arc<RestApi>,
2270 rate_limiter: Arc<RateLimitTracker>,
2271 instruments: Vec<InstrumentNameExchange>,
2272 initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2273) {
2274 enum DisconnectReason {
2275 Signal,
2276 HeartbeatTimeout,
2277 TokenRefresh,
2278 ConsumerDropped,
2279 }
2280
2281 let mut backoff = ExponentialBackoff::new();
2282 let mut disconnect_time: Option<DateTime<Utc>> = None;
2283 let (mut current_ws, mut current_token) = match initial {
2284 Some((ws, token)) => (Some(ws), Some(token)),
2285 None => (None, None),
2286 };
2287
2288 loop {
2289 let token = match current_token.take() {
2291 Some(t) => t,
2292 None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2293 Ok(t) => t,
2294 Err(e) => {
2295 error!(%e, "BinanceMargin userListenToken acquisition failed");
2296 if !backoff.wait().await {
2297 error!("BinanceMargin max reconnect attempts exhausted");
2298 break;
2299 }
2300 continue;
2301 }
2302 },
2303 };
2304
2305 let ws = match current_ws.take() {
2307 Some(ws) => ws,
2308 None => match connect_margin_ws(&ws_config).await {
2309 Ok(ws) => ws,
2310 Err(e) => {
2311 error!(%e, "BinanceMargin WS connect failed");
2312 if !backoff.wait().await {
2313 error!("BinanceMargin max reconnect attempts exhausted");
2314 break;
2315 }
2316 continue;
2317 }
2318 },
2319 };
2320
2321 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2323 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2325 let subscription = register_user_data_listener(
2328 &ws,
2329 tx.clone(),
2330 dedup.clone(),
2331 heartbeat_flag.clone(),
2332 signal_tx,
2333 cross_account_position_handler,
2334 );
2335
2336 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2338 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2339 subscription.unsubscribe();
2340 if let Err(de) = ws.disconnect().await {
2341 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2342 }
2343 if !backoff.wait().await {
2345 error!("BinanceMargin max reconnect attempts exhausted");
2346 break;
2347 }
2348 continue;
2349 }
2350 info!("BinanceMargin account_stream connected and subscribed");
2351 backoff.reset();
2355
2356 let token_deadline =
2359 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2360
2361 if let Some(dt) = disconnect_time.take()
2363 && tokio::time::timeout(
2364 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2365 recover_margin_fills(&rest, &rate_limiter, &instruments, dt, &tx, &dedup, false),
2368 )
2369 .await
2370 .is_err()
2371 {
2372 warn!(
2373 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2374 "BinanceMargin fill recovery timed out — remaining instruments not queried"
2375 );
2376 }
2377
2378 let reason = {
2380 let mut signal_rx = signal_rx;
2381 loop {
2382 tokio::select! {
2383 biased;
2386 _ = tx.closed() => {
2387 debug!("BinanceMargin account_stream consumer dropped, terminating");
2388 break DisconnectReason::ConsumerDropped;
2389 }
2390 _ = &mut signal_rx => {
2391 warn!("BinanceMargin WS disconnected, will attempt reconnect");
2392 break DisconnectReason::Signal;
2393 }
2394 () = tokio::time::sleep_until(token_deadline) => {
2395 info!("BinanceMargin userListenToken nearing expiry, renewing");
2396 break DisconnectReason::TokenRefresh;
2397 }
2398 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2399 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2400 continue;
2404 }
2405 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2406 break DisconnectReason::HeartbeatTimeout;
2407 }
2408 }
2409 }
2410 };
2411 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2412
2413 if should_reconnect {
2414 disconnect_time = Some(match reason {
2415 DisconnectReason::HeartbeatTimeout => {
2416 Utc::now()
2417 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2418 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2419 }
2420 DisconnectReason::TokenRefresh => {
2421 Utc::now()
2426 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2427 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2428 }
2429 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2430 });
2431 }
2432
2433 subscription.unsubscribe();
2435 if let Err(e) = ws.disconnect().await {
2436 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2437 }
2438
2439 if !should_reconnect || tx.is_closed() {
2440 debug!("BinanceMargin connection manager exiting");
2441 break;
2442 }
2443
2444 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2447 error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
2448 break;
2449 }
2450 }
2451}
2452
2453fn build_base_quote_map(
2465 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2466) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2467 let mut map = HashMap::with_capacity(assets.len());
2468 for entry in assets {
2469 let (Some(symbol), Some(base), Some(quote)) = (
2470 entry.symbol.as_deref(),
2471 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2472 entry
2473 .quote_asset
2474 .as_deref()
2475 .and_then(|q| q.asset.as_deref()),
2476 ) else {
2477 warn!(
2478 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2479 skipping base/quote map entry"
2480 );
2481 continue;
2482 };
2483 map.insert(
2484 InstrumentNameExchange::new(symbol),
2485 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2486 );
2487 }
2488 map
2489}
2490
2491async fn acquire_all_isolated_tokens(
2504 rest_config: &Arc<ConfigurationRestApi>,
2505 rate_limiter: &Arc<RateLimitTracker>,
2506 symbols: &[InstrumentNameExchange],
2507) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
2508 use futures::{StreamExt as _, TryStreamExt as _};
2509
2510 futures::stream::iter(symbols.iter().cloned().map(|sym| {
2511 let rest_config = rest_config.clone();
2512 let rate_limiter = rate_limiter.clone();
2513 async move {
2514 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
2515 Ok::<_, UnindexedClientError>((sym, token))
2516 }
2517 }))
2518 .buffer_unordered(8)
2519 .try_collect()
2520 .await
2521}
2522
2523fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
2528 tokens
2529 .iter()
2530 .map(|(_, t)| t.expiration_time_ms)
2531 .min()
2532 .unwrap_or(i64::MAX)
2533}
2534
2535struct IsolatedLiveConn {
2538 ws: Arc<WsApiBase>,
2539 subscription: Subscription,
2540 signal_rx: oneshot::Receiver<()>,
2541 heartbeat_flag: Arc<AtomicBool>,
2542 earliest_expiry_ms: i64,
2545}
2546
2547async fn isolated_connect_and_subscribe(
2557 ws_config: &ConfigurationWebsocketApi,
2558 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2559 dedup: &SharedDedupCache,
2560 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2561 tokens: &[(InstrumentNameExchange, UserListenToken)],
2562) -> anyhow::Result<IsolatedLiveConn> {
2563 let ws = connect_margin_ws(ws_config).await?;
2564 let sub_map = Arc::new(Mutex::new(
2570 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
2571 ));
2572 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2573 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2575
2576 let handle_position = {
2577 let sub_map = sub_map.clone();
2578 let base_quote = base_quote.clone();
2579 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
2580 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
2581 }
2582 };
2583 let subscription = register_user_data_listener(
2585 &ws,
2586 tx.clone(),
2587 dedup.clone(),
2588 heartbeat_flag.clone(),
2589 signal_tx,
2590 handle_position,
2591 );
2592
2593 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
2594 for (sym, token) in tokens {
2595 match subscribe_listen_token_capture(&ws, &token.token).await {
2596 Ok(Some(id)) => {
2597 sub_map
2598 .lock()
2599 .unwrap_or_else(|p| p.into_inner())
2600 .insert(id, sym.clone());
2601 }
2602 Ok(None) => warn!(
2603 symbol = %sym.name(),
2604 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
2605 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
2606 ),
2607 Err(e) => {
2608 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
2611 subscription.unsubscribe();
2612 if let Err(de) = ws.disconnect().await {
2613 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
2614 }
2615 return Err(e);
2616 }
2617 }
2618 }
2619
2620 Ok(IsolatedLiveConn {
2621 ws,
2622 subscription,
2623 signal_rx,
2624 heartbeat_flag,
2625 earliest_expiry_ms,
2626 })
2627}
2628
2629#[allow(
2638 clippy::cognitive_complexity,
2639 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
2640 + backoff); mirrors the cross manager, not worth splitting further"
2641)]
2642async fn isolated_connection_manager(
2643 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2644 dedup: SharedDedupCache,
2645 ws_config: ConfigurationWebsocketApi,
2646 rest_config: Arc<ConfigurationRestApi>,
2647 rest: Arc<RestApi>,
2648 rate_limiter: Arc<RateLimitTracker>,
2649 symbols: Vec<InstrumentNameExchange>,
2650 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2651 initial: Option<IsolatedLiveConn>,
2652) {
2653 enum DisconnectReason {
2654 Signal,
2655 HeartbeatTimeout,
2656 TokenRefresh,
2657 ConsumerDropped,
2658 }
2659
2660 let mut backoff = ExponentialBackoff::new();
2661 let mut disconnect_time: Option<DateTime<Utc>> = None;
2662 let mut current = initial;
2663
2664 loop {
2665 let IsolatedLiveConn {
2667 ws,
2668 subscription,
2669 signal_rx,
2670 heartbeat_flag,
2671 earliest_expiry_ms,
2672 } = match current.take() {
2673 Some(live) => live,
2675 None => {
2676 let tokens = match acquire_all_isolated_tokens(
2679 &rest_config,
2680 &rate_limiter,
2681 &symbols,
2682 )
2683 .await
2684 {
2685 Ok(t) => t,
2686 Err(e) => {
2687 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
2688 if !backoff.wait().await {
2689 error!("BinanceMargin isolated max reconnect attempts exhausted");
2690 break;
2691 }
2692 continue;
2693 }
2694 };
2695 match isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
2696 .await
2697 {
2698 Ok(live) => live,
2699 Err(e) => {
2700 error!(%e, "BinanceMargin isolated connect/subscribe failed");
2701 if !backoff.wait().await {
2702 error!("BinanceMargin isolated max reconnect attempts exhausted");
2703 break;
2704 }
2705 continue;
2706 }
2707 }
2708 }
2709 };
2710
2711 info!(
2712 symbols = symbols.len(),
2713 "BinanceMargin isolated account_stream connected and subscribed"
2714 );
2715 backoff.reset();
2718
2719 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
2722
2723 if let Some(dt) = disconnect_time.take()
2725 && tokio::time::timeout(
2726 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2727 recover_margin_fills(&rest, &rate_limiter, &symbols, dt, &tx, &dedup, true),
2729 )
2730 .await
2731 .is_err()
2732 {
2733 warn!(
2734 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2735 "BinanceMargin isolated fill recovery timed out — remaining instruments not queried"
2736 );
2737 }
2738
2739 let reason = {
2742 let mut signal_rx = signal_rx;
2743 loop {
2744 tokio::select! {
2745 biased;
2746 _ = tx.closed() => {
2747 debug!("BinanceMargin isolated consumer dropped, terminating");
2748 break DisconnectReason::ConsumerDropped;
2749 }
2750 _ = &mut signal_rx => {
2751 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
2752 break DisconnectReason::Signal;
2753 }
2754 () = tokio::time::sleep_until(token_deadline) => {
2755 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
2756 break DisconnectReason::TokenRefresh;
2757 }
2758 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2759 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2760 continue;
2761 }
2762 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2763 break DisconnectReason::HeartbeatTimeout;
2764 }
2765 }
2766 }
2767 };
2768 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2769
2770 if should_reconnect {
2771 disconnect_time = Some(match reason {
2772 DisconnectReason::HeartbeatTimeout => {
2773 Utc::now()
2774 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2775 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2776 }
2777 DisconnectReason::TokenRefresh => {
2778 Utc::now()
2782 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2783 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2784 }
2785 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2786 });
2787 }
2788
2789 subscription.unsubscribe();
2791 if let Err(e) = ws.disconnect().await {
2792 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
2793 }
2794
2795 if !should_reconnect || tx.is_closed() {
2796 debug!("BinanceMargin isolated connection manager exiting");
2797 break;
2798 }
2799
2800 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2803 error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
2804 break;
2805 }
2806 }
2807}
2808
2809async fn fetch_margin_open_orders_for_instrument(
2816 rest: Arc<RestApi>,
2817 rate_limiter: Arc<RateLimitTracker>,
2818 instrument: InstrumentNameExchange,
2819 is_isolated: bool,
2820) -> Result<
2821 (
2822 InstrumentNameExchange,
2823 Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
2824 ),
2825 UnindexedClientError,
2826> {
2827 let symbol_str = instrument.name().to_string();
2829 let isolated = isolated_str(is_isolated);
2830 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
2831 let sym = symbol_str.clone();
2832 let isolated = isolated.clone();
2833 Box::pin(async move {
2834 let params = QueryMarginAccountsOpenOrdersParams::builder()
2835 .symbol(sym)
2836 .is_isolated(isolated)
2837 .build()?;
2838 rest.query_margin_accounts_open_orders(params).await
2839 })
2840 })
2841 .await
2842 .map_err(connectivity_error)?;
2843
2844 let orders_data = response
2845 .data()
2846 .await
2847 .map_err(|e| connectivity_error(e.into()))?;
2848
2849 let orders = orders_data
2850 .into_iter()
2851 .filter_map(|o| convert_margin_open_order(&o, &instrument))
2852 .collect();
2853
2854 Ok((instrument, orders))
2855}
2856
2857async fn fetch_margin_all_open_orders(
2864 rest: Arc<RestApi>,
2865 rate_limiter: Arc<RateLimitTracker>,
2866 is_isolated: bool,
2867) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2868 let isolated = isolated_str(is_isolated);
2869 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
2870 let isolated = isolated.clone();
2871 Box::pin(async move {
2872 let params = QueryMarginAccountsOpenOrdersParams::builder()
2873 .is_isolated(isolated)
2874 .build()?;
2875 rest.query_margin_accounts_open_orders(params).await
2876 })
2877 })
2878 .await
2879 .map_err(connectivity_error)?;
2880
2881 let orders_data = response
2882 .data()
2883 .await
2884 .map_err(|e| connectivity_error(e.into()))?;
2885
2886 let orders = orders_data
2887 .into_iter()
2888 .filter_map(|o| convert_margin_open_order_owned_symbol(&o))
2889 .collect();
2890
2891 Ok(orders)
2892}
2893
2894async fn paginate_margin_my_trades(
2903 rest: &Arc<RestApi>,
2904 rate_limiter: &Arc<RateLimitTracker>,
2905 instrument: &InstrumentNameExchange,
2906 start_time_ms: i64,
2907 is_isolated: bool,
2908) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
2909 let symbol_str = instrument.name().to_string();
2911 let isolated = isolated_str(is_isolated);
2912 #[allow(clippy::cast_possible_wrap)]
2917 let limit = BINANCE_MAX_TRADES as i64;
2918 let mut all_pages = Vec::new();
2919 let mut cursor: Option<i64> = None;
2920 loop {
2921 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, |rest| {
2923 let sym = symbol_str.clone();
2924 let isolated = isolated.clone();
2925 let stm = start_time_ms;
2926 Box::pin(async move {
2927 let builder = QueryMarginAccountsTradeListParams::builder(sym)
2928 .is_isolated(isolated)
2929 .limit(limit);
2930 let params = if let Some(id) = fid {
2931 builder.from_id(id).build()?
2932 } else {
2933 builder.start_time(stm).build()?
2934 };
2935 rest.query_margin_accounts_trade_list(params).await
2936 })
2937 })
2938 .await
2939 .map_err(connectivity_error)?;
2940
2941 let page = response
2942 .data()
2943 .await
2944 .map_err(|e| connectivity_error(e.into()))?;
2945
2946 let page_len = page.len();
2947 let last_id = page.last().and_then(|t| t.id);
2948 all_pages.extend(page);
2949
2950 if page_len < BINANCE_MAX_TRADES {
2951 break;
2952 }
2953 match last_id {
2954 Some(id) => {
2955 debug!(%instrument, "BinanceMargin paginate_my_trades: fetching next page ({page_len} results)");
2956 match id.checked_add(1) {
2957 Some(next) => cursor = Some(next),
2958 None => break, }
2960 }
2961 None => {
2962 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, stopping pagination");
2963 break;
2964 }
2965 }
2966 }
2967 Ok(all_pages)
2968}
2969
2970fn convert_margin_balance_entry(
2980 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
2981 now: DateTime<Utc>,
2982) -> Option<AssetBalance<AssetNameExchange>> {
2983 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
2984 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2985 Some(v) => v,
2986 None => {
2987 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
2988 return None;
2989 }
2990 };
2991 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2992 Some(v) => v,
2993 None => {
2994 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
2995 return None;
2996 }
2997 };
2998 let borrowed = b
3003 .borrowed
3004 .as_deref()
3005 .and_then(|s| Decimal::from_str(s).ok())
3006 .unwrap_or(Decimal::ZERO);
3007 let interest = b
3008 .interest
3009 .as_deref()
3010 .and_then(|s| Decimal::from_str(s).ok())
3011 .unwrap_or(Decimal::ZERO);
3012 Some(AssetBalance::new(
3013 asset_name,
3014 Balance::new_margin(free + locked, free, borrowed, interest),
3015 now,
3016 ))
3017}
3018
3019fn filter_and_convert_margin_balances(
3022 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3023 assets: &[AssetNameExchange],
3024) -> Vec<AssetBalance<AssetNameExchange>> {
3025 let now = Utc::now();
3026 if assets.is_empty() {
3028 return user_assets
3029 .into_iter()
3030 .filter_map(|b| convert_margin_balance_entry(b, now))
3031 .collect();
3032 }
3033 if assets.len() <= 16 {
3035 return user_assets
3036 .into_iter()
3037 .filter_map(|b| {
3038 let asset_name_str = b.asset.as_deref()?;
3039 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3040 return None;
3041 }
3042 convert_margin_balance_entry(b, now)
3043 })
3044 .collect();
3045 }
3046 use std::collections::HashSet;
3047 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3048 user_assets
3049 .into_iter()
3050 .filter_map(|b| {
3051 let asset_name_str = b.asset.as_deref()?;
3052 if !asset_set.contains(asset_name_str) {
3053 return None;
3054 }
3055 convert_margin_balance_entry(b, now)
3056 })
3057 .collect()
3058}
3059
3060fn active_order_snapshot(
3067 o: Order<ExchangeId, InstrumentNameExchange, Open>,
3068) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3069{
3070 Order {
3071 key: o.key,
3072 side: o.side,
3073 price: o.price,
3074 quantity: o.quantity,
3075 kind: o.kind,
3076 time_in_force: o.time_in_force,
3077 state: OrderState::active(o.state),
3078 }
3079}
3080
3081fn convert_isolated_asset_balance(
3091 asset: Option<&str>,
3092 free: Option<&str>,
3093 locked: Option<&str>,
3094 borrowed: Option<&str>,
3095 interest: Option<&str>,
3096 now: DateTime<Utc>,
3097) -> Option<AssetBalance<AssetNameExchange>> {
3098 let asset_name = AssetNameExchange::new(asset?);
3099 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3100 Some(v) => v,
3101 None => {
3102 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3103 return None;
3104 }
3105 };
3106 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3107 Some(v) => v,
3108 None => {
3109 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3110 return None;
3111 }
3112 };
3113 let borrowed = borrowed
3114 .and_then(|s| Decimal::from_str(s).ok())
3115 .unwrap_or(Decimal::ZERO);
3116 let interest = interest
3117 .and_then(|s| Decimal::from_str(s).ok())
3118 .unwrap_or(Decimal::ZERO);
3119 Some(AssetBalance::new(
3120 asset_name,
3121 Balance::new_margin(free + locked, free, borrowed, interest),
3122 now,
3123 ))
3124}
3125
3126fn convert_isolated_margin_assets(
3134 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3135) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3136 let now = Utc::now();
3139 let mut map = HashMap::with_capacity(assets.len());
3140 for entry in assets {
3141 let Some(symbol) = entry.symbol.as_deref() else {
3142 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3143 continue;
3144 };
3145 let instrument = InstrumentNameExchange::new(symbol);
3146
3147 let Some(base_raw) = entry.base_asset.as_deref() else {
3148 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3149 continue;
3150 };
3151 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3152 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3153 continue;
3154 };
3155
3156 let Some(base) = convert_isolated_asset_balance(
3157 base_raw.asset.as_deref(),
3158 base_raw.free.as_deref(),
3159 base_raw.locked.as_deref(),
3160 base_raw.borrowed.as_deref(),
3161 base_raw.interest.as_deref(),
3162 now,
3163 ) else {
3164 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3165 continue;
3166 };
3167 let Some(quote) = convert_isolated_asset_balance(
3168 quote_raw.asset.as_deref(),
3169 quote_raw.free.as_deref(),
3170 quote_raw.locked.as_deref(),
3171 quote_raw.borrowed.as_deref(),
3172 quote_raw.interest.as_deref(),
3173 now,
3174 ) else {
3175 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3176 continue;
3177 };
3178
3179 let risk = IsolatedMarginRisk {
3180 margin_level: entry
3181 .margin_level
3182 .as_deref()
3183 .and_then(|s| Decimal::from_str(s).ok()),
3184 margin_ratio: entry
3185 .margin_ratio
3186 .as_deref()
3187 .and_then(|s| Decimal::from_str(s).ok()),
3188 liquidation_price: entry
3189 .liquidate_price
3190 .as_deref()
3191 .and_then(|s| Decimal::from_str(s).ok()),
3192 };
3193
3194 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3195 }
3196 map
3197}
3198
3199fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3202 symbols
3203 .chunks(5)
3204 .map(|chunk| {
3205 chunk
3206 .iter()
3207 .map(|s| s.name().as_str())
3208 .collect::<Vec<_>>()
3209 .join(",")
3210 })
3211 .collect()
3212}
3213
3214async fn fetch_isolated_margin_account_info(
3220 rest: Arc<RestApi>,
3221 rate_limiter: Arc<RateLimitTracker>,
3222 symbols: Vec<InstrumentNameExchange>,
3223) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
3224 use futures::{StreamExt as _, TryStreamExt as _};
3225
3226 let chunks = chunk_symbols(&symbols);
3227
3228 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3232 let rest = rest.clone();
3233 let rate_limiter = rate_limiter.clone();
3234 async move {
3235 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
3236 let symbols_param = symbols_param.clone();
3237 Box::pin(async move {
3238 let params = QueryIsolatedMarginAccountInfoParams::builder()
3239 .symbols(symbols_param)
3240 .build()?;
3241 rest.query_isolated_margin_account_info(params).await
3242 })
3243 })
3244 .await
3245 .map_err(connectivity_error)?;
3246
3247 let info = response
3248 .data()
3249 .await
3250 .map_err(|e| connectivity_error(e.into()))?;
3251 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3252 }
3253 }))
3254 .buffer_unordered(8)
3255 .try_fold(
3256 Vec::with_capacity(symbols.len()),
3257 |mut acc, assets| async move {
3258 acc.extend(assets);
3259 Ok(acc)
3260 },
3261 )
3262 .await
3263}
3264
3265fn convert_margin_open_order(
3272 o: &QueryMarginAccountsOpenOrdersResponseInner,
3273 instrument: &InstrumentNameExchange,
3274) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3275 let order_id_raw = match o.order_id {
3276 Some(id) => id,
3277 None => {
3278 warn!(%instrument, "BinanceMargin open order missing orderId");
3279 return None;
3280 }
3281 };
3282 let order_id = OrderId(format_smolstr!("{order_id_raw}"));
3283 if o.client_order_id.is_none() {
3284 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing clientOrderId, using orderId as fallback — order may not reconcile with engine state");
3285 }
3286 let cid = ClientOrderId::new(
3287 o.client_order_id
3288 .as_deref()
3289 .unwrap_or(&format_smolstr!("{order_id_raw}")),
3290 );
3291 let side = match o.side.as_deref() {
3292 Some(s) => parse_side(s)?,
3294 None => {
3295 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing side");
3296 return None;
3297 }
3298 };
3299 let price = o.price.as_deref().and_then(|s| Decimal::from_str(s).ok());
3300 let quantity = match o
3301 .orig_qty
3302 .as_deref()
3303 .and_then(|s| Decimal::from_str(s).ok())
3304 {
3305 Some(v) => v,
3306 None => {
3307 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing/unparseable origQty");
3308 return None;
3309 }
3310 };
3311 let filled_qty = match o.executed_qty.as_deref() {
3312 Some(s) => match Decimal::from_str(s) {
3313 Ok(v) => v,
3314 Err(_) => {
3315 warn!(%instrument, order_id = %order_id_raw, executed_qty = s, "BinanceMargin open order unparseable executedQty, defaulting to 0");
3316 Decimal::ZERO
3317 }
3318 },
3319 None => Decimal::ZERO,
3320 };
3321 let kind = match o.r#type.as_deref() {
3322 Some(t) => parse_order_kind(t)?,
3324 None => {
3325 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing type");
3326 return None;
3327 }
3328 };
3329 let time_in_force = parse_time_in_force(o.time_in_force.as_deref().unwrap_or("GTC"));
3330 let time_exchange = match o.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3331 Some(ts) => ts,
3332 None => {
3333 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing/unparseable time, using now");
3334 Utc::now()
3335 }
3336 };
3337
3338 Some(Order {
3339 key: OrderKey::new(
3340 ExchangeId::BinanceMargin,
3341 instrument.clone(),
3342 StrategyId::unknown(),
3345 cid,
3346 ),
3347 side,
3348 price,
3349 quantity,
3350 kind,
3351 time_in_force,
3352 state: Open::new(order_id, time_exchange, filled_qty),
3353 })
3354}
3355
3356fn convert_margin_open_order_owned_symbol(
3362 o: &QueryMarginAccountsOpenOrdersResponseInner,
3363) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3364 let instrument = match o.symbol.as_deref() {
3365 Some(s) => InstrumentNameExchange::new(s),
3366 None => {
3367 warn!("BinanceMargin open order missing symbol in return-all query, dropping order");
3368 return None;
3369 }
3370 };
3371 convert_margin_open_order(o, &instrument)
3372}
3373
3374fn convert_margin_trade(
3381 t: &QueryMarginAccountsTradeListResponseInner,
3382 instrument: &InstrumentNameExchange,
3383) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3384 let trade_id_raw = match t.id {
3385 Some(id) => id,
3386 None => {
3387 warn!(%instrument, "BinanceMargin trade missing id");
3388 return None;
3389 }
3390 };
3391 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3392 let order_id = match t.order_id {
3393 Some(id) => OrderId(format_smolstr!("{id}")),
3394 None => {
3395 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3396 return None;
3397 }
3398 };
3399 let side = match t.is_buyer {
3400 Some(true) => Side::Buy,
3401 Some(false) => Side::Sell,
3402 None => {
3403 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3404 return None;
3405 }
3406 };
3407 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3408 Some(v) => v,
3409 None => {
3410 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3411 return None;
3412 }
3413 };
3414 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3415 Some(v) => v,
3416 None => {
3417 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3418 return None;
3419 }
3420 };
3421 let commission = t
3422 .commission
3423 .as_deref()
3424 .and_then(|s| Decimal::from_str(s).ok())
3425 .unwrap_or(Decimal::ZERO);
3426 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3427 Some(ts) => ts,
3428 None => {
3429 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3430 Utc::now()
3431 }
3432 };
3433
3434 let fee_asset = t
3438 .commission_asset
3439 .as_deref()
3440 .map(AssetNameExchange::from)
3441 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3442
3443 Some(Trade::new(
3444 trade_id,
3445 order_id,
3446 instrument.clone(),
3447 StrategyId::unknown(), time_exchange,
3449 side,
3450 price,
3451 quantity,
3452 AssetFees::new(fee_asset, commission, None),
3453 ))
3454}
3455
3456#[derive(Debug)]
3462enum BuildOrderError {
3463 Unsupported,
3465 Build(String),
3467}
3468
3469fn isolated_str(is_isolated: bool) -> String {
3475 if is_isolated { "TRUE" } else { "FALSE" }.to_string()
3476}
3477
3478fn build_cancel_order_params(
3486 symbol: String,
3487 id: Option<&OrderId>,
3488 cid: &ClientOrderId,
3489 is_isolated: bool,
3490) -> Result<MarginAccountCancelOrderParams, String> {
3491 let mut builder =
3492 MarginAccountCancelOrderParams::builder(symbol).is_isolated(isolated_str(is_isolated));
3493
3494 match id {
3495 Some(order_id) => match order_id.0.parse::<i64>() {
3496 Ok(id) => builder = builder.order_id(id),
3497 Err(_) => {
3498 error!(
3500 order_id = %order_id.0,
3501 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3502 );
3503 builder = builder.orig_client_order_id(cid.0.to_string());
3504 }
3505 },
3506 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3507 }
3508
3509 builder.build().map_err(|e| e.to_string())
3510}
3511
3512#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3521 symbol: String,
3522 side: Side,
3523 price: Option<Decimal>,
3524 quantity: Decimal,
3525 kind: OrderKind,
3526 time_in_force: TimeInForce,
3527 new_client_order_id: String,
3528 side_effect: MarginSideEffect,
3529 is_isolated: bool,
3530) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3531 let binance_side = match side {
3532 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3533 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3534 };
3535
3536 let (binance_type, binance_tif) =
3537 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3538
3539 let mut builder = MarginAccountNewOrderParams::builder(
3541 symbol,
3542 binance_side,
3543 binance_type.as_binance_str().to_string(),
3544 )
3545 .quantity(quantity)
3546 .is_isolated(isolated_str(is_isolated))
3547 .side_effect_type(side_effect.as_binance_str().to_string())
3548 .new_client_order_id(new_client_order_id)
3549 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3550
3551 if side_effect == MarginSideEffect::AutoBorrowRepay {
3554 builder = builder.auto_repay_at_cancel(true);
3555 }
3556
3557 if let Some(tif) = binance_tif {
3558 builder = builder.time_in_force(tif);
3559 }
3560
3561 match kind {
3564 OrderKind::Limit => {
3565 builder = builder.price(price);
3566 }
3567 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3568 builder = builder.stop_price(trigger_price);
3569 }
3570 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3571 builder = builder.price(price).stop_price(trigger_price);
3572 }
3573 _ => {}
3577 }
3578
3579 builder
3580 .build()
3581 .map_err(|e| BuildOrderError::Build(e.to_string()))
3582}
3583
3584fn convert_order_kind_tif_margin(
3595 kind: OrderKind,
3596 tif: TimeInForce,
3597) -> Option<(
3598 BinanceOrderType,
3599 Option<MarginAccountNewOrderTimeInForceEnum>,
3600)> {
3601 if matches!(
3602 kind,
3603 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. }
3604 ) {
3605 warn!(
3606 ?kind,
3607 "BinanceMargin does not support trailing-stop orders (SDK trailingDelta binding gap)"
3608 );
3609 return None;
3610 }
3611
3612 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3613 let margin_tif = binance_tif.map(|t| match t {
3614 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3615 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3616 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3617 });
3618 Some((binance_type, margin_tif))
3619}
3620
3621fn margin_avg_price(cummulative_quote_qty: Option<&str>, filled_qty: Decimal) -> Option<Decimal> {
3627 if filled_qty.is_zero() {
3628 return None;
3629 }
3630 let s = cummulative_quote_qty?;
3631 match Decimal::from_str(s) {
3632 Ok(cumulative) => cumulative.checked_div(filled_qty),
3633 Err(_) => {
3634 warn!(
3637 cummulative_quote_qty = s,
3638 "BinanceMargin: failed to parse cummulativeQuoteQty; avg price unavailable"
3639 );
3640 None
3641 }
3642 }
3643}
3644
3645#[cfg(test)]
3646#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3648 use super::*;
3649
3650 #[test]
3651 fn margin_side_effect_default_is_auto_borrow_repay() {
3652 assert_eq!(
3653 MarginSideEffect::default(),
3654 MarginSideEffect::AutoBorrowRepay
3655 );
3656 }
3657
3658 #[test]
3659 fn margin_side_effect_wire_strings() {
3660 assert_eq!(
3661 MarginSideEffect::AutoBorrowRepay.as_binance_str(),
3662 "AUTO_BORROW_REPAY"
3663 );
3664 assert_eq!(
3665 MarginSideEffect::NoBorrow.as_binance_str(),
3666 "NO_SIDE_EFFECT"
3667 );
3668 }
3669
3670 #[test]
3671 fn margin_side_effect_serde_round_trip() {
3672 assert_eq!(
3674 serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
3675 r#""auto_borrow_repay""#
3676 );
3677 assert_eq!(
3678 serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
3679 r#""no_borrow""#
3680 );
3681 assert_eq!(
3682 serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
3683 MarginSideEffect::AutoBorrowRepay
3684 );
3685 assert_eq!(
3686 serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
3687 MarginSideEffect::NoBorrow
3688 );
3689 }
3690
3691 #[test]
3692 fn config_debug_redacts_secrets() {
3693 let config = BinanceMarginConfig::new(
3694 "my_api_key".to_string(),
3695 "my_secret_key".to_string(),
3696 false,
3697 false,
3698 Vec::new(),
3699 MarginSideEffect::default(),
3700 );
3701 let debug = format!("{config:?}");
3702 assert!(!debug.contains("my_api_key"));
3703 assert!(!debug.contains("my_secret_key"));
3704 assert!(debug.contains("***"));
3705 }
3706
3707 #[test]
3708 fn cross_margin_uses_common_case_defaults() {
3709 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3710 assert!(!config.testnet);
3711 assert!(!config.is_isolated);
3712 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3713 assert_eq!(config.api_key(), "k");
3714 }
3715
3716 #[test]
3717 fn config_deserializes_with_defaults() {
3718 let config: BinanceMarginConfig =
3720 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3721 .expect("deserialize");
3722 assert!(!config.is_isolated);
3723 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3724 }
3725
3726 #[test]
3727 fn config_deserializes_explicit_side_effect() {
3728 let config: BinanceMarginConfig = serde_json::from_str(
3729 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
3730 )
3731 .expect("deserialize");
3732 assert!(config.is_isolated);
3733 assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
3734 }
3735
3736 #[test]
3737 fn isolated_ctor_sets_symbols_and_flag() {
3738 let symbols = vec![
3739 InstrumentNameExchange::new("BTCUSDT"),
3740 InstrumentNameExchange::new("ETHUSDT"),
3741 ];
3742 let config =
3743 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
3744 assert!(config.is_isolated);
3745 assert_eq!(config.isolated_symbols, symbols);
3746 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3747 }
3748
3749 #[test]
3750 fn isolated_config_with_symbols_constructs() {
3751 let config = BinanceMarginConfig::isolated(
3753 "k".to_string(),
3754 "s".to_string(),
3755 vec![InstrumentNameExchange::new("BTCUSDT")],
3756 );
3757 let _client = BinanceMargin::new(config); }
3759
3760 #[test]
3761 #[should_panic(expected = "non-empty isolated_symbols")]
3762 fn isolated_empty_symbols_panics_at_new() {
3763 let config = BinanceMarginConfig::new(
3765 "k".to_string(),
3766 "s".to_string(),
3767 false,
3768 true,
3769 Vec::new(),
3770 MarginSideEffect::default(),
3771 );
3772 let _ = BinanceMargin::new(config);
3773 }
3774
3775 #[test]
3776 #[should_panic(expected = "non-empty isolated_symbols")]
3777 fn isolated_empty_symbols_panics_via_deserialize_path() {
3778 let config: BinanceMarginConfig = serde_json::from_str(
3781 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
3782 )
3783 .expect("deserialize");
3784 assert!(config.isolated_symbols.is_empty());
3785 let _ = BinanceMargin::new(config);
3786 }
3787
3788 #[test]
3789 fn isolated_str_maps_mode() {
3790 assert_eq!(isolated_str(false), "FALSE");
3791 assert_eq!(isolated_str(true), "TRUE");
3792 }
3793
3794 #[test]
3799 fn cancel_params_cross_with_parseable_order_id() {
3800 let p = build_cancel_order_params(
3801 "BTCUSDT".to_string(),
3802 Some(&OrderId::new("12345")),
3803 &ClientOrderId::new("cid-1"),
3804 false,
3805 )
3806 .expect("build");
3807 assert_eq!(p.symbol, "BTCUSDT");
3808 assert_eq!(p.is_isolated.as_deref(), Some("FALSE"));
3809 assert_eq!(p.order_id, Some(12345));
3810 assert_eq!(p.orig_client_order_id, None);
3811 }
3812
3813 #[test]
3814 fn cancel_params_isolated_is_config_driven() {
3815 let p = build_cancel_order_params(
3816 "BTCUSDT".to_string(),
3817 Some(&OrderId::new("12345")),
3818 &ClientOrderId::new("cid-1"),
3819 true,
3820 )
3821 .expect("build");
3822 assert_eq!(p.is_isolated.as_deref(), Some("TRUE"));
3823 assert_eq!(p.order_id, Some(12345));
3824 }
3825
3826 #[test]
3827 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
3828 let p = build_cancel_order_params(
3829 "BTCUSDT".to_string(),
3830 Some(&OrderId::new("not-an-i64")),
3831 &ClientOrderId::new("cid-1"),
3832 false,
3833 )
3834 .expect("build");
3835 assert_eq!(p.order_id, None);
3836 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3837 }
3838
3839 #[test]
3840 fn cancel_params_absent_order_id_uses_cid() {
3841 let p = build_cancel_order_params(
3842 "BTCUSDT".to_string(),
3843 None,
3844 &ClientOrderId::new("cid-1"),
3845 false,
3846 )
3847 .expect("build");
3848 assert_eq!(p.order_id, None);
3849 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3850 }
3851
3852 use crate::order::TrailingOffsetType;
3857
3858 fn params(
3860 side: Side,
3861 price: Option<Decimal>,
3862 kind: OrderKind,
3863 tif: TimeInForce,
3864 side_effect: MarginSideEffect,
3865 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3866 build_new_order_params(
3867 "BTCUSDT".to_string(),
3868 side,
3869 price,
3870 Decimal::from(2),
3871 kind,
3872 tif,
3873 "cid-1".to_string(),
3874 side_effect,
3875 false, )
3877 }
3878
3879 fn gtc() -> TimeInForce {
3880 TimeInForce::GoodUntilCancelled { post_only: false }
3881 }
3882
3883 #[test]
3884 fn new_order_limit_maps_core_fields() {
3885 let p = params(
3886 Side::Buy,
3887 Some(Decimal::from(50_000)),
3888 OrderKind::Limit,
3889 gtc(),
3890 MarginSideEffect::AutoBorrowRepay,
3891 )
3892 .expect("build");
3893
3894 assert_eq!(p.symbol, "BTCUSDT");
3895 assert_eq!(p.side.as_str(), "BUY");
3896 assert_eq!(p.r#type, "LIMIT");
3897 assert_eq!(p.quantity, Some(Decimal::from(2)));
3898 assert_eq!(p.price, Some(Decimal::from(50_000)));
3899 assert_eq!(p.stop_price, None);
3900 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
3901 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
3902 assert_eq!(
3904 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
3905 Some("FULL")
3906 );
3907 }
3908
3909 #[test]
3910 fn new_order_is_isolated_is_config_driven() {
3911 let cross = build_new_order_params(
3913 "BTCUSDT".to_string(),
3914 Side::Sell,
3915 Some(Decimal::from(10)),
3916 Decimal::from(2),
3917 OrderKind::Limit,
3918 gtc(),
3919 "cid-1".to_string(),
3920 MarginSideEffect::AutoBorrowRepay,
3921 false,
3922 )
3923 .expect("build");
3924 assert_eq!(cross.is_isolated.as_deref(), Some("FALSE"));
3925 assert_eq!(cross.side.as_str(), "SELL");
3926
3927 let isolated = build_new_order_params(
3929 "BTCUSDT".to_string(),
3930 Side::Sell,
3931 Some(Decimal::from(10)),
3932 Decimal::from(2),
3933 OrderKind::Limit,
3934 gtc(),
3935 "cid-1".to_string(),
3936 MarginSideEffect::AutoBorrowRepay,
3937 true,
3938 )
3939 .expect("build");
3940 assert_eq!(isolated.is_isolated.as_deref(), Some("TRUE"));
3941 }
3942
3943 #[test]
3944 fn side_effect_and_auto_repay_gating() {
3945 let auto = params(
3947 Side::Buy,
3948 Some(Decimal::from(1)),
3949 OrderKind::Limit,
3950 gtc(),
3951 MarginSideEffect::AutoBorrowRepay,
3952 )
3953 .expect("build");
3954 assert_eq!(auto.side_effect_type.as_deref(), Some("AUTO_BORROW_REPAY"));
3955 assert_eq!(auto.auto_repay_at_cancel, Some(true));
3956
3957 let no_borrow = params(
3959 Side::Buy,
3960 Some(Decimal::from(1)),
3961 OrderKind::Limit,
3962 gtc(),
3963 MarginSideEffect::NoBorrow,
3964 )
3965 .expect("build");
3966 assert_eq!(
3967 no_borrow.side_effect_type.as_deref(),
3968 Some("NO_SIDE_EFFECT")
3969 );
3970 assert_eq!(no_borrow.auto_repay_at_cancel, None);
3971 }
3972
3973 #[test]
3974 fn new_order_market_has_no_price_or_tif() {
3975 let p = params(
3976 Side::Buy,
3977 None,
3978 OrderKind::Market,
3979 gtc(),
3980 MarginSideEffect::AutoBorrowRepay,
3981 )
3982 .expect("build");
3983 assert_eq!(p.r#type, "MARKET");
3984 assert_eq!(p.price, None);
3985 assert_eq!(p.stop_price, None);
3986 assert!(p.time_in_force.is_none());
3987 }
3988
3989 #[test]
3990 fn post_only_limit_maps_to_limit_maker() {
3991 let p = params(
3992 Side::Buy,
3993 Some(Decimal::from(10)),
3994 OrderKind::Limit,
3995 TimeInForce::GoodUntilCancelled { post_only: true },
3996 MarginSideEffect::AutoBorrowRepay,
3997 )
3998 .expect("build");
3999 assert_eq!(p.r#type, "LIMIT_MAKER");
4000 assert!(p.time_in_force.is_none());
4002 }
4003
4004 #[test]
4005 fn conditional_kinds_set_stop_price() {
4006 let trigger = Decimal::from(48_000);
4007
4008 let stop = params(
4009 Side::Sell,
4010 None,
4011 OrderKind::Stop {
4012 trigger_price: trigger,
4013 },
4014 gtc(),
4015 MarginSideEffect::AutoBorrowRepay,
4016 )
4017 .expect("build");
4018 assert_eq!(stop.r#type, "STOP_LOSS");
4019 assert_eq!(stop.stop_price, Some(trigger));
4020 assert_eq!(stop.price, None);
4021
4022 let stop_limit = params(
4023 Side::Sell,
4024 Some(Decimal::from(47_900)),
4025 OrderKind::StopLimit {
4026 trigger_price: trigger,
4027 },
4028 gtc(),
4029 MarginSideEffect::AutoBorrowRepay,
4030 )
4031 .expect("build");
4032 assert_eq!(stop_limit.r#type, "STOP_LOSS_LIMIT");
4033 assert_eq!(stop_limit.stop_price, Some(trigger));
4034 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4035 assert_eq!(
4036 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4037 Some("GTC")
4038 );
4039
4040 let take_profit = params(
4041 Side::Sell,
4042 None,
4043 OrderKind::TakeProfit {
4044 trigger_price: trigger,
4045 },
4046 gtc(),
4047 MarginSideEffect::AutoBorrowRepay,
4048 )
4049 .expect("build");
4050 assert_eq!(take_profit.r#type, "TAKE_PROFIT");
4051 assert_eq!(take_profit.stop_price, Some(trigger));
4052 }
4053
4054 #[test]
4055 fn trailing_kinds_are_rejected() {
4056 let trailing_stop = params(
4057 Side::Sell,
4058 None,
4059 OrderKind::TrailingStop {
4060 offset: Decimal::from(100),
4061 offset_type: TrailingOffsetType::BasisPoints,
4062 },
4063 gtc(),
4064 MarginSideEffect::AutoBorrowRepay,
4065 );
4066 assert!(matches!(trailing_stop, Err(BuildOrderError::Unsupported)));
4067
4068 let trailing_stop_limit = params(
4069 Side::Sell,
4070 Some(Decimal::from(100)),
4071 OrderKind::TrailingStopLimit {
4072 offset: Decimal::from(100),
4073 offset_type: TrailingOffsetType::BasisPoints,
4074 limit_offset: Decimal::from(10),
4075 },
4076 gtc(),
4077 MarginSideEffect::AutoBorrowRepay,
4078 );
4079 assert!(matches!(
4080 trailing_stop_limit,
4081 Err(BuildOrderError::Unsupported)
4082 ));
4083 }
4084
4085 #[test]
4086 fn tif_variants_map_to_margin_enum() {
4087 for (tif, expected) in [
4088 (TimeInForce::ImmediateOrCancel, "IOC"),
4089 (TimeInForce::FillOrKill, "FOK"),
4090 (gtc(), "GTC"),
4091 ] {
4092 let p = params(
4093 Side::Buy,
4094 Some(Decimal::from(1)),
4095 OrderKind::Limit,
4096 tif,
4097 MarginSideEffect::AutoBorrowRepay,
4098 )
4099 .expect("build");
4100 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4101 }
4102 }
4103
4104 #[test]
4105 fn avg_price_from_cumulative_quote_qty() {
4106 assert_eq!(
4108 margin_avg_price(Some("100"), Decimal::from(4)),
4109 Some(Decimal::from(25))
4110 );
4111 assert_eq!(margin_avg_price(Some("100"), Decimal::ZERO), None);
4113 assert_eq!(margin_avg_price(None, Decimal::from(4)), None);
4115 assert_eq!(
4116 margin_avg_price(Some("not-a-number"), Decimal::from(4)),
4117 None
4118 );
4119 }
4120
4121 fn user_asset(
4126 asset: &str,
4127 free: &str,
4128 locked: &str,
4129 borrowed: &str,
4130 interest: &str,
4131 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4132 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4133 a.asset = Some(asset.to_string());
4134 a.free = Some(free.to_string());
4135 a.locked = Some(locked.to_string());
4136 a.borrowed = Some(borrowed.to_string());
4137 a.interest = Some(interest.to_string());
4138 a
4139 }
4140
4141 #[test]
4142 fn margin_balance_entry_maps_debt() {
4143 let ab =
4145 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4146 .expect("convert");
4147 assert_eq!(ab.asset.name().as_str(), "USDT");
4148 assert_eq!(ab.balance.total, Decimal::from(12));
4149 assert_eq!(ab.balance.free, Decimal::from(10));
4150 assert!(
4151 ab.balance.margin.is_some(),
4152 "REST snapshot must populate margin"
4153 );
4154 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4156 }
4157
4158 #[test]
4159 fn margin_balance_short_is_negative_net() {
4160 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4162 .expect("convert");
4163 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4164 }
4165
4166 #[test]
4167 fn margin_balance_missing_debt_defaults_to_zero() {
4168 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4171 a.asset = Some("ETH".to_string());
4172 a.free = Some("4".to_string());
4173 a.locked = Some("0".to_string());
4174 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4175 assert!(ab.balance.margin.is_some());
4176 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4177 }
4178
4179 #[test]
4180 fn margin_balance_missing_free_is_dropped() {
4181 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4183 a.asset = Some("USDT".to_string());
4184 a.locked = Some("0".to_string());
4185 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4186 }
4187
4188 #[test]
4189 fn margin_balance_filtering() {
4190 let assets = vec![
4191 user_asset("USDT", "10", "0", "0", "0"),
4192 user_asset("BTC", "1", "0", "0", "0"),
4193 user_asset("ETH", "5", "0", "0", "0"),
4194 ];
4195 assert_eq!(
4197 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4198 3
4199 );
4200 let filtered = filter_and_convert_margin_balances(
4202 assets,
4203 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4204 );
4205 assert_eq!(filtered.len(), 2);
4206 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4207 }
4208
4209 #[test]
4210 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4211 let assets = vec![
4213 user_asset("BTC", "1", "0", "0", "0"),
4214 user_asset("ETH", "5", "0", "0", "0"),
4215 user_asset("DOGE", "9", "0", "0", "0"), ];
4217 let mut requested: Vec<AssetNameExchange> = (0..17)
4219 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4220 .collect();
4221 requested.push(AssetNameExchange::new("BTC"));
4222 requested.push(AssetNameExchange::new("ETH"));
4223 assert!(
4224 requested.len() > 16,
4225 "must exceed the linear-scan threshold"
4226 );
4227
4228 let filtered = filter_and_convert_margin_balances(assets, &requested);
4229 assert_eq!(filtered.len(), 2);
4230 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4231 }
4232
4233 use binance_sdk::margin_trading::rest_api::{
4238 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4239 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4240 };
4241
4242 fn d(s: &str) -> Decimal {
4243 Decimal::from_str(s).unwrap()
4244 }
4245
4246 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4247 let mut b = IsoBase::new();
4248 b.asset = Some(asset.to_string());
4249 b.free = Some(free.to_string());
4250 b.locked = Some(locked.to_string());
4251 b.borrowed = Some(borrowed.to_string());
4252 b.interest = Some(interest.to_string());
4253 b
4254 }
4255
4256 fn iso_quote(
4257 asset: &str,
4258 free: &str,
4259 locked: &str,
4260 borrowed: &str,
4261 interest: &str,
4262 ) -> IsoQuote {
4263 let mut q = IsoQuote::new();
4264 q.asset = Some(asset.to_string());
4265 q.free = Some(free.to_string());
4266 q.locked = Some(locked.to_string());
4267 q.borrowed = Some(borrowed.to_string());
4268 q.interest = Some(interest.to_string());
4269 q
4270 }
4271
4272 fn iso_entry(
4273 symbol: &str,
4274 base: IsoBase,
4275 quote: IsoQuote,
4276 margin_level: Option<&str>,
4277 margin_ratio: Option<&str>,
4278 liquidate_price: Option<&str>,
4279 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4280 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4281 e.symbol = Some(symbol.to_string());
4282 e.base_asset = Some(Box::new(base));
4283 e.quote_asset = Some(Box::new(quote));
4284 e.margin_level = margin_level.map(str::to_string);
4285 e.margin_ratio = margin_ratio.map(str::to_string);
4286 e.liquidate_price = liquidate_price.map(str::to_string);
4287 e
4288 }
4289
4290 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4291 let config = BinanceMarginConfig::isolated(
4292 "k".to_string(),
4293 "s".to_string(),
4294 symbols
4295 .iter()
4296 .map(|s| InstrumentNameExchange::new(*s))
4297 .collect(),
4298 );
4299 BinanceMargin::new(config)
4300 }
4301
4302 #[test]
4303 fn isolated_assets_map_base_quote_and_risk() {
4304 let entry = iso_entry(
4307 "BTCUSDT",
4308 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4309 iso_quote("USDT", "1000", "50", "300", "1.5"),
4310 Some("3.5"),
4311 Some("0.12"),
4312 Some("48000"),
4313 );
4314 let map = convert_isolated_margin_assets(vec![entry]);
4315 let state = map
4316 .get(&InstrumentNameExchange::new("BTCUSDT"))
4317 .expect("entry present");
4318
4319 assert_eq!(state.base.asset.name().as_str(), "BTC");
4320 assert_eq!(state.base.balance.total, d("0.6"));
4321 assert_eq!(state.base.balance.free, d("0.5"));
4322 assert_eq!(state.base.balance.net_asset(), d("0.4"));
4323
4324 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4325 assert_eq!(state.quote.balance.total, d("1050"));
4326 assert_eq!(state.quote.balance.net_asset(), d("750"));
4327
4328 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4329 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4330 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4331 }
4332
4333 #[test]
4334 fn isolated_base_short_is_negative_net() {
4335 let entry = iso_entry(
4337 "BTCUSDT",
4338 iso_base("BTC", "0", "0", "1.5", "0.001"),
4339 iso_quote("USDT", "100", "0", "0", "0"),
4340 None,
4341 None,
4342 None,
4343 );
4344 let map = convert_isolated_margin_assets(vec![entry]);
4345 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4346 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4347 }
4348
4349 #[test]
4350 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4351 let mut base = IsoBase::new();
4354 base.asset = Some("BTC".to_string());
4355 base.free = Some("0.5".to_string());
4356 base.locked = Some("0".to_string());
4357 let mut quote = IsoQuote::new();
4358 quote.asset = Some("USDT".to_string());
4359 quote.free = Some("100".to_string());
4360 quote.locked = Some("0".to_string());
4361
4362 let map = convert_isolated_margin_assets(vec![iso_entry(
4363 "BTCUSDT", base, quote, None, None, None,
4364 )]);
4365 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4366 assert!(state.base.balance.margin.is_some());
4367 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4368 assert_eq!(state.risk.margin_level, None);
4369 assert_eq!(state.risk.liquidation_price, None);
4370 }
4371
4372 #[test]
4373 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4374 let entry = iso_entry(
4375 "BTCUSDT",
4376 iso_base("BTC", "1", "0", "0", "0"),
4377 iso_quote("USDT", "1", "0", "0", "0"),
4378 Some("not_a_number"),
4379 None,
4380 None,
4381 );
4382 let map = convert_isolated_margin_assets(vec![entry]);
4383 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4384 assert_eq!(state.risk.margin_level, None);
4385 }
4386
4387 #[test]
4388 fn isolated_missing_base_free_drops_entry() {
4389 let mut base = IsoBase::new();
4391 base.asset = Some("BTC".to_string());
4392 base.locked = Some("0".to_string()); let entry = iso_entry(
4394 "BTCUSDT",
4395 base,
4396 iso_quote("USDT", "100", "0", "0", "0"),
4397 None,
4398 None,
4399 None,
4400 );
4401 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4402 }
4403
4404 #[test]
4405 fn isolated_missing_symbol_drops_entry() {
4406 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4407 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4408 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4409 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4411 }
4412
4413 #[test]
4414 fn chunk_symbols_batches_by_five() {
4415 let syms: Vec<InstrumentNameExchange> = (0..12)
4416 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4417 .collect();
4418 let chunks = chunk_symbols(&syms);
4419 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4420 assert_eq!(chunks[0].split(',').count(), 5);
4421 assert_eq!(chunks[1].split(',').count(), 5);
4422 assert_eq!(chunks[2].split(',').count(), 2);
4423
4424 let three: Vec<_> = (0..3)
4426 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4427 .collect();
4428 assert_eq!(chunk_symbols(&three).len(), 1);
4429
4430 let five: Vec<_> = (0..5)
4432 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4433 .collect();
4434 let c = chunk_symbols(&five);
4435 assert_eq!(c.len(), 1);
4436 assert_eq!(c[0].split(',').count(), 5);
4437
4438 assert!(chunk_symbols(&[]).is_empty());
4440 }
4441
4442 #[test]
4443 fn effective_isolated_set_empty_returns_all_configured() {
4444 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4445 assert_eq!(
4446 client.effective_isolated_set(&[]),
4447 vec![
4448 InstrumentNameExchange::new("BTCUSDT"),
4449 InstrumentNameExchange::new("ETHUSDT"),
4450 ]
4451 );
4452 }
4453
4454 #[test]
4455 fn effective_isolated_set_intersects_requested() {
4456 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4457 assert_eq!(
4458 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4459 vec![InstrumentNameExchange::new("ETHUSDT")]
4460 );
4461 }
4462
4463 #[test]
4464 fn effective_isolated_set_skips_out_of_set() {
4465 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4466 assert_eq!(
4468 client.effective_isolated_set(&[
4469 InstrumentNameExchange::new("BTCUSDT"),
4470 InstrumentNameExchange::new("DOGEUSDT"),
4471 ]),
4472 vec![InstrumentNameExchange::new("BTCUSDT")]
4473 );
4474 }
4475
4476 #[tokio::test]
4477 async fn fetch_balances_isolated_returns_empty() {
4478 let client = isolated_client(&["BTCUSDT"]);
4481 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4482 }
4483
4484 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4485 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4486 o.order_id = order_id;
4487 o.client_order_id = Some("cid-9".to_string());
4488 o.side = Some(side.to_string());
4489 o.price = Some("50000".to_string());
4490 o.orig_qty = Some("2".to_string());
4491 o.executed_qty = Some("0.5".to_string());
4492 o.r#type = Some("LIMIT".to_string());
4493 o.time_in_force = Some("GTC".to_string());
4494 o.time = Some(1_700_000_000_000);
4495 o
4496 }
4497
4498 #[test]
4499 fn margin_open_order_converts() {
4500 let inst = InstrumentNameExchange::new("BTCUSDT");
4501 let order =
4502 convert_margin_open_order(&open_order(Some(42), "BUY"), &inst).expect("convert");
4503 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4504 assert_eq!(order.state.id.0.as_str(), "42");
4505 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4506 assert_eq!(order.side, Side::Buy);
4507 assert_eq!(order.price, Some(Decimal::from(50_000)));
4508 assert_eq!(order.quantity, Decimal::from(2));
4509 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4510 assert_eq!(order.kind, OrderKind::Limit);
4511 }
4512
4513 #[test]
4514 fn margin_open_order_missing_order_id_is_dropped() {
4515 let inst = InstrumentNameExchange::new("BTCUSDT");
4516 assert!(convert_margin_open_order(&open_order(None, "BUY"), &inst).is_none());
4517 }
4518
4519 #[test]
4520 fn margin_open_order_owned_symbol_recovers_instrument() {
4521 let mut o = open_order(Some(42), "SELL");
4523 o.symbol = Some("ETHUSDT".to_string());
4524 let order = convert_margin_open_order_owned_symbol(&o).expect("convert");
4525 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4526 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4527 assert_eq!(order.side, Side::Sell);
4528 }
4529
4530 #[test]
4531 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4532 let o = open_order(Some(42), "BUY");
4534 assert!(o.symbol.is_none());
4535 assert!(convert_margin_open_order_owned_symbol(&o).is_none());
4536 }
4537
4538 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4539 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4540 t.id = id;
4541 t.order_id = Some(7);
4542 t.is_buyer = is_buyer;
4543 t.price = Some("48000".to_string());
4544 t.qty = Some("0.25".to_string());
4545 t.commission = Some("0.001".to_string());
4546 t.commission_asset = Some("BNB".to_string());
4547 t.time = Some(1_700_000_000_000);
4548 t
4549 }
4550
4551 #[test]
4552 fn margin_trade_converts() {
4553 let inst = InstrumentNameExchange::new("BTCUSDT");
4554 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4555 assert_eq!(tr.id.0.as_str(), "11");
4556 assert_eq!(tr.order_id.0.as_str(), "7");
4557 assert_eq!(tr.side, Side::Sell);
4558 assert_eq!(tr.price, Decimal::from(48_000));
4559 assert_eq!(tr.quantity, Decimal::new(25, 2));
4560 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4562 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4563 }
4564
4565 #[test]
4566 fn margin_trade_missing_fields_are_dropped() {
4567 let inst = InstrumentNameExchange::new("BTCUSDT");
4568 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4570 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4571 }
4572
4573 #[test]
4576 fn token_renew_after_applies_margin_and_floor() {
4577 let now_ms = Utc::now().timestamp_millis();
4578 let far = token_renew_after(now_ms + 86_400_000).as_secs();
4580 assert!(
4581 far > 80_000 && far <= 86_400,
4582 "expected ~24h-minus-margin, got {far}"
4583 );
4584 assert_eq!(
4586 token_renew_after(now_ms - 10_000).as_secs(),
4587 TOKEN_MIN_LIFETIME_SECS
4588 );
4589 assert_eq!(
4591 token_renew_after(now_ms + 60_000).as_secs(),
4592 TOKEN_MIN_LIFETIME_SECS
4593 );
4594 }
4595
4596 fn push(event: serde_json::Value) -> String {
4601 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
4602 }
4603
4604 #[test]
4605 fn margin_ws_rpc_ack_yields_no_events() {
4606 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
4608 let mut buf = Vec::new();
4609 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4610 assert!(buf.is_empty());
4611 }
4612
4613 #[test]
4614 fn margin_ws_execution_report_trade_maps_to_trade() {
4615 let frame = push(serde_json::json!({
4616 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4617 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
4618 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4619 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
4620 }));
4621 let mut buf = Vec::new();
4622 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4623 assert_eq!(buf.len(), 1);
4624 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
4625 match &buf[0].kind {
4626 AccountEventKind::Trade(t) => {
4627 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
4628 assert_eq!(t.side, Side::Buy);
4629 assert_eq!(t.price, Decimal::from(48_000));
4630 assert_eq!(t.quantity, Decimal::new(5, 1));
4631 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
4633 }
4634 other => panic!("expected Trade, got {other:?}"),
4635 }
4636 }
4637
4638 #[test]
4639 fn margin_ws_new_report_maps_to_active_order_snapshot() {
4640 let frame = push(serde_json::json!({
4641 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
4642 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
4643 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
4644 }));
4645 let mut buf = Vec::new();
4646 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4647 assert_eq!(buf.len(), 1);
4648 match &buf[0].kind {
4649 AccountEventKind::OrderSnapshot(snap) => {
4650 assert_eq!(snap.0.side, Side::Sell);
4651 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
4652 assert_eq!(snap.0.quantity, Decimal::from(1));
4653 assert!(
4654 matches!(snap.0.state, OrderState::Active(_)),
4655 "NEW should be an active (resting) order"
4656 );
4657 }
4658 other => panic!("expected OrderSnapshot, got {other:?}"),
4659 }
4660 }
4661
4662 #[test]
4663 fn margin_ws_canceled_report_maps_to_order_cancelled() {
4664 let frame = push(serde_json::json!({
4665 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4666 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
4667 "z": "0", "T": 1_700_000_000_000_i64,
4668 }));
4669 let mut buf = Vec::new();
4670 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4671 assert_eq!(buf.len(), 1);
4672 match &buf[0].kind {
4673 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
4674 other => panic!("expected OrderCancelled, got {other:?}"),
4675 }
4676 }
4677
4678 #[test]
4679 fn margin_ws_execution_report_missing_symbol_is_dropped() {
4680 let frame = push(serde_json::json!({
4682 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
4683 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
4684 }));
4685 let mut buf = Vec::new();
4686 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4687 assert!(buf.is_empty());
4688 }
4689
4690 #[test]
4691 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
4692 let frame = push(serde_json::json!({
4693 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4694 "B": [
4695 { "a": "USDT", "f": "100.0", "l": "5.0" },
4696 { "a": "BTC", "f": "0.5", "l": "0" },
4697 ],
4698 }));
4699 let mut buf = Vec::new();
4700 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4701 assert_eq!(buf.len(), 2);
4702 for ev in &buf {
4704 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
4705 }
4706 }
4707
4708 #[test]
4709 fn margin_ws_stream_terminated_signals_reconnect() {
4710 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
4711 let mut buf = Vec::new();
4712 assert!(
4713 convert_margin_user_data_events(&frame, &mut buf),
4714 "eventStreamTerminated must signal reconnect"
4715 );
4716 assert!(buf.is_empty());
4717 }
4718
4719 #[test]
4720 fn margin_ws_margin_specific_events_are_observable_only() {
4721 let mut buf = Vec::new();
4724 let liability = push(serde_json::json!({
4725 "e": "userLiabilityChange", "a": "USDT", "t": "BORROW", "p": "100", "i": "0.01",
4726 }));
4727 assert!(!convert_margin_user_data_events(&liability, &mut buf));
4728 let level = push(serde_json::json!({
4729 "e": "marginLevelStatusChange", "l": "1.5", "s": "MARGIN_LEVEL_2",
4730 }));
4731 assert!(!convert_margin_user_data_events(&level, &mut buf));
4732 assert!(buf.is_empty());
4733 }
4734
4735 #[test]
4738 fn margin_trade_event_dedup_by_key() {
4739 let frame = push(serde_json::json!({
4742 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4743 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
4744 "l": "1", "L": "100", "z": "1", "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
4745 }));
4746 let mut buf = Vec::new();
4747 convert_margin_user_data_events(&frame, &mut buf);
4748 let event = buf.pop().expect("a Trade event");
4749
4750 let cache = new_dedup_cache();
4751 let first = dedup_key_from_event(&event).expect("Trade events have a dedup key");
4753 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
4754 let second = dedup_key_from_event(&event).expect("Trade events have a dedup key");
4755 assert!(
4756 is_duplicate(&cache, second),
4757 "second sighting is a duplicate"
4758 );
4759 }
4760
4761 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
4766 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
4767 }
4768
4769 fn isolated_handler(
4772 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
4773 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
4774 ) -> impl FnMut(Outboundaccountposition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
4775 move |position, subscription_id, buf| {
4776 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
4777 }
4778 }
4779
4780 fn btcusdt() -> InstrumentNameExchange {
4781 InstrumentNameExchange::new("BTCUSDT")
4782 }
4783
4784 fn token(expiration_time_ms: i64) -> UserListenToken {
4785 UserListenToken {
4786 token: "t".to_string(),
4787 expiration_time_ms,
4788 }
4789 }
4790
4791 #[test]
4792 fn listen_token_query_cross_vs_isolated() {
4793 assert!(build_listen_token_query(None).is_empty());
4795
4796 let q = build_listen_token_query(Some(&btcusdt()));
4798 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
4799 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
4800 assert_eq!(q.len(), 2);
4801 }
4802
4803 #[test]
4804 fn earliest_token_expiry_picks_min() {
4805 let tokens = vec![
4806 (btcusdt(), token(3_000)),
4807 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
4808 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
4809 ];
4810 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
4811 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
4813 }
4814
4815 #[test]
4816 fn base_quote_map_extracts_base_and_quote() {
4817 let entries = vec![
4818 iso_entry(
4819 "BTCUSDT",
4820 iso_base("BTC", "0", "0", "0", "0"),
4821 iso_quote("USDT", "0", "0", "0", "0"),
4822 None,
4823 None,
4824 None,
4825 ),
4826 iso_entry(
4827 "ETHBTC",
4828 iso_base("ETH", "0", "0", "0", "0"),
4829 iso_quote("BTC", "0", "0", "0", "0"),
4830 None,
4831 None,
4832 None,
4833 ),
4834 ];
4835 let map = build_base_quote_map(&entries);
4836 assert_eq!(
4837 map.get(&btcusdt())
4838 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4839 Some(("BTC", "USDT"))
4840 );
4841 assert_eq!(
4844 map.get(&InstrumentNameExchange::new("ETHBTC"))
4845 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4846 Some(("ETH", "BTC"))
4847 );
4848 }
4849
4850 #[test]
4851 fn isolated_outbound_position_routes_to_instrument_balance_update() {
4852 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
4853 let base_quote = Arc::new(HashMap::from([(
4854 btcusdt(),
4855 (
4856 AssetNameExchange::new("BTC"),
4857 AssetNameExchange::new("USDT"),
4858 ),
4859 )]));
4860 let mut handler = isolated_handler(sub_map, base_quote);
4861
4862 let frame = push_with_sub(
4864 7,
4865 serde_json::json!({
4866 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4867 "B": [
4868 { "a": "USDT", "f": "1000.0", "l": "50.0" },
4869 { "a": "BTC", "f": "0.5", "l": "0.1" },
4870 ],
4871 }),
4872 );
4873 let mut buf = Vec::new();
4874 assert!(!convert_margin_user_data_events_with(
4875 &frame,
4876 &mut buf,
4877 &mut handler
4878 ));
4879 assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
4880 match &buf[0].kind {
4881 AccountEventKind::InstrumentBalanceUpdate(ibu) => {
4882 assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
4883 assert_eq!(ibu.base.asset.name().as_str(), "BTC");
4884 assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
4885 assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
4886 assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
4887 assert_eq!(ibu.quote.update.free, Decimal::from(1000));
4888 assert_eq!(ibu.quote.update.locked, Decimal::from(50));
4889 }
4890 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
4891 }
4892 assert!(!matches!(
4894 buf[0].kind,
4895 AccountEventKind::BalanceStreamUpdate(_)
4896 ));
4897 }
4898
4899 #[test]
4900 fn isolated_outbound_position_unknown_subscription_dropped() {
4901 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
4903 let base_quote = Arc::new(HashMap::from([(
4904 btcusdt(),
4905 (
4906 AssetNameExchange::new("BTC"),
4907 AssetNameExchange::new("USDT"),
4908 ),
4909 )]));
4910 let mut handler = isolated_handler(sub_map, base_quote);
4911
4912 let frame = push_with_sub(
4913 99,
4914 serde_json::json!({
4915 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4916 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
4917 }),
4918 );
4919 let mut buf = Vec::new();
4920 assert!(!convert_margin_user_data_events_with(
4921 &frame,
4922 &mut buf,
4923 &mut handler
4924 ));
4925 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
4926 }
4927
4928 #[test]
4929 fn isolated_outbound_position_missing_side_dropped() {
4930 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
4933 let base_quote = Arc::new(HashMap::from([(
4934 btcusdt(),
4935 (
4936 AssetNameExchange::new("BTC"),
4937 AssetNameExchange::new("USDT"),
4938 ),
4939 )]));
4940 let mut handler = isolated_handler(sub_map, base_quote);
4941
4942 let frame = push_with_sub(
4943 7,
4944 serde_json::json!({
4945 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4946 "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
4947 }),
4948 );
4949 let mut buf = Vec::new();
4950 assert!(!convert_margin_user_data_events_with(
4951 &frame,
4952 &mut buf,
4953 &mut handler
4954 ));
4955 assert!(buf.is_empty(), "missing quote side → frame dropped");
4956 }
4957
4958 #[test]
4959 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
4960 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
4964 let base_quote = Arc::new(HashMap::new());
4965 let mut handler = isolated_handler(sub_map, base_quote);
4966
4967 let frame = push_with_sub(
4968 42,
4969 serde_json::json!({
4970 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4971 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
4972 "l": "1", "L": "100", "z": "1", "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
4973 }),
4974 );
4975 let mut buf = Vec::new();
4976 assert!(!convert_margin_user_data_events_with(
4977 &frame,
4978 &mut buf,
4979 &mut handler
4980 ));
4981 assert_eq!(buf.len(), 1);
4982 match &buf[0].kind {
4983 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
4984 other => panic!("expected Trade, got {other:?}"),
4985 }
4986 }
4987}