1use super::shared::{
38 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
39 CONNECT_TIMEOUT_SECS, ExponentialBackoff, FILL_RECOVERY_TIMEOUT_SECS, HEARTBEAT_TIMEOUT_SECS,
40 RateLimitTracker, SIGNAL_RECOVERY_LOOKBACK_MS, SharedDedupCache, classify_order_kind_tif,
41 classify_rest_order_error, connectivity_error, dedup_key_from_event, is_duplicate,
42 new_dedup_cache, parse_order_kind, parse_side, parse_time_in_force, rest_call_with_retry,
43};
44use crate::{
45 AccountEventKind, AccountSnapshot, InstrumentAccountSnapshot, InstrumentBalanceUpdate,
46 IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent, UnindexedAccountSnapshot,
47 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
48 client::ExecutionClient,
49 emit_stream_terminated,
50 error::{
51 ApiError, ConnectivityError, OrderError, StreamTerminationReason, UnindexedClientError,
52 UnindexedOrderError,
53 },
54 order::{
55 Order, OrderKey, OrderKind, TimeInForce,
56 id::{ClientOrderId, OrderId, StrategyId},
57 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
58 state::{Cancelled, Filled, Open, OrderState, UnindexedOrderState},
59 },
60 trade::{AssetFees, Trade, TradeId},
61};
62use binance_sdk::{
63 common::{
64 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
65 constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
66 models::WebsocketEvent,
67 websocket::{
68 SendWebsocketMessageResult, Subscription, WebsocketApi as WsApiBase,
69 WebsocketMessageSendOptions,
70 },
71 },
72 margin_trading::{
73 MarginTradingRestApi,
74 rest_api::{
75 MarginAccountCancelOrderParams, MarginAccountNewOrderNewOrderRespTypeEnum,
76 MarginAccountNewOrderParams, MarginAccountNewOrderSideEnum,
77 MarginAccountNewOrderTimeInForceEnum, QueryCrossMarginAccountDetailsParams,
78 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
79 QueryIsolatedMarginAccountInfoParams,
80 QueryIsolatedMarginAccountInfoResponseAssetsInner, QueryMarginAccountsOpenOrdersParams,
81 QueryMarginAccountsOpenOrdersResponseInner, QueryMarginAccountsTradeListParams,
82 QueryMarginAccountsTradeListResponseInner, RestApi,
83 },
84 websocket_streams::{
85 Executionreport, MarginLevelStatusChange, Outboundaccountposition, UserLiabilityChange,
86 },
87 },
88};
89use chrono::{DateTime, TimeZone, Utc};
90use futures::stream::BoxStream;
91use rust_decimal::Decimal;
92use rustrade_instrument::{
93 Side, asset::name::AssetNameExchange, exchange::ExchangeId,
94 instrument::name::InstrumentNameExchange,
95};
96use serde::{Deserialize, Serialize};
97use smol_str::{SmolStr, format_smolstr};
98use std::{
99 collections::{BTreeMap, HashMap},
100 str::FromStr,
101 sync::{
102 Arc, Mutex,
103 atomic::{AtomicBool, Ordering},
104 },
105 time::Duration,
106};
107use tokio::sync::{mpsc, oneshot};
108use tracing::{debug, error, info, trace, warn};
109
110#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum MarginSideEffect {
130 #[default]
138 AutoBorrowRepay,
139 NoBorrow,
143}
144
145impl MarginSideEffect {
146 pub fn as_binance_str(self) -> &'static str {
148 match self {
149 MarginSideEffect::AutoBorrowRepay => "AUTO_BORROW_REPAY",
150 MarginSideEffect::NoBorrow => "NO_SIDE_EFFECT",
151 }
152 }
153}
154
155#[derive(Clone, Deserialize)]
165pub struct BinanceMarginConfig {
166 api_key: String,
169 secret_key: String,
170 pub testnet: bool,
176 #[serde(default)]
183 pub is_isolated: bool,
184 #[serde(default)]
195 pub isolated_symbols: Vec<InstrumentNameExchange>,
196 #[serde(default)]
200 pub side_effect: MarginSideEffect,
201}
202
203impl std::fmt::Debug for BinanceMarginConfig {
205 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
206 f.debug_struct("BinanceMarginConfig")
207 .field("api_key", &"***")
208 .field("secret_key", &"***")
209 .field("testnet", &self.testnet)
210 .field("is_isolated", &self.is_isolated)
211 .field("isolated_symbols", &self.isolated_symbols)
212 .field("side_effect", &self.side_effect)
213 .finish()
214 }
215}
216
217impl BinanceMarginConfig {
218 pub fn new(
228 api_key: String,
229 secret_key: String,
230 testnet: bool,
231 is_isolated: bool,
232 isolated_symbols: Vec<InstrumentNameExchange>,
233 side_effect: MarginSideEffect,
234 ) -> Self {
235 Self {
236 api_key,
237 secret_key,
238 testnet,
239 is_isolated,
240 isolated_symbols,
241 side_effect,
242 }
243 }
244
245 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
252 Self::new(
253 api_key,
254 secret_key,
255 false,
256 false,
257 Vec::new(),
258 MarginSideEffect::default(),
259 )
260 }
261
262 pub fn isolated(
270 api_key: String,
271 secret_key: String,
272 symbols: Vec<InstrumentNameExchange>,
273 ) -> Self {
274 Self::new(
275 api_key,
276 secret_key,
277 false,
278 true,
279 symbols,
280 MarginSideEffect::default(),
281 )
282 }
283
284 pub fn api_key(&self) -> &str {
286 &self.api_key
287 }
288}
289
290#[derive(Clone)]
349pub struct BinanceMargin {
350 config: Arc<BinanceMarginConfig>,
351 rest: Arc<RestApi>,
353 rest_config: Arc<ConfigurationRestApi>,
357 ws_config: ConfigurationWebsocketApi,
361 rate_limiter: Arc<RateLimitTracker>,
363}
364
365impl std::fmt::Debug for BinanceMargin {
366 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
367 f.debug_struct("BinanceMargin")
368 .field("testnet", &self.config.testnet)
369 .field("is_isolated", &self.config.is_isolated)
370 .field("side_effect", &self.config.side_effect)
371 .finish_non_exhaustive()
372 }
373}
374
375impl BinanceMargin {
376 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
386 ConfigurationRestApi::builder()
387 .api_key(config.api_key.clone())
388 .api_secret(config.secret_key.clone())
389 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
390 .build()
391 .expect("failed to build Binance margin REST configuration")
392 }
393
394 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
404 ConfigurationWebsocketApi::builder()
405 .api_key(config.api_key.clone())
406 .api_secret(config.secret_key.clone())
407 .ws_url(SPOT_WS_API_PROD_URL)
411 .build()
412 .expect("failed to build Binance margin WebSocket configuration")
413 }
414
415 fn effective_isolated_set(
428 &self,
429 instruments: &[InstrumentNameExchange],
430 ) -> Vec<InstrumentNameExchange> {
431 if instruments.is_empty() {
432 return self.config.isolated_symbols.clone();
433 }
434 instruments
435 .iter()
436 .filter(|inst| {
437 let in_set = self.config.isolated_symbols.contains(*inst);
438 if !in_set {
439 warn!(
440 instrument = %inst.name(),
441 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
442 );
443 }
444 in_set
445 })
446 .cloned()
447 .collect()
448 }
449
450 async fn isolated_account_snapshot(
454 &self,
455 instruments: &[InstrumentNameExchange],
456 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
457 use futures::{StreamExt as _, TryStreamExt as _};
458
459 let effective = self.effective_isolated_set(instruments);
460 if effective.is_empty() {
461 return Ok(AccountSnapshot::new(
464 ExchangeId::BinanceMargin,
465 Vec::new(),
466 Vec::new(),
467 ));
468 }
469
470 let mut balances_by_symbol = convert_isolated_margin_assets(
472 fetch_isolated_margin_account_info(
473 self.rest.clone(),
474 self.rate_limiter.clone(),
475 effective.clone(),
476 )
477 .await?,
478 );
479
480 let instrument_snapshots: Vec<_> =
483 futures::stream::iter(effective.into_iter().map(|instrument| {
484 fetch_margin_open_orders_for_instrument(
485 self.rest.clone(),
486 self.rate_limiter.clone(),
487 instrument,
488 true,
489 )
490 }))
491 .buffer_unordered(8)
492 .map(|result| {
493 let (inst, orders) = result?;
494 let wrapped = orders.into_iter().map(active_order_snapshot).collect();
495 let isolated = balances_by_symbol.remove(&inst);
496 if isolated.is_none() {
497 warn!(
501 instrument = %inst.name(),
502 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
503 );
504 }
505 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
506 inst, wrapped, None, isolated,
507 ))
508 })
509 .try_collect()
510 .await?;
511
512 Ok(AccountSnapshot::new(
515 ExchangeId::BinanceMargin,
516 Vec::new(),
517 instrument_snapshots,
518 ))
519 }
520}
521
522impl ExecutionClient for BinanceMargin {
523 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
524 type Config = BinanceMarginConfig;
525 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
526
527 fn new(config: Self::Config) -> Self {
540 if config.testnet {
541 warn!(
542 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
543 using production endpoints"
544 );
545 }
546 assert!(
547 !(config.is_isolated && config.isolated_symbols.is_empty()),
548 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
549 );
550 let rest_config = Self::build_rest_config(&config);
555 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
556 let rest_config = Arc::new(rest_config);
557 let ws_config = Self::build_ws_config(&config);
558 Self {
559 config: Arc::new(config),
560 rest,
561 rest_config,
562 ws_config,
563 rate_limiter: Arc::new(RateLimitTracker::new()),
564 }
565 }
566
567 async fn open_order(
581 &self,
582 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
583 ) -> Option<Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState>> {
584 let instrument = request.key.instrument.clone();
585 let side = request.state.side;
586 let price = request.state.price;
587 let quantity = request.state.quantity;
588 let kind = request.state.kind;
589 let time_in_force = request.state.time_in_force;
590 let cid = request.key.cid.clone();
591
592 let order_key = OrderKey::new(
593 ExchangeId::BinanceMargin,
594 instrument.clone(),
595 request.key.strategy.clone(),
596 cid.clone(),
597 );
598
599 let inactive = |state: UnindexedOrderState| {
602 Some(Order {
603 key: order_key.clone(),
604 side,
605 price,
606 quantity,
607 kind,
608 time_in_force,
609 state,
610 })
611 };
612
613 let params = match build_new_order_params(
614 instrument.name().to_string(),
615 side,
616 price,
617 quantity,
618 kind,
619 time_in_force,
620 cid.0.to_string(),
621 self.config.side_effect,
622 self.config.is_isolated,
623 ) {
624 Ok(params) => params,
625 Err(BuildOrderError::Unsupported) => {
626 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
627 format!(
628 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
629 ),
630 )));
631 }
632 Err(BuildOrderError::Build(msg)) => {
633 error!(%msg, "BinanceMargin failed to build new order params");
634 return inactive(OrderState::inactive(OrderError::Rejected(
635 ApiError::OrderRejected(msg),
636 )));
637 }
638 };
639
640 let response = match rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
641 let params = params.clone();
642 Box::pin(async move { rest.margin_account_new_order(params).await })
643 })
644 .await
645 {
646 Ok(response) => response,
647 Err(e) => {
648 return inactive(OrderState::inactive(classify_rest_order_error(
649 &e,
650 &instrument,
651 )));
652 }
653 };
654
655 let data = match response.data().await {
656 Ok(data) => data,
657 Err(e) => {
658 return inactive(OrderState::inactive(OrderError::Rejected(
661 ApiError::OrderRejected(e.to_string()),
662 )));
663 }
664 };
665
666 let time_exchange = data
667 .transact_time
668 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
669 .unwrap_or_else(Utc::now);
670
671 let exchange_order_id = match data.order_id {
672 Some(id) => OrderId(format_smolstr!("{id}")),
673 None => {
674 error!("BinanceMargin open_order response missing orderId");
675 return inactive(OrderState::inactive(OrderError::Rejected(
676 ApiError::OrderRejected("open_order response missing orderId".into()),
677 )));
678 }
679 };
680
681 let filled_qty = match data.executed_qty.as_deref() {
682 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
683 warn!(
687 executed_qty = q,
688 "BinanceMargin: failed to parse executedQty; treating as zero"
689 );
690 Decimal::ZERO
691 }),
692 None => {
693 warn!("BinanceMargin: executedQty missing in response; treating as zero");
696 Decimal::ZERO
697 }
698 };
699
700 let state = if filled_qty >= quantity {
701 let avg_price = margin_avg_price(data.cummulative_quote_qty.as_deref(), filled_qty);
704 OrderState::fully_filled(Filled::new(
705 exchange_order_id,
706 time_exchange,
707 filled_qty,
708 avg_price,
709 ))
710 } else {
711 OrderState::active(Open::new(exchange_order_id, time_exchange, filled_qty))
712 };
713
714 Some(Order {
715 key: order_key,
716 side,
717 price,
718 quantity,
719 kind,
720 time_in_force,
721 state,
722 })
723 }
724
725 async fn cancel_order(
735 &self,
736 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
737 ) -> Option<UnindexedOrderResponseCancel> {
738 let instrument = request.key.instrument.clone();
739 let key = OrderKey {
740 exchange: request.key.exchange,
741 instrument: instrument.clone(),
742 strategy: request.key.strategy.clone(),
743 cid: request.key.cid.clone(),
744 };
745
746 let params = match build_cancel_order_params(
747 instrument.name().to_string(),
748 request.state.id.as_ref(),
749 &request.key.cid,
750 self.config.is_isolated,
751 ) {
752 Ok(p) => p,
753 Err(e) => {
754 error!(%e, "BinanceMargin failed to build cancel order params");
755 return Some(UnindexedOrderResponseCancel {
756 key,
757 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
758 });
759 }
760 };
761
762 let response = match rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
763 let params = params.clone();
764 Box::pin(async move { rest.margin_account_cancel_order(params).await })
765 })
766 .await
767 {
768 Ok(response) => response,
769 Err(e) => {
770 return Some(UnindexedOrderResponseCancel {
771 key,
772 state: Err(classify_rest_order_error(&e, &instrument)),
773 });
774 }
775 };
776
777 let data = match response.data().await {
778 Ok(data) => data,
779 Err(e) => {
780 return Some(UnindexedOrderResponseCancel {
783 key,
784 state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
785 });
786 }
787 };
788
789 let time_exchange = Utc::now();
791
792 let exchange_order_id = match data.order_id {
793 Some(id) => OrderId(SmolStr::new(id)),
796 None => {
797 error!("BinanceMargin cancel response missing orderId");
798 return Some(UnindexedOrderResponseCancel {
799 key,
800 state: Err(OrderError::Rejected(ApiError::OrderRejected(
801 "cancel response missing orderId".into(),
802 ))),
803 });
804 }
805 };
806
807 let filled_qty = match data.executed_qty.as_deref() {
808 Some(q) => Decimal::from_str(q).unwrap_or_else(|_| {
809 warn!(
813 executed_qty = q,
814 "BinanceMargin: failed to parse executedQty; treating as zero"
815 );
816 Decimal::ZERO
817 }),
818 None => {
819 warn!("BinanceMargin: executedQty missing in response; treating as zero");
822 Decimal::ZERO
823 }
824 };
825
826 Some(UnindexedOrderResponseCancel {
827 key,
828 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
829 })
830 }
831
832 async fn account_snapshot(
847 &self,
848 assets: &[AssetNameExchange],
849 instruments: &[InstrumentNameExchange],
850 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
851 if self.config.is_isolated {
852 return self.isolated_account_snapshot(instruments).await;
853 }
854 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
855 Box::pin(async move {
856 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
857 rest.query_cross_margin_account_details(params).await
858 })
859 })
860 .await
861 .map_err(connectivity_error)?;
862
863 let account = response
864 .data()
865 .await
866 .map_err(|e| connectivity_error(e.into()))?;
867
868 let balances =
869 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
870
871 use futures::{StreamExt as _, TryStreamExt as _};
876 let instrument_snapshots: Vec<_> =
877 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
878 fetch_margin_open_orders_for_instrument(
879 self.rest.clone(),
880 self.rate_limiter.clone(),
881 instrument,
882 false,
884 )
885 }))
886 .buffer_unordered(8)
887 .map(|result| {
888 let (inst, orders) = result?;
889 let wrapped = orders.into_iter().map(active_order_snapshot).collect();
890 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
891 inst, wrapped, None, None,
892 ))
893 })
894 .try_collect()
895 .await?;
896
897 Ok(AccountSnapshot::new(
898 ExchangeId::BinanceMargin,
899 balances,
900 instrument_snapshots,
901 ))
902 }
903
904 async fn fetch_balances(
914 &self,
915 assets: &[AssetNameExchange],
916 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
917 if self.config.is_isolated {
918 return Ok(Vec::new());
919 }
920 let response = rest_call_with_retry(&self.rest, &self.rate_limiter, |rest| {
921 Box::pin(async move {
922 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
923 rest.query_cross_margin_account_details(params).await
924 })
925 })
926 .await
927 .map_err(connectivity_error)?;
928
929 let account = response
930 .data()
931 .await
932 .map_err(|e| connectivity_error(e.into()))?;
933
934 Ok(filter_and_convert_margin_balances(
935 account.user_assets.unwrap_or_default(),
936 assets,
937 ))
938 }
939
940 async fn fetch_open_orders(
951 &self,
952 instruments: &[InstrumentNameExchange],
953 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
954 use futures::{StreamExt as _, TryStreamExt as _};
955
956 if self.config.is_isolated {
960 let effective = self.effective_isolated_set(instruments);
961 let capacity = effective.len();
962 return futures::stream::iter(effective.into_iter().map(|instrument| {
963 fetch_margin_open_orders_for_instrument(
964 self.rest.clone(),
965 self.rate_limiter.clone(),
966 instrument,
967 true,
968 )
969 }))
970 .buffer_unordered(8)
971 .try_fold(
972 Vec::with_capacity(capacity),
973 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
974 (_, orders)| async move {
975 acc.extend(orders);
976 Ok(acc)
977 },
978 )
979 .await;
980 }
981
982 if instruments.is_empty() {
985 return fetch_margin_all_open_orders(
986 self.rest.clone(),
987 self.rate_limiter.clone(),
988 false,
989 )
990 .await;
991 }
992 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
993 fetch_margin_open_orders_for_instrument(
994 self.rest.clone(),
995 self.rate_limiter.clone(),
996 instrument,
997 false,
998 )
999 }))
1000 .buffer_unordered(8)
1001 .try_fold(
1002 Vec::with_capacity(instruments.len()),
1003 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, orders)| async move {
1004 acc.extend(orders);
1005 Ok(acc)
1006 },
1007 )
1008 .await
1009 }
1010
1011 async fn fetch_trades(
1024 &self,
1025 time_since: DateTime<Utc>,
1026 instruments: &[InstrumentNameExchange],
1027 ) -> Result<Vec<Trade<AssetNameExchange, InstrumentNameExchange>>, UnindexedClientError> {
1028 use futures::StreamExt as _;
1029
1030 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1033 self.effective_isolated_set(instruments)
1034 } else {
1035 instruments.to_vec()
1036 };
1037 if effective.is_empty() {
1038 debug!(
1041 is_isolated = self.config.is_isolated,
1042 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1043 );
1044 return Ok(Vec::new());
1045 }
1046 let start_time_ms = time_since.timestamp_millis();
1047 let is_isolated = self.config.is_isolated;
1048 let mut all_trades = Vec::new();
1049
1050 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1053 let rest = self.rest.clone();
1054 let rate_limiter = self.rate_limiter.clone();
1055 async move {
1056 let pages = paginate_margin_my_trades(
1057 &rest,
1058 &rate_limiter,
1059 &inst,
1060 start_time_ms,
1061 is_isolated,
1062 )
1063 .await?;
1064 Ok::<_, UnindexedClientError>((inst, pages))
1065 }
1066 }))
1067 .buffer_unordered(8);
1068 while let Some(result) = stream.next().await {
1069 let (instrument, trades_data) = result?;
1070 for t in trades_data {
1071 if let Some(trade) = convert_margin_trade(&t, &instrument) {
1072 all_trades.push(trade);
1073 }
1074 }
1075 }
1076
1077 Ok(all_trades)
1078 }
1079
1080 async fn account_stream(
1141 &self,
1142 _assets: &[AssetNameExchange],
1145 instruments: &[InstrumentNameExchange],
1146 ) -> Result<Self::AccountStream, UnindexedClientError> {
1147 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1148 let dedup = new_dedup_cache();
1149 let rest = self.rest.clone();
1150 let rest_config = self.rest_config.clone();
1151 let ws_config = self.ws_config.clone();
1152 let rate_limiter = self.rate_limiter.clone();
1153
1154 let cm_handle = if self.config.is_isolated {
1155 let symbols = self.config.isolated_symbols.clone();
1159 debug_assert!(
1162 symbols.iter().all(|i| i.name().len() <= 23),
1163 "instrument name exceeds SmolStr inline capacity: {:?}",
1164 symbols.iter().find(|i| i.name().len() > 23)
1165 );
1166
1167 let assets = fetch_isolated_margin_account_info(
1173 rest.clone(),
1174 rate_limiter.clone(),
1175 symbols.clone(),
1176 )
1177 .await?;
1178 let base_quote = Arc::new(build_base_quote_map(&assets));
1179 for sym in &symbols {
1180 if !base_quote.contains_key(sym) {
1181 warn!(
1182 symbol = %sym.name(),
1183 "BinanceMargin isolated: account info returned no base/quote for configured \
1184 symbol — its live per-pair balance frames will be dropped (fills/orders \
1185 unaffected; balances available via account_snapshot)"
1186 );
1187 }
1188 }
1189
1190 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1194 let live =
1195 isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
1196 .await
1197 .map_err(|e| {
1198 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1199 })?;
1200
1201 tokio::spawn(isolated_connection_manager(
1202 tx,
1203 dedup,
1204 ws_config,
1205 rest_config,
1206 rest,
1207 rate_limiter,
1208 symbols,
1209 base_quote,
1210 Some(live),
1211 ))
1212 } else {
1213 let instruments = instruments.to_vec();
1215 debug_assert!(
1219 instruments.iter().all(|i| i.name().len() <= 23),
1220 "instrument name exceeds SmolStr inline capacity: {:?}",
1221 instruments.iter().find(|i| i.name().len() > 23)
1222 );
1223
1224 let initial_token =
1230 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1231 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1232 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1233 })?;
1234
1235 tokio::spawn(margin_connection_manager(
1236 tx,
1237 dedup,
1238 ws_config,
1239 rest_config,
1240 rest,
1241 rate_limiter,
1242 instruments,
1243 Some((initial_ws, initial_token)),
1244 ))
1245 };
1246
1247 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1248 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1249 Ok(futures::StreamExt::boxed(guarded_stream))
1250 }
1251}
1252
1253const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
1261
1262struct UserListenToken {
1264 token: String,
1265 expiration_time_ms: i64,
1266}
1267
1268fn build_listen_token_query(
1272 symbol: Option<&InstrumentNameExchange>,
1273) -> BTreeMap<String, serde_json::Value> {
1274 let mut query = BTreeMap::new();
1275 if let Some(sym) = symbol {
1276 query.insert(
1277 "isIsolated".to_string(),
1278 serde_json::Value::String("TRUE".to_string()),
1279 );
1280 query.insert(
1281 "symbol".to_string(),
1282 serde_json::Value::String(sym.name().to_string()),
1283 );
1284 }
1285 query
1286}
1287
1288#[derive(Deserialize)]
1290struct UserListenTokenResponse {
1291 #[serde(alias = "listenToken")]
1293 token: String,
1294 #[serde(rename = "expirationTime")]
1295 expiration_time: i64,
1296}
1297
1298async fn acquire_user_listen_token(
1312 rest_config: &Arc<ConfigurationRestApi>,
1313 rate_limiter: &RateLimitTracker,
1314 symbol: Option<&InstrumentNameExchange>,
1315) -> Result<UserListenToken, UnindexedClientError> {
1316 let query = build_listen_token_query(symbol);
1319 let response = rest_call_with_retry(rest_config, rate_limiter, |cfg| {
1320 let query = query.clone();
1321 Box::pin(async move {
1322 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
1323 &cfg,
1324 "/sapi/v1/userListenToken",
1325 reqwest::Method::POST,
1326 query,
1329 BTreeMap::new(),
1330 None,
1331 true, )
1333 .await
1334 })
1335 })
1336 .await
1337 .map_err(connectivity_error)?;
1338
1339 let data = response
1340 .data()
1341 .await
1342 .map_err(|e| connectivity_error(e.into()))?;
1343 Ok(UserListenToken {
1344 token: data.token,
1345 expiration_time_ms: data.expiration_time,
1346 })
1347}
1348
1349fn token_renew_after(expiration_time_ms: i64) -> Duration {
1352 let now_ms = Utc::now().timestamp_millis();
1353 let remaining_secs = expiration_time_ms
1357 .saturating_sub(now_ms)
1358 .saturating_div(1_000)
1359 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
1360 let secs = u64::try_from(remaining_secs).unwrap_or(0);
1361 if secs < TOKEN_MIN_LIFETIME_SECS {
1364 warn!(
1365 computed_secs = secs,
1366 floor_secs = TOKEN_MIN_LIFETIME_SECS,
1367 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
1368 );
1369 }
1370 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
1371}
1372
1373async fn connect_margin_ws(
1380 ws_config: &ConfigurationWebsocketApi,
1381) -> anyhow::Result<Arc<WsApiBase>> {
1382 let ws = WsApiBase::new(ws_config.clone(), vec![]);
1383 tokio::time::timeout(
1386 Duration::from_secs(CONNECT_TIMEOUT_SECS),
1387 ws.clone().connect(),
1388 )
1389 .await
1390 .map_err(|_| {
1391 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
1392 })??;
1393 Ok(ws)
1394}
1395
1396async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
1403 let mut payload = BTreeMap::new();
1406 payload.insert(
1407 "listenToken".to_string(),
1408 serde_json::Value::String(token.to_string()),
1409 );
1410 ws.send_message::<serde_json::Value>(
1411 "userDataStream.subscribe.listenToken",
1412 payload,
1413 WebsocketMessageSendOptions::new(),
1414 )
1415 .await
1416 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1417 Ok(())
1418}
1419
1420async fn subscribe_listen_token_capture(
1433 ws: &Arc<WsApiBase>,
1434 token: &str,
1435) -> anyhow::Result<Option<i64>> {
1436 #[derive(Deserialize)]
1439 struct SubscribeAck {
1440 #[serde(rename = "subscriptionId", default)]
1441 subscription_id: Option<i64>,
1442 }
1443
1444 let mut payload = BTreeMap::new();
1445 payload.insert(
1446 "listenToken".to_string(),
1447 serde_json::Value::String(token.to_string()),
1448 );
1449 let result = ws
1450 .send_message::<SubscribeAck>(
1451 "userDataStream.subscribe.listenToken",
1452 payload,
1453 WebsocketMessageSendOptions::new(),
1454 )
1455 .await
1456 .map_err(|e| anyhow::anyhow!("userDataStream.subscribe.listenToken failed: {e}"))?;
1457 let ack = match result {
1458 SendWebsocketMessageResult::Single(resp) => resp.data()?,
1459 SendWebsocketMessageResult::Multiple(responses) => responses
1461 .into_iter()
1462 .next()
1463 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
1464 .data()?,
1465 };
1466 Ok(ack.subscription_id)
1467}
1468
1469fn cross_account_position_handler(
1473 position: Outboundaccountposition,
1474 _subscription_id: Option<i64>,
1475 buf: &mut Vec<UnindexedAccountEvent>,
1476) {
1477 convert_margin_account_position(position, buf);
1478}
1479
1480#[cfg(test)]
1485fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
1486 convert_margin_user_data_events_with(frame, buf, &mut cross_account_position_handler)
1487}
1488
1489fn convert_margin_user_data_events_with(
1503 frame: &str,
1504 buf: &mut Vec<UnindexedAccountEvent>,
1505 handle_position: &mut impl FnMut(
1506 Outboundaccountposition,
1507 Option<i64>,
1508 &mut Vec<UnindexedAccountEvent>,
1509 ),
1510) -> bool {
1511 use serde_json::value::RawValue;
1512
1513 #[derive(Deserialize)]
1519 struct Envelope<'a> {
1520 #[serde(borrow, default)]
1521 id: Option<&'a RawValue>,
1522 #[serde(borrow, default)]
1523 event: Option<&'a RawValue>,
1524 #[serde(rename = "subscriptionId", default)]
1525 subscription_id: Option<i64>,
1526 }
1527 #[derive(Deserialize)]
1529 struct EventTag<'a> {
1530 #[serde(borrow, default)]
1531 e: Option<&'a str>,
1532 }
1533
1534 let envelope = match serde_json::from_str::<Envelope<'_>>(frame) {
1535 Ok(env) => env,
1536 Err(e) => {
1537 trace!(error = %e, "BinanceMargin WS: skipped unparseable frame");
1538 return false;
1539 }
1540 };
1541 if envelope.id.is_some() {
1543 return false;
1544 }
1545 let Some(event) = envelope.event else {
1547 trace!("BinanceMargin WS: ignoring frame without `event` or `id`");
1548 return false;
1549 };
1550 let subscription_id = envelope.subscription_id;
1551 let event_raw = event.get();
1552 let event_type = serde_json::from_str::<EventTag<'_>>(event_raw)
1560 .ok()
1561 .and_then(|tag| tag.e)
1562 .unwrap_or_default();
1563 match event_type {
1564 "executionReport" => {
1565 match serde_json::from_str::<Executionreport>(event_raw) {
1568 Ok(report) => {
1569 if let Some(ev) = convert_margin_execution_report(report) {
1570 buf.push(ev);
1571 }
1572 }
1573 Err(e) => {
1574 warn!(error = %e, "BinanceMargin: undeserializable executionReport, dropping")
1575 }
1576 }
1577 false
1578 }
1579 "outboundAccountPosition" => {
1580 match serde_json::from_str::<Outboundaccountposition>(event_raw) {
1581 Ok(position) => handle_position(position, subscription_id, buf),
1583 Err(e) => {
1584 warn!(error = %e, "BinanceMargin: undeserializable outboundAccountPosition, dropping")
1585 }
1586 }
1587 false
1588 }
1589 "balanceUpdate" => {
1590 false
1594 }
1595 "userLiabilityChange" => {
1596 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
1603 Ok(c) => {
1604 if tracing::enabled!(tracing::Level::INFO) {
1605 info!(
1606 asset = c.a.as_deref().unwrap_or("?"),
1607 kind = c.t.as_deref().unwrap_or("?"),
1608 principal = c.p.as_deref().unwrap_or("?"),
1609 interest = c.i.as_deref().unwrap_or("?"),
1610 "BinanceMargin userLiabilityChange (observable; not applied to balance state)"
1611 );
1612 }
1613 }
1614 Err(e) => warn!(
1615 error = %e,
1616 "BinanceMargin: undeserializable userLiabilityChange, dropping"
1617 ),
1618 }
1619 false
1620 }
1621 "marginLevelStatusChange" => {
1622 if tracing::enabled!(tracing::Level::WARN) {
1627 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
1628 Ok(c) => warn!(
1629 margin_level = c.l.as_deref().unwrap_or("?"),
1630 status = c.s.as_deref().unwrap_or("?"),
1631 "BinanceMargin marginLevelStatusChange (liquidation risk; observable, no policy)"
1632 ),
1633 Err(e) => warn!(
1634 error = %e,
1635 "BinanceMargin: undeserializable marginLevelStatusChange, dropping"
1636 ),
1637 }
1638 }
1639 false
1640 }
1641 "eventStreamTerminated" => {
1642 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
1643 true
1644 }
1645 other => {
1646 trace!(
1647 event_type = other,
1648 "BinanceMargin ignoring unhandled user data event"
1649 );
1650 false
1651 }
1652 }
1653}
1654
1655#[allow(clippy::cognitive_complexity)] fn convert_margin_execution_report(report: Executionreport) -> Option<UnindexedAccountEvent> {
1663 let exec_type = match report.x.as_deref() {
1664 Some(t) => t,
1665 None => {
1666 warn!("BinanceMargin executionReport missing execution type (x), dropping");
1667 return None;
1668 }
1669 };
1670 let symbol = match report.s.as_deref() {
1671 Some(s) => InstrumentNameExchange::new(s),
1672 None => {
1673 warn!("BinanceMargin executionReport missing symbol (s), dropping");
1674 return None;
1675 }
1676 };
1677 let order_id = match report.i {
1678 Some(id) => OrderId(format_smolstr!("{id}")),
1679 None => {
1680 warn!(%symbol, "BinanceMargin executionReport missing orderId (i), dropping");
1681 return None;
1682 }
1683 };
1684 let cid = match report.c.as_deref() {
1685 Some(c) => ClientOrderId::new(c),
1686 None => ClientOrderId::new(order_id.0.as_str()),
1687 };
1688 let time_exchange = match report
1689 .t_uppercase
1690 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1691 {
1692 Some(t) => t,
1693 None => {
1694 warn!(%symbol, "BinanceMargin executionReport missing/unparseable transaction time (T), using now");
1695 Utc::now()
1696 }
1697 };
1698
1699 match exec_type {
1700 "NEW" => convert_margin_new_order(&report, symbol, cid, order_id, time_exchange),
1701 "TRADE" => {
1702 let trade_id = match report.t {
1703 Some(id) => TradeId(format_smolstr!("{id}")),
1704 None => {
1705 warn!(%symbol, "BinanceMargin TRADE event missing trade ID (t), dropping");
1706 return None;
1707 }
1708 };
1709 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
1710 Some(s) => s,
1711 None => {
1712 warn!(%symbol, "BinanceMargin TRADE event missing/unknown side (S), dropping");
1713 return None;
1714 }
1715 };
1716 let last_price = match report.l_uppercase.as_deref() {
1717 Some(s) => match Decimal::from_str(s) {
1718 Ok(v) => v,
1719 Err(e) => {
1720 warn!(%symbol, error = %e, raw = s, "BinanceMargin TRADE event unparseable last price (L), dropping fill");
1721 return None;
1722 }
1723 },
1724 None => {
1725 warn!(%symbol, "BinanceMargin TRADE event missing last price (L), dropping fill");
1726 return None;
1727 }
1728 };
1729 let last_qty = match report.l.as_deref() {
1730 Some(s) => match Decimal::from_str(s) {
1731 Ok(v) => v,
1732 Err(e) => {
1733 warn!(%symbol, error = %e, raw = s, "BinanceMargin TRADE event unparseable last qty (l), dropping fill");
1734 return None;
1735 }
1736 },
1737 None => {
1738 warn!(%symbol, "BinanceMargin TRADE event missing last qty (l), dropping fill");
1739 return None;
1740 }
1741 };
1742 let commission = match Decimal::from_str(report.n.as_deref().unwrap_or("0")) {
1743 Ok(v) => v,
1744 Err(e) => {
1745 warn!(%symbol, error = %e, "BinanceMargin TRADE event unparseable commission (n), defaulting to 0");
1746 Decimal::ZERO
1747 }
1748 };
1749 let fee_asset = report
1750 .n_uppercase
1751 .as_deref()
1752 .map(AssetNameExchange::from)
1753 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
1754
1755 let trade = Trade::new(
1756 trade_id,
1757 order_id,
1758 symbol,
1759 StrategyId::unknown(), time_exchange,
1761 side,
1762 last_price,
1763 last_qty,
1764 AssetFees::new(fee_asset, commission, None),
1765 );
1766 Some(UnindexedAccountEvent::new(
1767 ExchangeId::BinanceMargin,
1768 AccountEventKind::Trade(trade),
1769 ))
1770 }
1771 "CANCELED" | "EXPIRED" | "EXPIRED_IN_MATCH" => {
1772 let filled_qty = report
1773 .z
1774 .as_deref()
1775 .and_then(|s| Decimal::from_str(s).ok())
1776 .unwrap_or(Decimal::ZERO);
1777 let response = UnindexedOrderResponseCancel {
1778 key: OrderKey::new(
1779 ExchangeId::BinanceMargin,
1780 symbol,
1781 StrategyId::unknown(),
1782 cid,
1783 ),
1784 state: Ok(Cancelled::new(order_id, time_exchange, filled_qty)),
1785 };
1786 Some(UnindexedAccountEvent::new(
1787 ExchangeId::BinanceMargin,
1788 AccountEventKind::OrderCancelled(response),
1789 ))
1790 }
1791 "REJECTED" => {
1792 let reject_reason = report.r.unwrap_or_else(|| "unknown".to_string());
1793 warn!(%symbol, %order_id, reason = %reject_reason, "BinanceMargin order REJECTED by matching engine");
1794 let response = UnindexedOrderResponseCancel {
1795 key: OrderKey::new(
1796 ExchangeId::BinanceMargin,
1797 symbol,
1798 StrategyId::unknown(),
1799 cid,
1800 ),
1801 state: Err(UnindexedOrderError::Rejected(ApiError::OrderRejected(
1802 reject_reason,
1803 ))),
1804 };
1805 Some(UnindexedAccountEvent::new(
1806 ExchangeId::BinanceMargin,
1807 AccountEventKind::OrderCancelled(response),
1808 ))
1809 }
1810 "REPLACE" => {
1811 let filled_qty = report
1813 .z
1814 .as_deref()
1815 .and_then(|s| Decimal::from_str(s).ok())
1816 .unwrap_or(Decimal::ZERO);
1817 let response = UnindexedOrderResponseCancel {
1818 key: OrderKey::new(
1819 ExchangeId::BinanceMargin,
1820 symbol,
1821 StrategyId::unknown(),
1822 cid,
1823 ),
1824 state: Ok(Cancelled::new(order_id, time_exchange, filled_qty)),
1825 };
1826 Some(UnindexedAccountEvent::new(
1827 ExchangeId::BinanceMargin,
1828 AccountEventKind::OrderCancelled(response),
1829 ))
1830 }
1831 _ => {
1832 trace!(exec_type, "BinanceMargin ignoring execution type");
1834 None
1835 }
1836 }
1837}
1838
1839fn convert_margin_new_order(
1841 report: &Executionreport,
1842 symbol: InstrumentNameExchange,
1843 cid: ClientOrderId,
1844 order_id: OrderId,
1845 time_exchange: DateTime<Utc>,
1846) -> Option<UnindexedAccountEvent> {
1847 let side = match report.s_uppercase.as_deref().and_then(parse_side) {
1848 Some(s) => s,
1849 None => {
1850 warn!(%symbol, "BinanceMargin NEW event missing/unknown side (S), dropping");
1851 return None;
1852 }
1853 };
1854 let kind = parse_order_kind(report.o.as_deref().unwrap_or("LIMIT"))?;
1855 let price: Option<Decimal> = match (report.p.as_deref(), &kind) {
1856 (Some(p), _) => match Decimal::from_str(p) {
1857 Ok(v) if !v.is_zero() => Some(v),
1858 Ok(_) => {
1859 if matches!(
1860 kind,
1861 OrderKind::Limit
1862 | OrderKind::StopLimit { .. }
1863 | OrderKind::TakeProfitLimit { .. }
1864 | OrderKind::TrailingStopLimit { .. }
1865 ) {
1866 trace!(%symbol, %kind, "BinanceMargin NEW event has zero price (p) on limit-type order, treating as no limit price");
1867 }
1868 None
1869 }
1870 Err(e) => {
1871 warn!(%symbol, price = p, error = %e, "BinanceMargin NEW event unparseable price (p), dropping");
1872 return None;
1873 }
1874 },
1875 (
1876 None,
1877 OrderKind::Market
1878 | OrderKind::Stop { .. }
1879 | OrderKind::TakeProfit { .. }
1880 | OrderKind::TrailingStop { .. },
1881 ) => None,
1882 (
1883 None,
1884 OrderKind::Limit
1885 | OrderKind::StopLimit { .. }
1886 | OrderKind::TakeProfitLimit { .. }
1887 | OrderKind::TrailingStopLimit { .. },
1888 ) => {
1889 warn!(%symbol, "BinanceMargin NEW limit-type order missing price (p), dropping");
1890 return None;
1891 }
1892 };
1893 let quantity = match report.q.as_deref() {
1894 Some(q) => match Decimal::from_str(q) {
1895 Ok(v) => v,
1896 Err(e) => {
1897 warn!(%symbol, qty = q, error = %e, "BinanceMargin NEW event unparseable quantity (q), dropping");
1898 return None;
1899 }
1900 },
1901 None => {
1902 warn!(%symbol, "BinanceMargin NEW order missing quantity (q), dropping");
1903 return None;
1904 }
1905 };
1906 let time_in_force = parse_time_in_force(report.f.as_deref().unwrap_or("GTC"));
1907 let filled_qty = report
1908 .z
1909 .as_deref()
1910 .and_then(|s| Decimal::from_str(s).ok())
1911 .unwrap_or(Decimal::ZERO);
1912
1913 let order = Order {
1914 key: OrderKey::new(
1915 ExchangeId::BinanceMargin,
1916 symbol,
1917 StrategyId::unknown(),
1918 cid,
1919 ),
1920 side,
1921 price,
1922 quantity,
1923 kind,
1924 time_in_force,
1925 state: OrderState::active(Open::new(order_id, time_exchange, filled_qty)),
1926 };
1927 Some(UnindexedAccountEvent::new(
1928 ExchangeId::BinanceMargin,
1929 AccountEventKind::OrderSnapshot(rustrade_integration::collection::snapshot::Snapshot::new(
1930 order,
1931 )),
1932 ))
1933}
1934
1935fn convert_margin_account_position(
1941 position: Outboundaccountposition,
1942 buf: &mut Vec<UnindexedAccountEvent>,
1943) {
1944 let time_exchange = position
1945 .u
1946 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
1947 .unwrap_or_else(Utc::now);
1948
1949 for b in position.b_uppercase.unwrap_or_default() {
1950 let asset = match b.a {
1951 Some(a) => AssetNameExchange::new(a),
1952 None => {
1953 warn!("BinanceMargin account position entry missing asset name");
1954 continue;
1955 }
1956 };
1957 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1958 Some(v) => v,
1959 None => {
1960 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
1961 continue;
1962 }
1963 };
1964 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
1965 Some(v) => v,
1966 None => {
1967 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
1968 continue;
1969 }
1970 };
1971 let update =
1972 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
1973 buf.push(UnindexedAccountEvent::new(
1974 ExchangeId::BinanceMargin,
1975 AccountEventKind::BalanceStreamUpdate(
1976 rustrade_integration::collection::snapshot::Snapshot::new(update),
1977 ),
1978 ));
1979 }
1980}
1981
1982fn route_isolated_account_position(
1993 position: Outboundaccountposition,
1994 subscription_id: Option<i64>,
1995 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
1996 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
1997 buf: &mut Vec<UnindexedAccountEvent>,
1998) {
1999 let Some(sub_id) = subscription_id else {
2000 warn!(
2001 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
2002 (per-instrument balance routing requires it)"
2003 );
2004 return;
2005 };
2006 let instrument = {
2007 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
2010 match map.get(&sub_id) {
2011 Some(inst) => inst.clone(),
2012 None => {
2013 warn!(
2014 subscription_id = sub_id,
2015 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
2016 );
2017 return;
2018 }
2019 }
2020 };
2021 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
2022 warn!(
2023 instrument = %instrument.name(),
2024 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
2025 );
2026 return;
2027 };
2028
2029 let time_exchange = position
2030 .u
2031 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2032 .unwrap_or_else(Utc::now);
2033
2034 let mut base_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
2035 let mut quote_update: Option<AssetBalanceUpdate<AssetNameExchange>> = None;
2036 for b in position.b_uppercase.unwrap_or_default() {
2037 let Some(asset) = b.a.as_deref() else {
2038 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
2039 continue;
2040 };
2041 let side = if asset == base_asset.name().as_str() {
2043 &mut base_update
2044 } else if asset == quote_asset.name().as_str() {
2045 &mut quote_update
2046 } else {
2047 continue;
2048 };
2049 let (Some(free), Some(locked)) = (
2050 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2051 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2052 ) else {
2053 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
2054 continue;
2055 };
2056 *side = Some(AssetBalanceUpdate::new(
2057 AssetNameExchange::new(asset),
2058 BalanceUpdate::new(free, locked),
2059 time_exchange,
2060 ));
2061 }
2062
2063 let (Some(base), Some(quote)) = (base_update, quote_update) else {
2067 warn!(
2068 instrument = %instrument.name(),
2069 "BinanceMargin isolated: outboundAccountPosition missing base or quote side — dropping (no partial InstrumentBalanceUpdate)"
2070 );
2071 return;
2072 };
2073 buf.push(UnindexedAccountEvent::new(
2074 ExchangeId::BinanceMargin,
2075 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
2076 instrument, base, quote,
2077 )),
2078 ));
2079}
2080
2081fn register_user_data_listener(
2092 ws: &Arc<WsApiBase>,
2093 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2094 dedup: SharedDedupCache,
2095 heartbeat_flag: Arc<AtomicBool>,
2096 signal_tx: oneshot::Sender<()>,
2097 mut handle_position: impl FnMut(
2098 Outboundaccountposition,
2099 Option<i64>,
2100 &mut Vec<UnindexedAccountEvent>,
2101 ) + Send
2102 + 'static,
2103) -> Subscription {
2104 let mut signal_tx_opt = Some(signal_tx);
2105 let mut event_tx = Some(tx);
2106 let mut event_buf = Vec::with_capacity(32);
2110
2111 ws.common.events.subscribe(move |event| {
2115 let Some(ref sender) = event_tx else { return };
2116 match event {
2117 WebsocketEvent::Message(json_str) => {
2118 heartbeat_flag.store(true, Ordering::Release);
2119 let terminated = convert_margin_user_data_events_with(
2122 &json_str,
2123 &mut event_buf,
2124 &mut handle_position,
2125 );
2126 for ev in event_buf.drain(..) {
2127 if let Some(key) = dedup_key_from_event(&ev)
2128 && is_duplicate(&dedup, key)
2129 {
2130 trace!("BinanceMargin dedup: skipping duplicate event");
2131 continue;
2132 }
2133 if sender.send(ev).is_err() {
2134 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
2135 event_tx.take();
2136 if let Some(s) = signal_tx_opt.take() {
2137 let _ = s.send(());
2138 }
2139 return;
2140 }
2141 }
2142 if terminated {
2143 event_tx.take();
2144 if let Some(s) = signal_tx_opt.take() {
2145 let _ = s.send(());
2146 }
2147 }
2148 }
2149 WebsocketEvent::Ping | WebsocketEvent::Pong => {
2150 heartbeat_flag.store(true, Ordering::Release);
2151 }
2152 WebsocketEvent::Error(e) => {
2153 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
2154 event_tx.take();
2155 if let Some(s) = signal_tx_opt.take() {
2156 let _ = s.send(());
2157 }
2158 }
2159 WebsocketEvent::Close(code, reason) => {
2160 warn!(code, %reason, "BinanceMargin WebSocket closed");
2161 event_tx.take();
2162 if let Some(s) = signal_tx_opt.take() {
2163 let _ = s.send(());
2164 }
2165 }
2166 _ => {
2167 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
2168 }
2169 }
2170 })
2171}
2172
2173async fn recover_margin_fills(
2178 rest: &Arc<RestApi>,
2179 rate_limiter: &Arc<RateLimitTracker>,
2180 instruments: &[InstrumentNameExchange],
2181 disconnect_time: DateTime<Utc>,
2182 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2183 dedup: &SharedDedupCache,
2184 is_isolated: bool,
2185) {
2186 use futures::StreamExt as _;
2187
2188 if instruments.is_empty() {
2189 debug!("BinanceMargin recover_fills: empty instruments — no fills recovered");
2190 return;
2191 }
2192 info!(
2193 since = %disconnect_time,
2194 instruments = instruments.len(),
2195 "BinanceMargin recovering fills after reconnect"
2196 );
2197
2198 let start_time_ms = disconnect_time.timestamp_millis();
2199 let mut recovered = 0u32;
2200 let mut duplicates = 0u32;
2201 let mut failed_instruments = 0u32;
2202
2203 let mut stream = futures::stream::iter(instruments.iter().cloned().map(|inst| {
2204 let rest = rest.clone();
2205 let rl = rate_limiter.clone();
2206 async move {
2207 match paginate_margin_my_trades(&rest, &rl, &inst, start_time_ms, is_isolated).await {
2208 Ok(pages) => Some(
2209 pages
2210 .iter()
2211 .filter_map(|t| convert_margin_trade(t, &inst))
2212 .collect::<Vec<_>>(),
2213 ),
2214 Err(e) => {
2215 warn!(%e, %inst, "BinanceMargin fill recovery: REST request failed");
2216 None
2217 }
2218 }
2219 }
2220 }))
2221 .buffer_unordered(8);
2222 while let Some(result) = stream.next().await {
2223 let trades = match result {
2224 Some(t) => t,
2225 None => {
2226 failed_instruments += 1;
2227 continue;
2228 }
2229 };
2230 for trade in trades {
2231 let event = UnindexedAccountEvent::new(
2232 ExchangeId::BinanceMargin,
2233 AccountEventKind::Trade(trade),
2234 );
2235 if let Some(key) = dedup_key_from_event(&event)
2236 && is_duplicate(dedup, key)
2237 {
2238 duplicates += 1;
2239 continue;
2240 }
2241 if tx.send(event).is_err() {
2242 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2243 return;
2244 }
2245 recovered += 1;
2246 }
2247 }
2248
2249 if failed_instruments > 0 {
2250 error!(
2251 recovered,
2252 duplicates,
2253 failed_instruments,
2254 "BinanceMargin fill recovery complete with failures — some fills may be permanently missed"
2255 );
2256 } else {
2257 info!(
2258 recovered,
2259 duplicates, "BinanceMargin fill recovery complete"
2260 );
2261 }
2262}
2263
2264#[allow(
2271 clippy::cognitive_complexity,
2272 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2273 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2274)]
2275async fn margin_connection_manager(
2276 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2277 dedup: SharedDedupCache,
2278 ws_config: ConfigurationWebsocketApi,
2279 rest_config: Arc<ConfigurationRestApi>,
2280 rest: Arc<RestApi>,
2281 rate_limiter: Arc<RateLimitTracker>,
2282 instruments: Vec<InstrumentNameExchange>,
2283 initial: Option<(Arc<WsApiBase>, UserListenToken)>,
2284) {
2285 enum DisconnectReason {
2286 Signal,
2287 HeartbeatTimeout,
2288 TokenRefresh,
2289 ConsumerDropped,
2290 }
2291
2292 let mut backoff = ExponentialBackoff::new();
2293 let mut disconnect_time: Option<DateTime<Utc>> = None;
2294 let (mut current_ws, mut current_token) = match initial {
2295 Some((ws, token)) => (Some(ws), Some(token)),
2296 None => (None, None),
2297 };
2298
2299 loop {
2300 let token = match current_token.take() {
2302 Some(t) => t,
2303 None => match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2304 Ok(t) => t,
2305 Err(e) => {
2306 error!(%e, "BinanceMargin userListenToken acquisition failed");
2307 if !backoff.wait().await {
2308 error!("BinanceMargin max reconnect attempts exhausted");
2309 emit_stream_terminated(
2310 &tx,
2311 ExchangeId::BinanceMargin,
2312 StreamTerminationReason::ReconnectBudgetExhausted {
2313 attempts: backoff.attempts(),
2314 last_error: e.to_string(),
2315 },
2316 );
2317 break;
2318 }
2319 continue;
2320 }
2321 },
2322 };
2323
2324 let ws = match current_ws.take() {
2326 Some(ws) => ws,
2327 None => match connect_margin_ws(&ws_config).await {
2328 Ok(ws) => ws,
2329 Err(e) => {
2330 error!(%e, "BinanceMargin WS connect failed");
2331 if !backoff.wait().await {
2332 error!("BinanceMargin max reconnect attempts exhausted");
2333 emit_stream_terminated(
2334 &tx,
2335 ExchangeId::BinanceMargin,
2336 StreamTerminationReason::ReconnectBudgetExhausted {
2337 attempts: backoff.attempts(),
2338 last_error: e.to_string(),
2339 },
2340 );
2341 break;
2342 }
2343 continue;
2344 }
2345 },
2346 };
2347
2348 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2350 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2352 let subscription = register_user_data_listener(
2355 &ws,
2356 tx.clone(),
2357 dedup.clone(),
2358 heartbeat_flag.clone(),
2359 signal_tx,
2360 cross_account_position_handler,
2361 );
2362
2363 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
2365 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
2366 subscription.unsubscribe();
2367 if let Err(de) = ws.disconnect().await {
2368 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
2369 }
2370 if !backoff.wait().await {
2372 error!("BinanceMargin max reconnect attempts exhausted");
2373 emit_stream_terminated(
2374 &tx,
2375 ExchangeId::BinanceMargin,
2376 StreamTerminationReason::ReconnectBudgetExhausted {
2377 attempts: backoff.attempts(),
2378 last_error: e.to_string(),
2379 },
2380 );
2381 break;
2382 }
2383 continue;
2384 }
2385 info!("BinanceMargin account_stream connected and subscribed");
2386 backoff.reset();
2390
2391 let token_deadline =
2394 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
2395
2396 if let Some(dt) = disconnect_time.take()
2398 && tokio::time::timeout(
2399 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2400 recover_margin_fills(&rest, &rate_limiter, &instruments, dt, &tx, &dedup, false),
2403 )
2404 .await
2405 .is_err()
2406 {
2407 warn!(
2408 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2409 "BinanceMargin fill recovery timed out — remaining instruments not queried"
2410 );
2411 }
2412
2413 let reason = {
2415 let mut signal_rx = signal_rx;
2416 loop {
2417 tokio::select! {
2418 biased;
2421 _ = tx.closed() => {
2422 debug!("BinanceMargin account_stream consumer dropped, terminating");
2423 break DisconnectReason::ConsumerDropped;
2424 }
2425 _ = &mut signal_rx => {
2426 warn!("BinanceMargin WS disconnected, will attempt reconnect");
2427 break DisconnectReason::Signal;
2428 }
2429 () = tokio::time::sleep_until(token_deadline) => {
2430 info!("BinanceMargin userListenToken nearing expiry, renewing");
2431 break DisconnectReason::TokenRefresh;
2432 }
2433 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2434 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2435 continue;
2439 }
2440 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2441 break DisconnectReason::HeartbeatTimeout;
2442 }
2443 }
2444 }
2445 };
2446 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2447
2448 if should_reconnect {
2449 disconnect_time = Some(match reason {
2450 DisconnectReason::HeartbeatTimeout => {
2451 Utc::now()
2452 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2453 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2454 }
2455 DisconnectReason::TokenRefresh => {
2456 Utc::now()
2461 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2462 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2463 }
2464 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2465 });
2466 }
2467
2468 subscription.unsubscribe();
2470 if let Err(e) = ws.disconnect().await {
2471 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
2472 }
2473
2474 if !should_reconnect || tx.is_closed() {
2475 debug!("BinanceMargin connection manager exiting");
2477 break;
2478 }
2479
2480 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2483 error!("BinanceMargin max reconnect attempts exhausted, stream terminating");
2484 let last_error = match reason {
2487 DisconnectReason::HeartbeatTimeout => {
2488 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2489 }
2490 _ => "WebSocket disconnected (server close/error)".to_string(),
2491 };
2492 emit_stream_terminated(
2493 &tx,
2494 ExchangeId::BinanceMargin,
2495 StreamTerminationReason::ReconnectBudgetExhausted {
2496 attempts: backoff.attempts(),
2497 last_error,
2498 },
2499 );
2500 break;
2501 }
2502 }
2503}
2504
2505fn build_base_quote_map(
2517 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
2518) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
2519 let mut map = HashMap::with_capacity(assets.len());
2520 for entry in assets {
2521 let (Some(symbol), Some(base), Some(quote)) = (
2522 entry.symbol.as_deref(),
2523 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
2524 entry
2525 .quote_asset
2526 .as_deref()
2527 .and_then(|q| q.asset.as_deref()),
2528 ) else {
2529 warn!(
2530 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
2531 skipping base/quote map entry"
2532 );
2533 continue;
2534 };
2535 map.insert(
2536 InstrumentNameExchange::new(symbol),
2537 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
2538 );
2539 }
2540 map
2541}
2542
2543async fn acquire_all_isolated_tokens(
2556 rest_config: &Arc<ConfigurationRestApi>,
2557 rate_limiter: &Arc<RateLimitTracker>,
2558 symbols: &[InstrumentNameExchange],
2559) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
2560 use futures::{StreamExt as _, TryStreamExt as _};
2561
2562 futures::stream::iter(symbols.iter().cloned().map(|sym| {
2563 let rest_config = rest_config.clone();
2564 let rate_limiter = rate_limiter.clone();
2565 async move {
2566 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
2567 Ok::<_, UnindexedClientError>((sym, token))
2568 }
2569 }))
2570 .buffer_unordered(8)
2571 .try_collect()
2572 .await
2573}
2574
2575fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
2580 tokens
2581 .iter()
2582 .map(|(_, t)| t.expiration_time_ms)
2583 .min()
2584 .unwrap_or(i64::MAX)
2585}
2586
2587struct IsolatedLiveConn {
2590 ws: Arc<WsApiBase>,
2591 subscription: Subscription,
2592 signal_rx: oneshot::Receiver<()>,
2593 heartbeat_flag: Arc<AtomicBool>,
2594 earliest_expiry_ms: i64,
2597}
2598
2599async fn isolated_connect_and_subscribe(
2609 ws_config: &ConfigurationWebsocketApi,
2610 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2611 dedup: &SharedDedupCache,
2612 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2613 tokens: &[(InstrumentNameExchange, UserListenToken)],
2614) -> anyhow::Result<IsolatedLiveConn> {
2615 let ws = connect_margin_ws(ws_config).await?;
2616 let sub_map = Arc::new(Mutex::new(
2622 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
2623 ));
2624 let (signal_tx, signal_rx) = oneshot::channel::<()>();
2625 let heartbeat_flag = Arc::new(AtomicBool::new(true));
2627
2628 let handle_position = {
2629 let sub_map = sub_map.clone();
2630 let base_quote = base_quote.clone();
2631 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
2632 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
2633 }
2634 };
2635 let subscription = register_user_data_listener(
2637 &ws,
2638 tx.clone(),
2639 dedup.clone(),
2640 heartbeat_flag.clone(),
2641 signal_tx,
2642 handle_position,
2643 );
2644
2645 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
2646 for (sym, token) in tokens {
2647 match subscribe_listen_token_capture(&ws, &token.token).await {
2648 Ok(Some(id)) => {
2649 sub_map
2650 .lock()
2651 .unwrap_or_else(|p| p.into_inner())
2652 .insert(id, sym.clone());
2653 }
2654 Ok(None) => warn!(
2655 symbol = %sym.name(),
2656 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
2657 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
2658 ),
2659 Err(e) => {
2660 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
2663 subscription.unsubscribe();
2664 if let Err(de) = ws.disconnect().await {
2665 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
2666 }
2667 return Err(e);
2668 }
2669 }
2670 }
2671
2672 Ok(IsolatedLiveConn {
2673 ws,
2674 subscription,
2675 signal_rx,
2676 heartbeat_flag,
2677 earliest_expiry_ms,
2678 })
2679}
2680
2681#[allow(
2690 clippy::cognitive_complexity,
2691 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
2692 + backoff); mirrors the cross manager, not worth splitting further"
2693)]
2694async fn isolated_connection_manager(
2695 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2696 dedup: SharedDedupCache,
2697 ws_config: ConfigurationWebsocketApi,
2698 rest_config: Arc<ConfigurationRestApi>,
2699 rest: Arc<RestApi>,
2700 rate_limiter: Arc<RateLimitTracker>,
2701 symbols: Vec<InstrumentNameExchange>,
2702 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
2703 initial: Option<IsolatedLiveConn>,
2704) {
2705 enum DisconnectReason {
2706 Signal,
2707 HeartbeatTimeout,
2708 TokenRefresh,
2709 ConsumerDropped,
2710 }
2711
2712 let mut backoff = ExponentialBackoff::new();
2713 let mut disconnect_time: Option<DateTime<Utc>> = None;
2714 let mut current = initial;
2715
2716 loop {
2717 let IsolatedLiveConn {
2719 ws,
2720 subscription,
2721 signal_rx,
2722 heartbeat_flag,
2723 earliest_expiry_ms,
2724 } = match current.take() {
2725 Some(live) => live,
2727 None => {
2728 let tokens = match acquire_all_isolated_tokens(
2731 &rest_config,
2732 &rate_limiter,
2733 &symbols,
2734 )
2735 .await
2736 {
2737 Ok(t) => t,
2738 Err(e) => {
2739 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
2740 if !backoff.wait().await {
2741 error!("BinanceMargin isolated max reconnect attempts exhausted");
2742 emit_stream_terminated(
2743 &tx,
2744 ExchangeId::BinanceMargin,
2745 StreamTerminationReason::ReconnectBudgetExhausted {
2746 attempts: backoff.attempts(),
2747 last_error: e.to_string(),
2748 },
2749 );
2750 break;
2751 }
2752 continue;
2753 }
2754 };
2755 match isolated_connect_and_subscribe(&ws_config, &tx, &dedup, &base_quote, &tokens)
2756 .await
2757 {
2758 Ok(live) => live,
2759 Err(e) => {
2760 error!(%e, "BinanceMargin isolated connect/subscribe failed");
2761 if !backoff.wait().await {
2762 error!("BinanceMargin isolated max reconnect attempts exhausted");
2763 emit_stream_terminated(
2764 &tx,
2765 ExchangeId::BinanceMargin,
2766 StreamTerminationReason::ReconnectBudgetExhausted {
2767 attempts: backoff.attempts(),
2768 last_error: e.to_string(),
2769 },
2770 );
2771 break;
2772 }
2773 continue;
2774 }
2775 }
2776 }
2777 };
2778
2779 info!(
2780 symbols = symbols.len(),
2781 "BinanceMargin isolated account_stream connected and subscribed"
2782 );
2783 backoff.reset();
2786
2787 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
2790
2791 if let Some(dt) = disconnect_time.take()
2793 && tokio::time::timeout(
2794 Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS),
2795 recover_margin_fills(&rest, &rate_limiter, &symbols, dt, &tx, &dedup, true),
2797 )
2798 .await
2799 .is_err()
2800 {
2801 warn!(
2802 timeout_secs = FILL_RECOVERY_TIMEOUT_SECS,
2803 "BinanceMargin isolated fill recovery timed out — remaining instruments not queried"
2804 );
2805 }
2806
2807 let reason = {
2810 let mut signal_rx = signal_rx;
2811 loop {
2812 tokio::select! {
2813 biased;
2814 _ = tx.closed() => {
2815 debug!("BinanceMargin isolated consumer dropped, terminating");
2816 break DisconnectReason::ConsumerDropped;
2817 }
2818 _ = &mut signal_rx => {
2819 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
2820 break DisconnectReason::Signal;
2821 }
2822 () = tokio::time::sleep_until(token_deadline) => {
2823 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
2824 break DisconnectReason::TokenRefresh;
2825 }
2826 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
2827 if heartbeat_flag.swap(false, Ordering::AcqRel) {
2828 continue;
2829 }
2830 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
2831 break DisconnectReason::HeartbeatTimeout;
2832 }
2833 }
2834 }
2835 };
2836 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
2837
2838 if should_reconnect {
2839 disconnect_time = Some(match reason {
2840 DisconnectReason::HeartbeatTimeout => {
2841 Utc::now()
2842 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
2843 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2844 }
2845 DisconnectReason::TokenRefresh => {
2846 Utc::now()
2850 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
2851 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
2852 }
2853 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
2854 });
2855 }
2856
2857 subscription.unsubscribe();
2859 if let Err(e) = ws.disconnect().await {
2860 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
2861 }
2862
2863 if !should_reconnect || tx.is_closed() {
2864 debug!("BinanceMargin isolated connection manager exiting");
2866 break;
2867 }
2868
2869 if !matches!(reason, DisconnectReason::TokenRefresh) && !backoff.wait().await {
2872 error!("BinanceMargin isolated max reconnect attempts exhausted, stream terminating");
2873 let last_error = match reason {
2876 DisconnectReason::HeartbeatTimeout => {
2877 format!("heartbeat timeout ({HEARTBEAT_TIMEOUT_SECS}s)")
2878 }
2879 _ => "WebSocket disconnected (server close/error)".to_string(),
2880 };
2881 emit_stream_terminated(
2882 &tx,
2883 ExchangeId::BinanceMargin,
2884 StreamTerminationReason::ReconnectBudgetExhausted {
2885 attempts: backoff.attempts(),
2886 last_error,
2887 },
2888 );
2889 break;
2890 }
2891 }
2892}
2893
2894async fn fetch_margin_open_orders_for_instrument(
2901 rest: Arc<RestApi>,
2902 rate_limiter: Arc<RateLimitTracker>,
2903 instrument: InstrumentNameExchange,
2904 is_isolated: bool,
2905) -> Result<
2906 (
2907 InstrumentNameExchange,
2908 Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
2909 ),
2910 UnindexedClientError,
2911> {
2912 let symbol_str = instrument.name().to_string();
2914 let isolated = isolated_str(is_isolated);
2915 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
2916 let sym = symbol_str.clone();
2917 let isolated = isolated.clone();
2918 Box::pin(async move {
2919 let params = QueryMarginAccountsOpenOrdersParams::builder()
2920 .symbol(sym)
2921 .is_isolated(isolated)
2922 .build()?;
2923 rest.query_margin_accounts_open_orders(params).await
2924 })
2925 })
2926 .await
2927 .map_err(connectivity_error)?;
2928
2929 let orders_data = response
2930 .data()
2931 .await
2932 .map_err(|e| connectivity_error(e.into()))?;
2933
2934 let orders = orders_data
2935 .into_iter()
2936 .filter_map(|o| convert_margin_open_order(&o, &instrument))
2937 .collect();
2938
2939 Ok((instrument, orders))
2940}
2941
2942async fn fetch_margin_all_open_orders(
2949 rest: Arc<RestApi>,
2950 rate_limiter: Arc<RateLimitTracker>,
2951 is_isolated: bool,
2952) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
2953 let isolated = isolated_str(is_isolated);
2954 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
2955 let isolated = isolated.clone();
2956 Box::pin(async move {
2957 let params = QueryMarginAccountsOpenOrdersParams::builder()
2958 .is_isolated(isolated)
2959 .build()?;
2960 rest.query_margin_accounts_open_orders(params).await
2961 })
2962 })
2963 .await
2964 .map_err(connectivity_error)?;
2965
2966 let orders_data = response
2967 .data()
2968 .await
2969 .map_err(|e| connectivity_error(e.into()))?;
2970
2971 let orders = orders_data
2972 .into_iter()
2973 .filter_map(|o| convert_margin_open_order_owned_symbol(&o))
2974 .collect();
2975
2976 Ok(orders)
2977}
2978
2979async fn paginate_margin_my_trades(
2988 rest: &Arc<RestApi>,
2989 rate_limiter: &Arc<RateLimitTracker>,
2990 instrument: &InstrumentNameExchange,
2991 start_time_ms: i64,
2992 is_isolated: bool,
2993) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
2994 let symbol_str = instrument.name().to_string();
2996 let isolated = isolated_str(is_isolated);
2997 #[allow(clippy::cast_possible_wrap)]
3002 let limit = BINANCE_MAX_TRADES as i64;
3003 let mut all_pages = Vec::new();
3004 let mut cursor: Option<i64> = None;
3005 loop {
3006 let fid = cursor; let response = rest_call_with_retry(rest, rate_limiter, |rest| {
3008 let sym = symbol_str.clone();
3009 let isolated = isolated.clone();
3010 let stm = start_time_ms;
3011 Box::pin(async move {
3012 let builder = QueryMarginAccountsTradeListParams::builder(sym)
3013 .is_isolated(isolated)
3014 .limit(limit);
3015 let params = if let Some(id) = fid {
3016 builder.from_id(id).build()?
3017 } else {
3018 builder.start_time(stm).build()?
3019 };
3020 rest.query_margin_accounts_trade_list(params).await
3021 })
3022 })
3023 .await
3024 .map_err(connectivity_error)?;
3025
3026 let page = response
3027 .data()
3028 .await
3029 .map_err(|e| connectivity_error(e.into()))?;
3030
3031 let page_len = page.len();
3032 let last_id = page.last().and_then(|t| t.id);
3033 all_pages.extend(page);
3034
3035 if page_len < BINANCE_MAX_TRADES {
3036 break;
3037 }
3038 match last_id {
3039 Some(id) => {
3040 debug!(%instrument, "BinanceMargin paginate_my_trades: fetching next page ({page_len} results)");
3041 match id.checked_add(1) {
3042 Some(next) => cursor = Some(next),
3043 None => break, }
3045 }
3046 None => {
3047 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, stopping pagination");
3048 break;
3049 }
3050 }
3051 }
3052 Ok(all_pages)
3053}
3054
3055fn convert_margin_balance_entry(
3065 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
3066 now: DateTime<Utc>,
3067) -> Option<AssetBalance<AssetNameExchange>> {
3068 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
3069 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3070 Some(v) => v,
3071 None => {
3072 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
3073 return None;
3074 }
3075 };
3076 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3077 Some(v) => v,
3078 None => {
3079 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
3080 return None;
3081 }
3082 };
3083 let borrowed = b
3088 .borrowed
3089 .as_deref()
3090 .and_then(|s| Decimal::from_str(s).ok())
3091 .unwrap_or(Decimal::ZERO);
3092 let interest = b
3093 .interest
3094 .as_deref()
3095 .and_then(|s| Decimal::from_str(s).ok())
3096 .unwrap_or(Decimal::ZERO);
3097 Some(AssetBalance::new(
3098 asset_name,
3099 Balance::new_margin(free + locked, free, borrowed, interest),
3100 now,
3101 ))
3102}
3103
3104fn filter_and_convert_margin_balances(
3107 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
3108 assets: &[AssetNameExchange],
3109) -> Vec<AssetBalance<AssetNameExchange>> {
3110 let now = Utc::now();
3111 if assets.is_empty() {
3113 return user_assets
3114 .into_iter()
3115 .filter_map(|b| convert_margin_balance_entry(b, now))
3116 .collect();
3117 }
3118 if assets.len() <= 16 {
3120 return user_assets
3121 .into_iter()
3122 .filter_map(|b| {
3123 let asset_name_str = b.asset.as_deref()?;
3124 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
3125 return None;
3126 }
3127 convert_margin_balance_entry(b, now)
3128 })
3129 .collect();
3130 }
3131 use std::collections::HashSet;
3132 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
3133 user_assets
3134 .into_iter()
3135 .filter_map(|b| {
3136 let asset_name_str = b.asset.as_deref()?;
3137 if !asset_set.contains(asset_name_str) {
3138 return None;
3139 }
3140 convert_margin_balance_entry(b, now)
3141 })
3142 .collect()
3143}
3144
3145fn active_order_snapshot(
3152 o: Order<ExchangeId, InstrumentNameExchange, Open>,
3153) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
3154{
3155 Order {
3156 key: o.key,
3157 side: o.side,
3158 price: o.price,
3159 quantity: o.quantity,
3160 kind: o.kind,
3161 time_in_force: o.time_in_force,
3162 state: OrderState::active(o.state),
3163 }
3164}
3165
3166fn convert_isolated_asset_balance(
3176 asset: Option<&str>,
3177 free: Option<&str>,
3178 locked: Option<&str>,
3179 borrowed: Option<&str>,
3180 interest: Option<&str>,
3181 now: DateTime<Utc>,
3182) -> Option<AssetBalance<AssetNameExchange>> {
3183 let asset_name = AssetNameExchange::new(asset?);
3184 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
3185 Some(v) => v,
3186 None => {
3187 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
3188 return None;
3189 }
3190 };
3191 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
3192 Some(v) => v,
3193 None => {
3194 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
3195 return None;
3196 }
3197 };
3198 let borrowed = borrowed
3199 .and_then(|s| Decimal::from_str(s).ok())
3200 .unwrap_or(Decimal::ZERO);
3201 let interest = interest
3202 .and_then(|s| Decimal::from_str(s).ok())
3203 .unwrap_or(Decimal::ZERO);
3204 Some(AssetBalance::new(
3205 asset_name,
3206 Balance::new_margin(free + locked, free, borrowed, interest),
3207 now,
3208 ))
3209}
3210
3211fn convert_isolated_margin_assets(
3219 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
3220) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
3221 let now = Utc::now();
3224 let mut map = HashMap::with_capacity(assets.len());
3225 for entry in assets {
3226 let Some(symbol) = entry.symbol.as_deref() else {
3227 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
3228 continue;
3229 };
3230 let instrument = InstrumentNameExchange::new(symbol);
3231
3232 let Some(base_raw) = entry.base_asset.as_deref() else {
3233 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
3234 continue;
3235 };
3236 let Some(quote_raw) = entry.quote_asset.as_deref() else {
3237 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
3238 continue;
3239 };
3240
3241 let Some(base) = convert_isolated_asset_balance(
3242 base_raw.asset.as_deref(),
3243 base_raw.free.as_deref(),
3244 base_raw.locked.as_deref(),
3245 base_raw.borrowed.as_deref(),
3246 base_raw.interest.as_deref(),
3247 now,
3248 ) else {
3249 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
3250 continue;
3251 };
3252 let Some(quote) = convert_isolated_asset_balance(
3253 quote_raw.asset.as_deref(),
3254 quote_raw.free.as_deref(),
3255 quote_raw.locked.as_deref(),
3256 quote_raw.borrowed.as_deref(),
3257 quote_raw.interest.as_deref(),
3258 now,
3259 ) else {
3260 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
3261 continue;
3262 };
3263
3264 let risk = IsolatedMarginRisk {
3265 margin_level: entry
3266 .margin_level
3267 .as_deref()
3268 .and_then(|s| Decimal::from_str(s).ok()),
3269 margin_ratio: entry
3270 .margin_ratio
3271 .as_deref()
3272 .and_then(|s| Decimal::from_str(s).ok()),
3273 liquidation_price: entry
3274 .liquidate_price
3275 .as_deref()
3276 .and_then(|s| Decimal::from_str(s).ok()),
3277 };
3278
3279 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
3280 }
3281 map
3282}
3283
3284fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
3287 symbols
3288 .chunks(5)
3289 .map(|chunk| {
3290 chunk
3291 .iter()
3292 .map(|s| s.name().as_str())
3293 .collect::<Vec<_>>()
3294 .join(",")
3295 })
3296 .collect()
3297}
3298
3299async fn fetch_isolated_margin_account_info(
3305 rest: Arc<RestApi>,
3306 rate_limiter: Arc<RateLimitTracker>,
3307 symbols: Vec<InstrumentNameExchange>,
3308) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
3309 use futures::{StreamExt as _, TryStreamExt as _};
3310
3311 let chunks = chunk_symbols(&symbols);
3312
3313 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
3317 let rest = rest.clone();
3318 let rate_limiter = rate_limiter.clone();
3319 async move {
3320 let response = rest_call_with_retry(&rest, &rate_limiter, |rest| {
3321 let symbols_param = symbols_param.clone();
3322 Box::pin(async move {
3323 let params = QueryIsolatedMarginAccountInfoParams::builder()
3324 .symbols(symbols_param)
3325 .build()?;
3326 rest.query_isolated_margin_account_info(params).await
3327 })
3328 })
3329 .await
3330 .map_err(connectivity_error)?;
3331
3332 let info = response
3333 .data()
3334 .await
3335 .map_err(|e| connectivity_error(e.into()))?;
3336 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
3337 }
3338 }))
3339 .buffer_unordered(8)
3340 .try_fold(
3341 Vec::with_capacity(symbols.len()),
3342 |mut acc, assets| async move {
3343 acc.extend(assets);
3344 Ok(acc)
3345 },
3346 )
3347 .await
3348}
3349
3350fn convert_margin_open_order(
3357 o: &QueryMarginAccountsOpenOrdersResponseInner,
3358 instrument: &InstrumentNameExchange,
3359) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3360 let order_id_raw = match o.order_id {
3361 Some(id) => id,
3362 None => {
3363 warn!(%instrument, "BinanceMargin open order missing orderId");
3364 return None;
3365 }
3366 };
3367 let order_id = OrderId(format_smolstr!("{order_id_raw}"));
3368 if o.client_order_id.is_none() {
3369 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing clientOrderId, using orderId as fallback — order may not reconcile with engine state");
3370 }
3371 let cid = ClientOrderId::new(
3372 o.client_order_id
3373 .as_deref()
3374 .unwrap_or(&format_smolstr!("{order_id_raw}")),
3375 );
3376 let side = match o.side.as_deref() {
3377 Some(s) => parse_side(s)?,
3379 None => {
3380 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing side");
3381 return None;
3382 }
3383 };
3384 let price = o.price.as_deref().and_then(|s| Decimal::from_str(s).ok());
3385 let quantity = match o
3386 .orig_qty
3387 .as_deref()
3388 .and_then(|s| Decimal::from_str(s).ok())
3389 {
3390 Some(v) => v,
3391 None => {
3392 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing/unparseable origQty");
3393 return None;
3394 }
3395 };
3396 let filled_qty = match o.executed_qty.as_deref() {
3397 Some(s) => match Decimal::from_str(s) {
3398 Ok(v) => v,
3399 Err(_) => {
3400 warn!(%instrument, order_id = %order_id_raw, executed_qty = s, "BinanceMargin open order unparseable executedQty, defaulting to 0");
3401 Decimal::ZERO
3402 }
3403 },
3404 None => Decimal::ZERO,
3405 };
3406 let kind = match o.r#type.as_deref() {
3407 Some(t) => parse_order_kind(t)?,
3409 None => {
3410 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing type");
3411 return None;
3412 }
3413 };
3414 let time_in_force = parse_time_in_force(o.time_in_force.as_deref().unwrap_or("GTC"));
3415 let time_exchange = match o.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3416 Some(ts) => ts,
3417 None => {
3418 warn!(%instrument, order_id = %order_id_raw, "BinanceMargin open order missing/unparseable time, using now");
3419 Utc::now()
3420 }
3421 };
3422
3423 Some(Order {
3424 key: OrderKey::new(
3425 ExchangeId::BinanceMargin,
3426 instrument.clone(),
3427 StrategyId::unknown(),
3430 cid,
3431 ),
3432 side,
3433 price,
3434 quantity,
3435 kind,
3436 time_in_force,
3437 state: Open::new(order_id, time_exchange, filled_qty),
3438 })
3439}
3440
3441fn convert_margin_open_order_owned_symbol(
3447 o: &QueryMarginAccountsOpenOrdersResponseInner,
3448) -> Option<Order<ExchangeId, InstrumentNameExchange, Open>> {
3449 let instrument = match o.symbol.as_deref() {
3450 Some(s) => InstrumentNameExchange::new(s),
3451 None => {
3452 warn!("BinanceMargin open order missing symbol in return-all query, dropping order");
3453 return None;
3454 }
3455 };
3456 convert_margin_open_order(o, &instrument)
3457}
3458
3459fn convert_margin_trade(
3466 t: &QueryMarginAccountsTradeListResponseInner,
3467 instrument: &InstrumentNameExchange,
3468) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
3469 let trade_id_raw = match t.id {
3470 Some(id) => id,
3471 None => {
3472 warn!(%instrument, "BinanceMargin trade missing id");
3473 return None;
3474 }
3475 };
3476 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
3477 let order_id = match t.order_id {
3478 Some(id) => OrderId(format_smolstr!("{id}")),
3479 None => {
3480 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
3481 return None;
3482 }
3483 };
3484 let side = match t.is_buyer {
3485 Some(true) => Side::Buy,
3486 Some(false) => Side::Sell,
3487 None => {
3488 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
3489 return None;
3490 }
3491 };
3492 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3493 Some(v) => v,
3494 None => {
3495 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
3496 return None;
3497 }
3498 };
3499 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
3500 Some(v) => v,
3501 None => {
3502 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
3503 return None;
3504 }
3505 };
3506 let commission = t
3507 .commission
3508 .as_deref()
3509 .and_then(|s| Decimal::from_str(s).ok())
3510 .unwrap_or(Decimal::ZERO);
3511 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
3512 Some(ts) => ts,
3513 None => {
3514 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
3515 Utc::now()
3516 }
3517 };
3518
3519 let fee_asset = t
3523 .commission_asset
3524 .as_deref()
3525 .map(AssetNameExchange::from)
3526 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
3527
3528 Some(Trade::new(
3529 trade_id,
3530 order_id,
3531 instrument.clone(),
3532 StrategyId::unknown(), time_exchange,
3534 side,
3535 price,
3536 quantity,
3537 AssetFees::new(fee_asset, commission, None),
3538 ))
3539}
3540
3541#[derive(Debug)]
3547enum BuildOrderError {
3548 Unsupported,
3550 Build(String),
3552}
3553
3554fn isolated_str(is_isolated: bool) -> String {
3560 if is_isolated { "TRUE" } else { "FALSE" }.to_string()
3561}
3562
3563fn build_cancel_order_params(
3571 symbol: String,
3572 id: Option<&OrderId>,
3573 cid: &ClientOrderId,
3574 is_isolated: bool,
3575) -> Result<MarginAccountCancelOrderParams, String> {
3576 let mut builder =
3577 MarginAccountCancelOrderParams::builder(symbol).is_isolated(isolated_str(is_isolated));
3578
3579 match id {
3580 Some(order_id) => match order_id.0.parse::<i64>() {
3581 Ok(id) => builder = builder.order_id(id),
3582 Err(_) => {
3583 error!(
3585 order_id = %order_id.0,
3586 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
3587 );
3588 builder = builder.orig_client_order_id(cid.0.to_string());
3589 }
3590 },
3591 None => builder = builder.orig_client_order_id(cid.0.to_string()),
3592 }
3593
3594 builder.build().map_err(|e| e.to_string())
3595}
3596
3597#[allow(clippy::too_many_arguments)] fn build_new_order_params(
3606 symbol: String,
3607 side: Side,
3608 price: Option<Decimal>,
3609 quantity: Decimal,
3610 kind: OrderKind,
3611 time_in_force: TimeInForce,
3612 new_client_order_id: String,
3613 side_effect: MarginSideEffect,
3614 is_isolated: bool,
3615) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3616 let binance_side = match side {
3617 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
3618 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
3619 };
3620
3621 let (binance_type, binance_tif) =
3622 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
3623
3624 let mut builder = MarginAccountNewOrderParams::builder(
3626 symbol,
3627 binance_side,
3628 binance_type.as_binance_str().to_string(),
3629 )
3630 .quantity(quantity)
3631 .is_isolated(isolated_str(is_isolated))
3632 .side_effect_type(side_effect.as_binance_str().to_string())
3633 .new_client_order_id(new_client_order_id)
3634 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
3635
3636 if side_effect == MarginSideEffect::AutoBorrowRepay {
3639 builder = builder.auto_repay_at_cancel(true);
3640 }
3641
3642 if let Some(tif) = binance_tif {
3643 builder = builder.time_in_force(tif);
3644 }
3645
3646 match kind {
3649 OrderKind::Limit => {
3650 builder = builder.price(price);
3651 }
3652 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
3653 builder = builder.stop_price(trigger_price);
3654 }
3655 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
3656 builder = builder.price(price).stop_price(trigger_price);
3657 }
3658 _ => {}
3662 }
3663
3664 builder
3665 .build()
3666 .map_err(|e| BuildOrderError::Build(e.to_string()))
3667}
3668
3669fn convert_order_kind_tif_margin(
3680 kind: OrderKind,
3681 tif: TimeInForce,
3682) -> Option<(
3683 BinanceOrderType,
3684 Option<MarginAccountNewOrderTimeInForceEnum>,
3685)> {
3686 if matches!(
3687 kind,
3688 OrderKind::TrailingStop { .. } | OrderKind::TrailingStopLimit { .. }
3689 ) {
3690 warn!(
3691 ?kind,
3692 "BinanceMargin does not support trailing-stop orders (SDK trailingDelta binding gap)"
3693 );
3694 return None;
3695 }
3696
3697 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
3698 let margin_tif = binance_tif.map(|t| match t {
3699 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
3700 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
3701 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
3702 });
3703 Some((binance_type, margin_tif))
3704}
3705
3706fn margin_avg_price(cummulative_quote_qty: Option<&str>, filled_qty: Decimal) -> Option<Decimal> {
3712 if filled_qty.is_zero() {
3713 return None;
3714 }
3715 let s = cummulative_quote_qty?;
3716 match Decimal::from_str(s) {
3717 Ok(cumulative) => cumulative.checked_div(filled_qty),
3718 Err(_) => {
3719 warn!(
3722 cummulative_quote_qty = s,
3723 "BinanceMargin: failed to parse cummulativeQuoteQty; avg price unavailable"
3724 );
3725 None
3726 }
3727 }
3728}
3729
3730#[cfg(test)]
3731#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
3733 use super::*;
3734
3735 #[test]
3736 fn margin_side_effect_default_is_auto_borrow_repay() {
3737 assert_eq!(
3738 MarginSideEffect::default(),
3739 MarginSideEffect::AutoBorrowRepay
3740 );
3741 }
3742
3743 #[test]
3744 fn margin_side_effect_wire_strings() {
3745 assert_eq!(
3746 MarginSideEffect::AutoBorrowRepay.as_binance_str(),
3747 "AUTO_BORROW_REPAY"
3748 );
3749 assert_eq!(
3750 MarginSideEffect::NoBorrow.as_binance_str(),
3751 "NO_SIDE_EFFECT"
3752 );
3753 }
3754
3755 #[test]
3756 fn margin_side_effect_serde_round_trip() {
3757 assert_eq!(
3759 serde_json::to_string(&MarginSideEffect::AutoBorrowRepay).unwrap(),
3760 r#""auto_borrow_repay""#
3761 );
3762 assert_eq!(
3763 serde_json::to_string(&MarginSideEffect::NoBorrow).unwrap(),
3764 r#""no_borrow""#
3765 );
3766 assert_eq!(
3767 serde_json::from_str::<MarginSideEffect>(r#""auto_borrow_repay""#).unwrap(),
3768 MarginSideEffect::AutoBorrowRepay
3769 );
3770 assert_eq!(
3771 serde_json::from_str::<MarginSideEffect>(r#""no_borrow""#).unwrap(),
3772 MarginSideEffect::NoBorrow
3773 );
3774 }
3775
3776 #[test]
3777 fn config_debug_redacts_secrets() {
3778 let config = BinanceMarginConfig::new(
3779 "my_api_key".to_string(),
3780 "my_secret_key".to_string(),
3781 false,
3782 false,
3783 Vec::new(),
3784 MarginSideEffect::default(),
3785 );
3786 let debug = format!("{config:?}");
3787 assert!(!debug.contains("my_api_key"));
3788 assert!(!debug.contains("my_secret_key"));
3789 assert!(debug.contains("***"));
3790 }
3791
3792 #[test]
3793 fn cross_margin_uses_common_case_defaults() {
3794 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
3795 assert!(!config.testnet);
3796 assert!(!config.is_isolated);
3797 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3798 assert_eq!(config.api_key(), "k");
3799 }
3800
3801 #[test]
3802 fn config_deserializes_with_defaults() {
3803 let config: BinanceMarginConfig =
3805 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
3806 .expect("deserialize");
3807 assert!(!config.is_isolated);
3808 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3809 }
3810
3811 #[test]
3812 fn config_deserializes_explicit_side_effect() {
3813 let config: BinanceMarginConfig = serde_json::from_str(
3814 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"side_effect":"no_borrow"}"#,
3815 )
3816 .expect("deserialize");
3817 assert!(config.is_isolated);
3818 assert_eq!(config.side_effect, MarginSideEffect::NoBorrow);
3819 }
3820
3821 #[test]
3822 fn isolated_ctor_sets_symbols_and_flag() {
3823 let symbols = vec![
3824 InstrumentNameExchange::new("BTCUSDT"),
3825 InstrumentNameExchange::new("ETHUSDT"),
3826 ];
3827 let config =
3828 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
3829 assert!(config.is_isolated);
3830 assert_eq!(config.isolated_symbols, symbols);
3831 assert_eq!(config.side_effect, MarginSideEffect::AutoBorrowRepay);
3832 }
3833
3834 #[test]
3835 fn isolated_config_with_symbols_constructs() {
3836 let config = BinanceMarginConfig::isolated(
3838 "k".to_string(),
3839 "s".to_string(),
3840 vec![InstrumentNameExchange::new("BTCUSDT")],
3841 );
3842 let _client = BinanceMargin::new(config); }
3844
3845 #[test]
3846 #[should_panic(expected = "non-empty isolated_symbols")]
3847 fn isolated_empty_symbols_panics_at_new() {
3848 let config = BinanceMarginConfig::new(
3850 "k".to_string(),
3851 "s".to_string(),
3852 false,
3853 true,
3854 Vec::new(),
3855 MarginSideEffect::default(),
3856 );
3857 let _ = BinanceMargin::new(config);
3858 }
3859
3860 #[test]
3861 #[should_panic(expected = "non-empty isolated_symbols")]
3862 fn isolated_empty_symbols_panics_via_deserialize_path() {
3863 let config: BinanceMarginConfig = serde_json::from_str(
3866 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
3867 )
3868 .expect("deserialize");
3869 assert!(config.isolated_symbols.is_empty());
3870 let _ = BinanceMargin::new(config);
3871 }
3872
3873 #[test]
3874 fn isolated_str_maps_mode() {
3875 assert_eq!(isolated_str(false), "FALSE");
3876 assert_eq!(isolated_str(true), "TRUE");
3877 }
3878
3879 #[test]
3884 fn cancel_params_cross_with_parseable_order_id() {
3885 let p = build_cancel_order_params(
3886 "BTCUSDT".to_string(),
3887 Some(&OrderId::new("12345")),
3888 &ClientOrderId::new("cid-1"),
3889 false,
3890 )
3891 .expect("build");
3892 assert_eq!(p.symbol, "BTCUSDT");
3893 assert_eq!(p.is_isolated.as_deref(), Some("FALSE"));
3894 assert_eq!(p.order_id, Some(12345));
3895 assert_eq!(p.orig_client_order_id, None);
3896 }
3897
3898 #[test]
3899 fn cancel_params_isolated_is_config_driven() {
3900 let p = build_cancel_order_params(
3901 "BTCUSDT".to_string(),
3902 Some(&OrderId::new("12345")),
3903 &ClientOrderId::new("cid-1"),
3904 true,
3905 )
3906 .expect("build");
3907 assert_eq!(p.is_isolated.as_deref(), Some("TRUE"));
3908 assert_eq!(p.order_id, Some(12345));
3909 }
3910
3911 #[test]
3912 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
3913 let p = build_cancel_order_params(
3914 "BTCUSDT".to_string(),
3915 Some(&OrderId::new("not-an-i64")),
3916 &ClientOrderId::new("cid-1"),
3917 false,
3918 )
3919 .expect("build");
3920 assert_eq!(p.order_id, None);
3921 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3922 }
3923
3924 #[test]
3925 fn cancel_params_absent_order_id_uses_cid() {
3926 let p = build_cancel_order_params(
3927 "BTCUSDT".to_string(),
3928 None,
3929 &ClientOrderId::new("cid-1"),
3930 false,
3931 )
3932 .expect("build");
3933 assert_eq!(p.order_id, None);
3934 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
3935 }
3936
3937 use crate::order::TrailingOffsetType;
3942
3943 fn params(
3945 side: Side,
3946 price: Option<Decimal>,
3947 kind: OrderKind,
3948 tif: TimeInForce,
3949 side_effect: MarginSideEffect,
3950 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
3951 build_new_order_params(
3952 "BTCUSDT".to_string(),
3953 side,
3954 price,
3955 Decimal::from(2),
3956 kind,
3957 tif,
3958 "cid-1".to_string(),
3959 side_effect,
3960 false, )
3962 }
3963
3964 fn gtc() -> TimeInForce {
3965 TimeInForce::GoodUntilCancelled { post_only: false }
3966 }
3967
3968 #[test]
3969 fn new_order_limit_maps_core_fields() {
3970 let p = params(
3971 Side::Buy,
3972 Some(Decimal::from(50_000)),
3973 OrderKind::Limit,
3974 gtc(),
3975 MarginSideEffect::AutoBorrowRepay,
3976 )
3977 .expect("build");
3978
3979 assert_eq!(p.symbol, "BTCUSDT");
3980 assert_eq!(p.side.as_str(), "BUY");
3981 assert_eq!(p.r#type, "LIMIT");
3982 assert_eq!(p.quantity, Some(Decimal::from(2)));
3983 assert_eq!(p.price, Some(Decimal::from(50_000)));
3984 assert_eq!(p.stop_price, None);
3985 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
3986 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
3987 assert_eq!(
3989 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
3990 Some("FULL")
3991 );
3992 }
3993
3994 #[test]
3995 fn new_order_is_isolated_is_config_driven() {
3996 let cross = build_new_order_params(
3998 "BTCUSDT".to_string(),
3999 Side::Sell,
4000 Some(Decimal::from(10)),
4001 Decimal::from(2),
4002 OrderKind::Limit,
4003 gtc(),
4004 "cid-1".to_string(),
4005 MarginSideEffect::AutoBorrowRepay,
4006 false,
4007 )
4008 .expect("build");
4009 assert_eq!(cross.is_isolated.as_deref(), Some("FALSE"));
4010 assert_eq!(cross.side.as_str(), "SELL");
4011
4012 let isolated = build_new_order_params(
4014 "BTCUSDT".to_string(),
4015 Side::Sell,
4016 Some(Decimal::from(10)),
4017 Decimal::from(2),
4018 OrderKind::Limit,
4019 gtc(),
4020 "cid-1".to_string(),
4021 MarginSideEffect::AutoBorrowRepay,
4022 true,
4023 )
4024 .expect("build");
4025 assert_eq!(isolated.is_isolated.as_deref(), Some("TRUE"));
4026 }
4027
4028 #[test]
4029 fn side_effect_and_auto_repay_gating() {
4030 let auto = params(
4032 Side::Buy,
4033 Some(Decimal::from(1)),
4034 OrderKind::Limit,
4035 gtc(),
4036 MarginSideEffect::AutoBorrowRepay,
4037 )
4038 .expect("build");
4039 assert_eq!(auto.side_effect_type.as_deref(), Some("AUTO_BORROW_REPAY"));
4040 assert_eq!(auto.auto_repay_at_cancel, Some(true));
4041
4042 let no_borrow = params(
4044 Side::Buy,
4045 Some(Decimal::from(1)),
4046 OrderKind::Limit,
4047 gtc(),
4048 MarginSideEffect::NoBorrow,
4049 )
4050 .expect("build");
4051 assert_eq!(
4052 no_borrow.side_effect_type.as_deref(),
4053 Some("NO_SIDE_EFFECT")
4054 );
4055 assert_eq!(no_borrow.auto_repay_at_cancel, None);
4056 }
4057
4058 #[test]
4059 fn new_order_market_has_no_price_or_tif() {
4060 let p = params(
4061 Side::Buy,
4062 None,
4063 OrderKind::Market,
4064 gtc(),
4065 MarginSideEffect::AutoBorrowRepay,
4066 )
4067 .expect("build");
4068 assert_eq!(p.r#type, "MARKET");
4069 assert_eq!(p.price, None);
4070 assert_eq!(p.stop_price, None);
4071 assert!(p.time_in_force.is_none());
4072 }
4073
4074 #[test]
4075 fn post_only_limit_maps_to_limit_maker() {
4076 let p = params(
4077 Side::Buy,
4078 Some(Decimal::from(10)),
4079 OrderKind::Limit,
4080 TimeInForce::GoodUntilCancelled { post_only: true },
4081 MarginSideEffect::AutoBorrowRepay,
4082 )
4083 .expect("build");
4084 assert_eq!(p.r#type, "LIMIT_MAKER");
4085 assert!(p.time_in_force.is_none());
4087 }
4088
4089 #[test]
4090 fn conditional_kinds_set_stop_price() {
4091 let trigger = Decimal::from(48_000);
4092
4093 let stop = params(
4094 Side::Sell,
4095 None,
4096 OrderKind::Stop {
4097 trigger_price: trigger,
4098 },
4099 gtc(),
4100 MarginSideEffect::AutoBorrowRepay,
4101 )
4102 .expect("build");
4103 assert_eq!(stop.r#type, "STOP_LOSS");
4104 assert_eq!(stop.stop_price, Some(trigger));
4105 assert_eq!(stop.price, None);
4106
4107 let stop_limit = params(
4108 Side::Sell,
4109 Some(Decimal::from(47_900)),
4110 OrderKind::StopLimit {
4111 trigger_price: trigger,
4112 },
4113 gtc(),
4114 MarginSideEffect::AutoBorrowRepay,
4115 )
4116 .expect("build");
4117 assert_eq!(stop_limit.r#type, "STOP_LOSS_LIMIT");
4118 assert_eq!(stop_limit.stop_price, Some(trigger));
4119 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
4120 assert_eq!(
4121 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
4122 Some("GTC")
4123 );
4124
4125 let take_profit = params(
4126 Side::Sell,
4127 None,
4128 OrderKind::TakeProfit {
4129 trigger_price: trigger,
4130 },
4131 gtc(),
4132 MarginSideEffect::AutoBorrowRepay,
4133 )
4134 .expect("build");
4135 assert_eq!(take_profit.r#type, "TAKE_PROFIT");
4136 assert_eq!(take_profit.stop_price, Some(trigger));
4137 }
4138
4139 #[test]
4140 fn trailing_kinds_are_rejected() {
4141 let trailing_stop = params(
4142 Side::Sell,
4143 None,
4144 OrderKind::TrailingStop {
4145 offset: Decimal::from(100),
4146 offset_type: TrailingOffsetType::BasisPoints,
4147 },
4148 gtc(),
4149 MarginSideEffect::AutoBorrowRepay,
4150 );
4151 assert!(matches!(trailing_stop, Err(BuildOrderError::Unsupported)));
4152
4153 let trailing_stop_limit = params(
4154 Side::Sell,
4155 Some(Decimal::from(100)),
4156 OrderKind::TrailingStopLimit {
4157 offset: Decimal::from(100),
4158 offset_type: TrailingOffsetType::BasisPoints,
4159 limit_offset: Decimal::from(10),
4160 },
4161 gtc(),
4162 MarginSideEffect::AutoBorrowRepay,
4163 );
4164 assert!(matches!(
4165 trailing_stop_limit,
4166 Err(BuildOrderError::Unsupported)
4167 ));
4168 }
4169
4170 #[test]
4171 fn tif_variants_map_to_margin_enum() {
4172 for (tif, expected) in [
4173 (TimeInForce::ImmediateOrCancel, "IOC"),
4174 (TimeInForce::FillOrKill, "FOK"),
4175 (gtc(), "GTC"),
4176 ] {
4177 let p = params(
4178 Side::Buy,
4179 Some(Decimal::from(1)),
4180 OrderKind::Limit,
4181 tif,
4182 MarginSideEffect::AutoBorrowRepay,
4183 )
4184 .expect("build");
4185 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
4186 }
4187 }
4188
4189 #[test]
4190 fn avg_price_from_cumulative_quote_qty() {
4191 assert_eq!(
4193 margin_avg_price(Some("100"), Decimal::from(4)),
4194 Some(Decimal::from(25))
4195 );
4196 assert_eq!(margin_avg_price(Some("100"), Decimal::ZERO), None);
4198 assert_eq!(margin_avg_price(None, Decimal::from(4)), None);
4200 assert_eq!(
4201 margin_avg_price(Some("not-a-number"), Decimal::from(4)),
4202 None
4203 );
4204 }
4205
4206 fn user_asset(
4211 asset: &str,
4212 free: &str,
4213 locked: &str,
4214 borrowed: &str,
4215 interest: &str,
4216 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
4217 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4218 a.asset = Some(asset.to_string());
4219 a.free = Some(free.to_string());
4220 a.locked = Some(locked.to_string());
4221 a.borrowed = Some(borrowed.to_string());
4222 a.interest = Some(interest.to_string());
4223 a
4224 }
4225
4226 #[test]
4227 fn margin_balance_entry_maps_debt() {
4228 let ab =
4230 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
4231 .expect("convert");
4232 assert_eq!(ab.asset.name().as_str(), "USDT");
4233 assert_eq!(ab.balance.total, Decimal::from(12));
4234 assert_eq!(ab.balance.free, Decimal::from(10));
4235 assert!(
4236 ab.balance.margin.is_some(),
4237 "REST snapshot must populate margin"
4238 );
4239 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
4241 }
4242
4243 #[test]
4244 fn margin_balance_short_is_negative_net() {
4245 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
4247 .expect("convert");
4248 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
4249 }
4250
4251 #[test]
4252 fn margin_balance_missing_debt_defaults_to_zero() {
4253 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4256 a.asset = Some("ETH".to_string());
4257 a.free = Some("4".to_string());
4258 a.locked = Some("0".to_string());
4259 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
4260 assert!(ab.balance.margin.is_some());
4261 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
4262 }
4263
4264 #[test]
4265 fn margin_balance_missing_free_is_dropped() {
4266 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
4268 a.asset = Some("USDT".to_string());
4269 a.locked = Some("0".to_string());
4270 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
4271 }
4272
4273 #[test]
4274 fn margin_balance_filtering() {
4275 let assets = vec![
4276 user_asset("USDT", "10", "0", "0", "0"),
4277 user_asset("BTC", "1", "0", "0", "0"),
4278 user_asset("ETH", "5", "0", "0", "0"),
4279 ];
4280 assert_eq!(
4282 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
4283 3
4284 );
4285 let filtered = filter_and_convert_margin_balances(
4287 assets,
4288 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
4289 );
4290 assert_eq!(filtered.len(), 2);
4291 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
4292 }
4293
4294 #[test]
4295 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
4296 let assets = vec![
4298 user_asset("BTC", "1", "0", "0", "0"),
4299 user_asset("ETH", "5", "0", "0", "0"),
4300 user_asset("DOGE", "9", "0", "0", "0"), ];
4302 let mut requested: Vec<AssetNameExchange> = (0..17)
4304 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
4305 .collect();
4306 requested.push(AssetNameExchange::new("BTC"));
4307 requested.push(AssetNameExchange::new("ETH"));
4308 assert!(
4309 requested.len() > 16,
4310 "must exceed the linear-scan threshold"
4311 );
4312
4313 let filtered = filter_and_convert_margin_balances(assets, &requested);
4314 assert_eq!(filtered.len(), 2);
4315 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
4316 }
4317
4318 use binance_sdk::margin_trading::rest_api::{
4323 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
4324 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
4325 };
4326
4327 fn d(s: &str) -> Decimal {
4328 Decimal::from_str(s).unwrap()
4329 }
4330
4331 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
4332 let mut b = IsoBase::new();
4333 b.asset = Some(asset.to_string());
4334 b.free = Some(free.to_string());
4335 b.locked = Some(locked.to_string());
4336 b.borrowed = Some(borrowed.to_string());
4337 b.interest = Some(interest.to_string());
4338 b
4339 }
4340
4341 fn iso_quote(
4342 asset: &str,
4343 free: &str,
4344 locked: &str,
4345 borrowed: &str,
4346 interest: &str,
4347 ) -> IsoQuote {
4348 let mut q = IsoQuote::new();
4349 q.asset = Some(asset.to_string());
4350 q.free = Some(free.to_string());
4351 q.locked = Some(locked.to_string());
4352 q.borrowed = Some(borrowed.to_string());
4353 q.interest = Some(interest.to_string());
4354 q
4355 }
4356
4357 fn iso_entry(
4358 symbol: &str,
4359 base: IsoBase,
4360 quote: IsoQuote,
4361 margin_level: Option<&str>,
4362 margin_ratio: Option<&str>,
4363 liquidate_price: Option<&str>,
4364 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
4365 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4366 e.symbol = Some(symbol.to_string());
4367 e.base_asset = Some(Box::new(base));
4368 e.quote_asset = Some(Box::new(quote));
4369 e.margin_level = margin_level.map(str::to_string);
4370 e.margin_ratio = margin_ratio.map(str::to_string);
4371 e.liquidate_price = liquidate_price.map(str::to_string);
4372 e
4373 }
4374
4375 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
4376 let config = BinanceMarginConfig::isolated(
4377 "k".to_string(),
4378 "s".to_string(),
4379 symbols
4380 .iter()
4381 .map(|s| InstrumentNameExchange::new(*s))
4382 .collect(),
4383 );
4384 BinanceMargin::new(config)
4385 }
4386
4387 #[test]
4388 fn isolated_assets_map_base_quote_and_risk() {
4389 let entry = iso_entry(
4392 "BTCUSDT",
4393 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
4394 iso_quote("USDT", "1000", "50", "300", "1.5"),
4395 Some("3.5"),
4396 Some("0.12"),
4397 Some("48000"),
4398 );
4399 let map = convert_isolated_margin_assets(vec![entry]);
4400 let state = map
4401 .get(&InstrumentNameExchange::new("BTCUSDT"))
4402 .expect("entry present");
4403
4404 assert_eq!(state.base.asset.name().as_str(), "BTC");
4405 assert_eq!(state.base.balance.total, d("0.6"));
4406 assert_eq!(state.base.balance.free, d("0.5"));
4407 assert_eq!(state.base.balance.net_asset(), d("0.4"));
4408
4409 assert_eq!(state.quote.asset.name().as_str(), "USDT");
4410 assert_eq!(state.quote.balance.total, d("1050"));
4411 assert_eq!(state.quote.balance.net_asset(), d("750"));
4412
4413 assert_eq!(state.risk.margin_level, Some(d("3.5")));
4414 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
4415 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
4416 }
4417
4418 #[test]
4419 fn isolated_base_short_is_negative_net() {
4420 let entry = iso_entry(
4422 "BTCUSDT",
4423 iso_base("BTC", "0", "0", "1.5", "0.001"),
4424 iso_quote("USDT", "100", "0", "0", "0"),
4425 None,
4426 None,
4427 None,
4428 );
4429 let map = convert_isolated_margin_assets(vec![entry]);
4430 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4431 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
4432 }
4433
4434 #[test]
4435 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
4436 let mut base = IsoBase::new();
4439 base.asset = Some("BTC".to_string());
4440 base.free = Some("0.5".to_string());
4441 base.locked = Some("0".to_string());
4442 let mut quote = IsoQuote::new();
4443 quote.asset = Some("USDT".to_string());
4444 quote.free = Some("100".to_string());
4445 quote.locked = Some("0".to_string());
4446
4447 let map = convert_isolated_margin_assets(vec![iso_entry(
4448 "BTCUSDT", base, quote, None, None, None,
4449 )]);
4450 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4451 assert!(state.base.balance.margin.is_some());
4452 assert_eq!(state.base.balance.net_asset(), d("0.5"));
4453 assert_eq!(state.risk.margin_level, None);
4454 assert_eq!(state.risk.liquidation_price, None);
4455 }
4456
4457 #[test]
4458 fn isolated_unparseable_risk_is_none_but_entry_kept() {
4459 let entry = iso_entry(
4460 "BTCUSDT",
4461 iso_base("BTC", "1", "0", "0", "0"),
4462 iso_quote("USDT", "1", "0", "0", "0"),
4463 Some("not_a_number"),
4464 None,
4465 None,
4466 );
4467 let map = convert_isolated_margin_assets(vec![entry]);
4468 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
4469 assert_eq!(state.risk.margin_level, None);
4470 }
4471
4472 #[test]
4473 fn isolated_missing_base_free_drops_entry() {
4474 let mut base = IsoBase::new();
4476 base.asset = Some("BTC".to_string());
4477 base.locked = Some("0".to_string()); let entry = iso_entry(
4479 "BTCUSDT",
4480 base,
4481 iso_quote("USDT", "100", "0", "0", "0"),
4482 None,
4483 None,
4484 None,
4485 );
4486 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
4487 }
4488
4489 #[test]
4490 fn isolated_missing_symbol_drops_entry() {
4491 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
4492 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
4493 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
4494 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
4496 }
4497
4498 #[test]
4499 fn chunk_symbols_batches_by_five() {
4500 let syms: Vec<InstrumentNameExchange> = (0..12)
4501 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
4502 .collect();
4503 let chunks = chunk_symbols(&syms);
4504 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
4505 assert_eq!(chunks[0].split(',').count(), 5);
4506 assert_eq!(chunks[1].split(',').count(), 5);
4507 assert_eq!(chunks[2].split(',').count(), 2);
4508
4509 let three: Vec<_> = (0..3)
4511 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4512 .collect();
4513 assert_eq!(chunk_symbols(&three).len(), 1);
4514
4515 let five: Vec<_> = (0..5)
4517 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
4518 .collect();
4519 let c = chunk_symbols(&five);
4520 assert_eq!(c.len(), 1);
4521 assert_eq!(c[0].split(',').count(), 5);
4522
4523 assert!(chunk_symbols(&[]).is_empty());
4525 }
4526
4527 #[test]
4528 fn effective_isolated_set_empty_returns_all_configured() {
4529 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4530 assert_eq!(
4531 client.effective_isolated_set(&[]),
4532 vec![
4533 InstrumentNameExchange::new("BTCUSDT"),
4534 InstrumentNameExchange::new("ETHUSDT"),
4535 ]
4536 );
4537 }
4538
4539 #[test]
4540 fn effective_isolated_set_intersects_requested() {
4541 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4542 assert_eq!(
4543 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
4544 vec![InstrumentNameExchange::new("ETHUSDT")]
4545 );
4546 }
4547
4548 #[test]
4549 fn effective_isolated_set_skips_out_of_set() {
4550 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
4551 assert_eq!(
4553 client.effective_isolated_set(&[
4554 InstrumentNameExchange::new("BTCUSDT"),
4555 InstrumentNameExchange::new("DOGEUSDT"),
4556 ]),
4557 vec![InstrumentNameExchange::new("BTCUSDT")]
4558 );
4559 }
4560
4561 #[tokio::test]
4562 async fn fetch_balances_isolated_returns_empty() {
4563 let client = isolated_client(&["BTCUSDT"]);
4566 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
4567 }
4568
4569 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
4570 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
4571 o.order_id = order_id;
4572 o.client_order_id = Some("cid-9".to_string());
4573 o.side = Some(side.to_string());
4574 o.price = Some("50000".to_string());
4575 o.orig_qty = Some("2".to_string());
4576 o.executed_qty = Some("0.5".to_string());
4577 o.r#type = Some("LIMIT".to_string());
4578 o.time_in_force = Some("GTC".to_string());
4579 o.time = Some(1_700_000_000_000);
4580 o
4581 }
4582
4583 #[test]
4584 fn margin_open_order_converts() {
4585 let inst = InstrumentNameExchange::new("BTCUSDT");
4586 let order =
4587 convert_margin_open_order(&open_order(Some(42), "BUY"), &inst).expect("convert");
4588 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4589 assert_eq!(order.state.id.0.as_str(), "42");
4590 assert_eq!(order.key.cid.0.as_str(), "cid-9");
4591 assert_eq!(order.side, Side::Buy);
4592 assert_eq!(order.price, Some(Decimal::from(50_000)));
4593 assert_eq!(order.quantity, Decimal::from(2));
4594 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
4595 assert_eq!(order.kind, OrderKind::Limit);
4596 }
4597
4598 #[test]
4599 fn margin_open_order_missing_order_id_is_dropped() {
4600 let inst = InstrumentNameExchange::new("BTCUSDT");
4601 assert!(convert_margin_open_order(&open_order(None, "BUY"), &inst).is_none());
4602 }
4603
4604 #[test]
4605 fn margin_open_order_owned_symbol_recovers_instrument() {
4606 let mut o = open_order(Some(42), "SELL");
4608 o.symbol = Some("ETHUSDT".to_string());
4609 let order = convert_margin_open_order_owned_symbol(&o).expect("convert");
4610 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
4611 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
4612 assert_eq!(order.side, Side::Sell);
4613 }
4614
4615 #[test]
4616 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
4617 let o = open_order(Some(42), "BUY");
4619 assert!(o.symbol.is_none());
4620 assert!(convert_margin_open_order_owned_symbol(&o).is_none());
4621 }
4622
4623 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
4624 let mut t = QueryMarginAccountsTradeListResponseInner::new();
4625 t.id = id;
4626 t.order_id = Some(7);
4627 t.is_buyer = is_buyer;
4628 t.price = Some("48000".to_string());
4629 t.qty = Some("0.25".to_string());
4630 t.commission = Some("0.001".to_string());
4631 t.commission_asset = Some("BNB".to_string());
4632 t.time = Some(1_700_000_000_000);
4633 t
4634 }
4635
4636 #[test]
4637 fn margin_trade_converts() {
4638 let inst = InstrumentNameExchange::new("BTCUSDT");
4639 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
4640 assert_eq!(tr.id.0.as_str(), "11");
4641 assert_eq!(tr.order_id.0.as_str(), "7");
4642 assert_eq!(tr.side, Side::Sell);
4643 assert_eq!(tr.price, Decimal::from(48_000));
4644 assert_eq!(tr.quantity, Decimal::new(25, 2));
4645 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
4647 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
4648 }
4649
4650 #[test]
4651 fn margin_trade_missing_fields_are_dropped() {
4652 let inst = InstrumentNameExchange::new("BTCUSDT");
4653 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
4655 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
4656 }
4657
4658 #[test]
4661 fn token_renew_after_applies_margin_and_floor() {
4662 let now_ms = Utc::now().timestamp_millis();
4663 let far = token_renew_after(now_ms + 86_400_000).as_secs();
4665 assert!(
4666 far > 80_000 && far <= 86_400,
4667 "expected ~24h-minus-margin, got {far}"
4668 );
4669 assert_eq!(
4671 token_renew_after(now_ms - 10_000).as_secs(),
4672 TOKEN_MIN_LIFETIME_SECS
4673 );
4674 assert_eq!(
4676 token_renew_after(now_ms + 60_000).as_secs(),
4677 TOKEN_MIN_LIFETIME_SECS
4678 );
4679 }
4680
4681 fn push(event: serde_json::Value) -> String {
4686 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
4687 }
4688
4689 #[test]
4690 fn margin_ws_rpc_ack_yields_no_events() {
4691 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
4693 let mut buf = Vec::new();
4694 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4695 assert!(buf.is_empty());
4696 }
4697
4698 #[test]
4699 fn margin_ws_execution_report_trade_maps_to_trade() {
4700 let frame = push(serde_json::json!({
4701 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4702 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
4703 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
4704 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
4705 }));
4706 let mut buf = Vec::new();
4707 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4708 assert_eq!(buf.len(), 1);
4709 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
4710 match &buf[0].kind {
4711 AccountEventKind::Trade(t) => {
4712 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
4713 assert_eq!(t.side, Side::Buy);
4714 assert_eq!(t.price, Decimal::from(48_000));
4715 assert_eq!(t.quantity, Decimal::new(5, 1));
4716 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
4718 }
4719 other => panic!("expected Trade, got {other:?}"),
4720 }
4721 }
4722
4723 #[test]
4724 fn margin_ws_new_report_maps_to_active_order_snapshot() {
4725 let frame = push(serde_json::json!({
4726 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
4727 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
4728 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
4729 }));
4730 let mut buf = Vec::new();
4731 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4732 assert_eq!(buf.len(), 1);
4733 match &buf[0].kind {
4734 AccountEventKind::OrderSnapshot(snap) => {
4735 assert_eq!(snap.0.side, Side::Sell);
4736 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
4737 assert_eq!(snap.0.quantity, Decimal::from(1));
4738 assert!(
4739 matches!(snap.0.state, OrderState::Active(_)),
4740 "NEW should be an active (resting) order"
4741 );
4742 }
4743 other => panic!("expected OrderSnapshot, got {other:?}"),
4744 }
4745 }
4746
4747 #[test]
4748 fn margin_ws_canceled_report_maps_to_order_cancelled() {
4749 let frame = push(serde_json::json!({
4750 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4751 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
4752 "z": "0", "T": 1_700_000_000_000_i64,
4753 }));
4754 let mut buf = Vec::new();
4755 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4756 assert_eq!(buf.len(), 1);
4757 match &buf[0].kind {
4758 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
4759 other => panic!("expected OrderCancelled, got {other:?}"),
4760 }
4761 }
4762
4763 #[test]
4764 fn margin_ws_execution_report_missing_symbol_is_dropped() {
4765 let frame = push(serde_json::json!({
4767 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
4768 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
4769 }));
4770 let mut buf = Vec::new();
4771 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4772 assert!(buf.is_empty());
4773 }
4774
4775 #[test]
4776 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
4777 let frame = push(serde_json::json!({
4778 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4779 "B": [
4780 { "a": "USDT", "f": "100.0", "l": "5.0" },
4781 { "a": "BTC", "f": "0.5", "l": "0" },
4782 ],
4783 }));
4784 let mut buf = Vec::new();
4785 assert!(!convert_margin_user_data_events(&frame, &mut buf));
4786 assert_eq!(buf.len(), 2);
4787 for ev in &buf {
4789 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
4790 }
4791 }
4792
4793 #[test]
4794 fn margin_ws_stream_terminated_signals_reconnect() {
4795 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
4796 let mut buf = Vec::new();
4797 assert!(
4798 convert_margin_user_data_events(&frame, &mut buf),
4799 "eventStreamTerminated must signal reconnect"
4800 );
4801 assert!(buf.is_empty());
4802 }
4803
4804 #[test]
4805 fn margin_ws_margin_specific_events_are_observable_only() {
4806 let mut buf = Vec::new();
4809 let liability = push(serde_json::json!({
4810 "e": "userLiabilityChange", "a": "USDT", "t": "BORROW", "p": "100", "i": "0.01",
4811 }));
4812 assert!(!convert_margin_user_data_events(&liability, &mut buf));
4813 let level = push(serde_json::json!({
4814 "e": "marginLevelStatusChange", "l": "1.5", "s": "MARGIN_LEVEL_2",
4815 }));
4816 assert!(!convert_margin_user_data_events(&level, &mut buf));
4817 assert!(buf.is_empty());
4818 }
4819
4820 #[test]
4823 fn margin_trade_event_dedup_by_key() {
4824 let frame = push(serde_json::json!({
4827 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
4828 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
4829 "l": "1", "L": "100", "z": "1", "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
4830 }));
4831 let mut buf = Vec::new();
4832 convert_margin_user_data_events(&frame, &mut buf);
4833 let event = buf.pop().expect("a Trade event");
4834
4835 let cache = new_dedup_cache();
4836 let first = dedup_key_from_event(&event).expect("Trade events have a dedup key");
4838 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
4839 let second = dedup_key_from_event(&event).expect("Trade events have a dedup key");
4840 assert!(
4841 is_duplicate(&cache, second),
4842 "second sighting is a duplicate"
4843 );
4844 }
4845
4846 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
4851 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
4852 }
4853
4854 fn isolated_handler(
4857 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
4858 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
4859 ) -> impl FnMut(Outboundaccountposition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
4860 move |position, subscription_id, buf| {
4861 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
4862 }
4863 }
4864
4865 fn btcusdt() -> InstrumentNameExchange {
4866 InstrumentNameExchange::new("BTCUSDT")
4867 }
4868
4869 fn token(expiration_time_ms: i64) -> UserListenToken {
4870 UserListenToken {
4871 token: "t".to_string(),
4872 expiration_time_ms,
4873 }
4874 }
4875
4876 #[test]
4877 fn listen_token_query_cross_vs_isolated() {
4878 assert!(build_listen_token_query(None).is_empty());
4880
4881 let q = build_listen_token_query(Some(&btcusdt()));
4883 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
4884 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
4885 assert_eq!(q.len(), 2);
4886 }
4887
4888 #[test]
4889 fn earliest_token_expiry_picks_min() {
4890 let tokens = vec![
4891 (btcusdt(), token(3_000)),
4892 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
4893 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
4894 ];
4895 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
4896 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
4898 }
4899
4900 #[test]
4901 fn base_quote_map_extracts_base_and_quote() {
4902 let entries = vec![
4903 iso_entry(
4904 "BTCUSDT",
4905 iso_base("BTC", "0", "0", "0", "0"),
4906 iso_quote("USDT", "0", "0", "0", "0"),
4907 None,
4908 None,
4909 None,
4910 ),
4911 iso_entry(
4912 "ETHBTC",
4913 iso_base("ETH", "0", "0", "0", "0"),
4914 iso_quote("BTC", "0", "0", "0", "0"),
4915 None,
4916 None,
4917 None,
4918 ),
4919 ];
4920 let map = build_base_quote_map(&entries);
4921 assert_eq!(
4922 map.get(&btcusdt())
4923 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4924 Some(("BTC", "USDT"))
4925 );
4926 assert_eq!(
4929 map.get(&InstrumentNameExchange::new("ETHBTC"))
4930 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
4931 Some(("ETH", "BTC"))
4932 );
4933 }
4934
4935 #[test]
4936 fn isolated_outbound_position_routes_to_instrument_balance_update() {
4937 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
4938 let base_quote = Arc::new(HashMap::from([(
4939 btcusdt(),
4940 (
4941 AssetNameExchange::new("BTC"),
4942 AssetNameExchange::new("USDT"),
4943 ),
4944 )]));
4945 let mut handler = isolated_handler(sub_map, base_quote);
4946
4947 let frame = push_with_sub(
4949 7,
4950 serde_json::json!({
4951 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
4952 "B": [
4953 { "a": "USDT", "f": "1000.0", "l": "50.0" },
4954 { "a": "BTC", "f": "0.5", "l": "0.1" },
4955 ],
4956 }),
4957 );
4958 let mut buf = Vec::new();
4959 assert!(!convert_margin_user_data_events_with(
4960 &frame,
4961 &mut buf,
4962 &mut handler
4963 ));
4964 assert_eq!(buf.len(), 1, "one InstrumentBalanceUpdate for the pair");
4965 match &buf[0].kind {
4966 AccountEventKind::InstrumentBalanceUpdate(ibu) => {
4967 assert_eq!(ibu.instrument.name().as_str(), "BTCUSDT");
4968 assert_eq!(ibu.base.asset.name().as_str(), "BTC");
4969 assert_eq!(ibu.base.update.free, Decimal::new(5, 1));
4970 assert_eq!(ibu.base.update.locked, Decimal::new(1, 1));
4971 assert_eq!(ibu.quote.asset.name().as_str(), "USDT");
4972 assert_eq!(ibu.quote.update.free, Decimal::from(1000));
4973 assert_eq!(ibu.quote.update.locked, Decimal::from(50));
4974 }
4975 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
4976 }
4977 assert!(!matches!(
4979 buf[0].kind,
4980 AccountEventKind::BalanceStreamUpdate(_)
4981 ));
4982 }
4983
4984 #[test]
4985 fn isolated_outbound_position_unknown_subscription_dropped() {
4986 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
4988 let base_quote = Arc::new(HashMap::from([(
4989 btcusdt(),
4990 (
4991 AssetNameExchange::new("BTC"),
4992 AssetNameExchange::new("USDT"),
4993 ),
4994 )]));
4995 let mut handler = isolated_handler(sub_map, base_quote);
4996
4997 let frame = push_with_sub(
4998 99,
4999 serde_json::json!({
5000 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5001 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
5002 }),
5003 );
5004 let mut buf = Vec::new();
5005 assert!(!convert_margin_user_data_events_with(
5006 &frame,
5007 &mut buf,
5008 &mut handler
5009 ));
5010 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
5011 }
5012
5013 #[test]
5014 fn isolated_outbound_position_missing_side_dropped() {
5015 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
5018 let base_quote = Arc::new(HashMap::from([(
5019 btcusdt(),
5020 (
5021 AssetNameExchange::new("BTC"),
5022 AssetNameExchange::new("USDT"),
5023 ),
5024 )]));
5025 let mut handler = isolated_handler(sub_map, base_quote);
5026
5027 let frame = push_with_sub(
5028 7,
5029 serde_json::json!({
5030 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
5031 "B": [{ "a": "BTC", "f": "0.5", "l": "0" }],
5032 }),
5033 );
5034 let mut buf = Vec::new();
5035 assert!(!convert_margin_user_data_events_with(
5036 &frame,
5037 &mut buf,
5038 &mut handler
5039 ));
5040 assert!(buf.is_empty(), "missing quote side → frame dropped");
5041 }
5042
5043 #[test]
5044 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
5045 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
5049 let base_quote = Arc::new(HashMap::new());
5050 let mut handler = isolated_handler(sub_map, base_quote);
5051
5052 let frame = push_with_sub(
5053 42,
5054 serde_json::json!({
5055 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
5056 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
5057 "l": "1", "L": "100", "z": "1", "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
5058 }),
5059 );
5060 let mut buf = Vec::new();
5061 assert!(!convert_margin_user_data_events_with(
5062 &frame,
5063 &mut buf,
5064 &mut handler
5065 ));
5066 assert_eq!(buf.len(), 1);
5067 match &buf[0].kind {
5068 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
5069 other => panic!("expected Trade, got {other:?}"),
5070 }
5071 }
5072}