1mod records;
62
63pub use records::MarginForcedLiquidation;
64
65use super::margin_risk::{
66 RISK_STREAM_URL, RiskStreamTask, SapiRiskListenKeys, log_liability_change,
67 log_margin_level_change, margin_level_notice,
68};
69use super::rules::RulesLoader;
70use super::shared::{
71 AbortOnDropStream, BINANCE_MAX_TRADES, BinanceOrderType, BinanceTimeInForce,
72 CONNECT_TIMEOUT_SECS, DisconnectOnDrop, ExecutionReport, FILL_RECOVERY_TIMEOUT_SECS,
73 HEARTBEAT_TIMEOUT_SECS, MyTradesFrom, ORDER_EXECUTIONS_BUDGET, OpenOrderListing,
74 PlacementResponse, RateLimitTracker, RequestKind, SIGNAL_RECOVERY_LOOKBACK_MS,
75 SharedDedupCache, TrailingOrderError, UnhandledEvents, UnrecoveredFills, UnsubscribeOnDrop,
76 UserDataFrame, WeightPool, binance_filled_qty, classify_order_kind_tif,
77 classify_rest_order_error, classify_rest_query_error, convert_ended_order,
78 convert_execution_report, convert_open_order_listing, convert_open_order_owned_symbol,
79 dedup_key_from_event, disconnect_error, drop_after, frame_excerpt, gap_failed, gap_time,
80 is_duplicate, is_unknown_order, log_unhandled_event, log_unrecognised_frame, new_dedup_cache,
81 parse_user_data_frame, placed_order_state, recovered_order_cids, recovered_order_totals,
82 response_decode_error, rest_call_with_retry, retry_or_end, trailing_params,
83};
84use crate::rules::{OrderRules, PreSendRules};
85use crate::{
86 AccountEventKind, AccountSnapshot, FillRecoveryFailure, InstrumentAccountSnapshot,
87 InstrumentBalanceUpdate, IsolatedInstrumentState, IsolatedMarginRisk, UnindexedAccountEvent,
88 UnindexedAccountSnapshot,
89 balance::{AssetBalance, AssetBalanceUpdate, Balance, BalanceUpdate},
90 borrow_capacity::BorrowCapacity,
91 client::{
92 BorrowCapacityClient, ExecutionClient, OrderStatusClient, ShortabilityClient,
93 order_recovery::{
94 KnownLiveOrders, OpenListing, OrderLookup, SharedKnownLiveOrders, UncheckedOrders,
95 fetch_ended_by_key, recover_ended_orders,
96 },
97 reconnect_budget::{BackoffLimits, ReconnectBudget},
98 },
99 emit_stream_terminated,
100 error::{ApiError, ConnectivityError, OrderError, UnindexedClientError},
101 margin::MarginEffect,
102 order::{
103 Order, OrderKey, OrderKind, TimeInForce, UnindexedInactiveOrder, UnindexedOrderKey,
104 id::{ClientOrderId, OrderId, StrategyId, VenueOrderId},
105 request::{OrderRequestCancel, OrderRequestOpen, UnindexedOrderResponseCancel},
106 state::{Cancelled, Open, OrderState, UnindexedOrderState},
107 },
108 position::PositionReport,
109 shortability::Shortability,
110 trade::{AssetFees, Trade, TradeId, TradesRead},
111};
112use binance_sdk::{
113 common::{
114 config::{ConfigurationRestApi, ConfigurationWebsocketApi},
115 constants::{MARGIN_TRADING_REST_API_PROD_URL, SPOT_WS_API_PROD_URL},
116 errors::WebsocketError,
117 models::WebsocketEvent,
118 websocket::{
119 SendWebsocketMessageResult, WebsocketApi as WsApiBase, WebsocketMessageSendOptions,
120 },
121 },
122 margin_trading::{
123 MarginTradingRestApi,
124 rest_api::{
125 GetAllCrossMarginPairsParams, GetAllIsolatedMarginSymbolParams,
126 GetAllMarginAssetsParams, GetDelistScheduleParams,
127 GetFutureHourlyInterestRateIsIsolatedEnum, GetFutureHourlyInterestRateParams,
128 MarginAccountCancelOrderIsIsolatedEnum, MarginAccountCancelOrderParams,
129 MarginAccountNewOrderIsIsolatedEnum, MarginAccountNewOrderNewOrderRespTypeEnum,
130 MarginAccountNewOrderParams, MarginAccountNewOrderSideEffectTypeEnum,
131 MarginAccountNewOrderSideEnum, MarginAccountNewOrderTimeInForceEnum,
132 MarginAccountNewOrderTypeEnum, QueryCrossMarginAccountDetailsParams,
133 QueryCrossMarginAccountDetailsResponseUserAssetsInner,
134 QueryIsolatedMarginAccountInfoParams,
135 QueryIsolatedMarginAccountInfoResponseAssetsInner,
136 QueryMarginAccountsOpenOrdersIsIsolatedEnum, QueryMarginAccountsOpenOrdersParams,
137 QueryMarginAccountsOrderIsIsolatedEnum, QueryMarginAccountsOrderParams,
138 QueryMarginAccountsTradeListIsIsolatedEnum, QueryMarginAccountsTradeListParams,
139 QueryMarginAccountsTradeListResponseInner, QueryMaxBorrowParams, RestApi,
140 },
141 websocket_streams::{
142 MarginLevelStatusChange, OutboundAccountPosition, UserLiabilityChange,
143 },
144 },
145};
146use chrono::{DateTime, TimeZone, Utc};
147use fnv::FnvHashSet;
148use futures::stream::BoxStream;
149use rust_decimal::Decimal;
150use rustrade_instrument::{
151 Side,
152 asset::name::AssetNameExchange,
153 exchange::ExchangeId,
154 instrument::{kind::InstrumentKindDiscriminant, name::InstrumentNameExchange},
155};
156use serde::Deserialize;
157use smol_str::{SmolStr, format_smolstr};
158use std::{
159 collections::{BTreeMap, HashMap},
160 str::FromStr,
161 sync::{
162 Arc, Mutex,
163 atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
164 },
165 time::Duration,
166};
167use tokio::sync::{mpsc, oneshot};
168use tracing::{debug, error, info, trace, warn};
169
170fn binance_side_effect(effect: MarginEffect) -> MarginAccountNewOrderSideEffectTypeEnum {
181 match effect {
182 MarginEffect::NoSideEffect => MarginAccountNewOrderSideEffectTypeEnum::NoSideEffect,
183 MarginEffect::Borrow => MarginAccountNewOrderSideEffectTypeEnum::MarginBuy,
184 MarginEffect::Repay => MarginAccountNewOrderSideEffectTypeEnum::AutoRepay,
185 }
186}
187
188#[derive(Clone, Deserialize)]
198pub struct BinanceMarginConfig {
199 api_key: String,
202 secret_key: String,
203 pub testnet: bool,
209 #[serde(default)]
216 pub is_isolated: bool,
217 #[serde(default)]
228 pub isolated_symbols: Vec<InstrumentNameExchange>,
229 #[serde(default)]
234 pub margin_effect: MarginEffect,
235 #[serde(default = "crate::margin::default_auto_repay_at_cancel")]
242 pub auto_repay_at_cancel: bool,
243 #[serde(default)]
249 pub pre_send_rules: PreSendRules,
250}
251
252impl std::fmt::Debug for BinanceMarginConfig {
254 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
255 f.debug_struct("BinanceMarginConfig")
256 .field("api_key", &"***")
257 .field("secret_key", &"***")
258 .field("testnet", &self.testnet)
259 .field("is_isolated", &self.is_isolated)
260 .field("isolated_symbols", &self.isolated_symbols)
261 .field("margin_effect", &self.margin_effect)
262 .field("auto_repay_at_cancel", &self.auto_repay_at_cancel)
263 .field("pre_send_rules", &self.pre_send_rules)
264 .finish()
265 }
266}
267
268impl BinanceMarginConfig {
269 pub fn new(
280 api_key: String,
281 secret_key: String,
282 testnet: bool,
283 is_isolated: bool,
284 isolated_symbols: Vec<InstrumentNameExchange>,
285 margin_effect: MarginEffect,
286 auto_repay_at_cancel: bool,
287 ) -> Self {
288 Self {
289 api_key,
290 secret_key,
291 testnet,
292 is_isolated,
293 isolated_symbols,
294 margin_effect,
295 auto_repay_at_cancel,
296 pre_send_rules: PreSendRules::default(),
297 }
298 }
299
300 #[must_use]
302 pub fn with_pre_send_rules(mut self, pre_send_rules: PreSendRules) -> Self {
303 self.pre_send_rules = pre_send_rules;
304 self
305 }
306
307 pub fn cross_margin(api_key: String, secret_key: String) -> Self {
314 Self::new(
315 api_key,
316 secret_key,
317 false,
318 false,
319 Vec::new(),
320 MarginEffect::default(),
321 true,
322 )
323 }
324
325 pub fn isolated(
333 api_key: String,
334 secret_key: String,
335 symbols: Vec<InstrumentNameExchange>,
336 ) -> Self {
337 Self::new(
338 api_key,
339 secret_key,
340 false,
341 true,
342 symbols,
343 MarginEffect::default(),
344 true,
345 )
346 }
347
348 pub fn api_key(&self) -> &str {
350 &self.api_key
351 }
352}
353
354#[derive(Clone)]
486pub struct BinanceMargin {
487 config: Arc<BinanceMarginConfig>,
488 rest: Arc<RestApi>,
490 rest_config: Arc<ConfigurationRestApi>,
494 ws_config: ConfigurationWebsocketApi,
498 rate_limiter: Arc<RateLimitTracker>,
500 known_live: SharedKnownLiveOrders,
503 rules: Arc<RulesLoader>,
506}
507
508impl std::fmt::Debug for BinanceMargin {
509 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510 f.debug_struct("BinanceMargin")
511 .field("testnet", &self.config.testnet)
512 .field("is_isolated", &self.config.is_isolated)
513 .field("margin_effect", &self.config.margin_effect)
514 .finish_non_exhaustive()
515 }
516}
517
518impl BinanceMargin {
519 pub async fn load_rules(
527 &self,
528 instruments: &[InstrumentNameExchange],
529 ) -> Result<HashMap<InstrumentNameExchange, OrderRules>, UnindexedClientError> {
530 self.rules.load(instruments).await
531 }
532
533 fn remember_live<'a>(
535 &self,
536 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
537 ) {
538 let mut known = self.known_live.lock();
539 for order in orders {
540 known.live(&order.key, order.quantity, order.kind, &order.state);
541 }
542 }
543
544 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
554 ConfigurationRestApi::builder()
555 .api_key(config.api_key.clone())
556 .api_secret(config.secret_key.clone())
557 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
558 .build()
559 .expect("failed to build Binance margin REST configuration")
560 }
561
562 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
573 ConfigurationWebsocketApi::builder()
574 .api_key(config.api_key.clone())
575 .api_secret(config.secret_key.clone())
576 .ws_url(SPOT_WS_API_PROD_URL)
580 .build()
581 .expect("failed to build Binance margin WebSocket configuration")
582 }
583
584 fn effective_isolated_set(
597 &self,
598 instruments: &[InstrumentNameExchange],
599 ) -> Vec<InstrumentNameExchange> {
600 if instruments.is_empty() {
601 return self.config.isolated_symbols.clone();
602 }
603 instruments
604 .iter()
605 .filter(|inst| {
606 let in_set = self.config.isolated_symbols.contains(*inst);
607 if !in_set {
608 warn!(
609 instrument = %inst.name(),
610 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
611 );
612 }
613 in_set
614 })
615 .cloned()
616 .collect()
617 }
618
619 async fn isolated_account_snapshot(
623 &self,
624 instruments: &[InstrumentNameExchange],
625 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
626 use futures::{StreamExt as _, TryStreamExt as _};
627
628 let effective = self.effective_isolated_set(instruments);
629 if effective.is_empty() {
630 return Ok(AccountSnapshot::new(
633 ExchangeId::BinanceMargin,
634 Vec::new(),
635 Vec::new(),
636 ));
637 }
638
639 let mut balances_by_symbol = convert_isolated_margin_assets(
641 fetch_isolated_margin_account_info(
642 self.rest.clone(),
643 self.rate_limiter.clone(),
644 effective.clone(),
645 )
646 .await?,
647 );
648
649 let instrument_snapshots: Vec<_> =
652 futures::stream::iter(effective.into_iter().map(|instrument| {
653 fetch_margin_open_orders_for_instrument(
654 self.rest.clone(),
655 self.rate_limiter.clone(),
656 instrument,
657 true,
658 RequestKind::Query,
659 )
660 }))
661 .buffer_unordered(8)
662 .map(|result| {
663 let (inst, listing) = result?;
664 self.remember_live(&listing.orders);
665 let wrapped = listing
666 .orders
667 .into_iter()
668 .map(active_order_snapshot)
669 .collect();
670 let isolated = balances_by_symbol.remove(&inst);
671 if isolated.is_none() {
672 warn!(
676 instrument = %inst.name(),
677 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
678 );
679 }
680 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
681 inst,
682 wrapped,
683 listing.complete,
684 PositionReport::Unreported,
685 isolated,
686 ))
687 })
688 .try_collect()
689 .await?;
690
691 Ok(AccountSnapshot::new(
694 ExchangeId::BinanceMargin,
695 Vec::new(),
696 instrument_snapshots,
697 ))
698 }
699}
700
701impl ExecutionClient for BinanceMargin {
702 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
703
704 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
707 &[InstrumentKindDiscriminant::Spot];
708 type Config = BinanceMarginConfig;
709 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
710
711 fn new(config: Self::Config) -> Self {
724 if config.testnet {
725 warn!(
726 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
727 using production endpoints"
728 );
729 }
730 assert!(
731 !(config.is_isolated && config.isolated_symbols.is_empty()),
732 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
733 );
734 let rest_config = Self::build_rest_config(&config);
739 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
740 let rules = Arc::new(RulesLoader::new(
744 config.pre_send_rules,
745 Arc::new(binance_sdk::spot::SpotRestApi::production(
746 rest_config.clone(),
747 )),
748 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
749 ));
750 let rest_config = Arc::new(rest_config);
751 let ws_config = Self::build_ws_config(&config);
752 Self {
753 config: Arc::new(config),
754 rest,
755 rest_config,
756 ws_config,
757 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
758 known_live: KnownLiveOrders::shared(ExchangeId::BinanceMargin),
759 rules,
760 }
761 }
762
763 async fn open_order(
792 &self,
793 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
794 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
795 let instrument = request.key.instrument.clone();
796 let side = request.state.side;
797 let price = request.state.price;
798 let quantity = request.state.quantity;
799 let kind = request.state.kind;
800 let time_in_force = request.state.time_in_force;
801 let cid = request.key.cid.clone();
802
803 let order_key = OrderKey::new(
804 ExchangeId::BinanceMargin,
805 instrument.clone(),
806 request.key.strategy.clone(),
807 cid.clone(),
808 );
809
810 let inactive = |state: UnindexedOrderState| Order {
813 key: order_key.clone(),
814 side,
815 price,
816 quantity,
817 kind,
818 time_in_force,
819 state,
820 };
821
822 let params = match build_new_order_params(
823 instrument.name().to_string(),
824 side,
825 price,
826 quantity,
827 kind,
828 time_in_force,
829 cid.0.to_string(),
830 request
831 .state
832 .margin_effect
833 .unwrap_or(self.config.margin_effect),
834 self.config.auto_repay_at_cancel,
835 self.config.is_isolated,
836 ) {
837 Ok(params) => params,
838 Err(BuildOrderError::Unsupported) => {
839 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
840 format!(
841 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
842 ),
843 )));
844 }
845 Err(BuildOrderError::Trailing(refused)) => {
846 return inactive(OrderState::inactive(refused.into_order_error()));
847 }
848 Err(BuildOrderError::Build(msg)) => {
849 error!(%msg, "BinanceMargin failed to build new order params");
850 return inactive(OrderState::inactive(OrderError::Rejected(
851 ApiError::OrderRejected(msg),
852 )));
853 }
854 };
855
856 if let Err(refused) = self
857 .rules
858 .check(instrument.name(), kind, price, quantity)
859 .await
860 {
861 return inactive(OrderState::inactive(refused));
862 }
863
864 let response =
865 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
866 let params = params.clone();
867 Box::pin(async move { rest.margin_account_new_order(params).await })
868 })
869 .await
870 {
871 Ok(response) => response,
872 Err(e) => {
873 return inactive(OrderState::inactive(classify_rest_order_error(
874 &e,
875 &instrument,
876 )));
877 }
878 };
879
880 let data = match response.data().await {
881 Ok(data) => data,
882 Err(e) => {
883 return inactive(OrderState::inactive(OrderError::Rejected(
886 ApiError::OrderRejected(e.to_string()),
887 )));
888 }
889 };
890
891 let time_exchange = data
892 .transact_time
893 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
894 .unwrap_or_else(Utc::now);
895
896 let exchange_order_id = match data.order_id {
897 Some(id) => OrderId(format_smolstr!("{id}")),
898 None => {
899 error!("BinanceMargin open_order response missing orderId");
900 return inactive(OrderState::inactive(OrderError::Rejected(
901 ApiError::OrderRejected("open_order response missing orderId".into()),
902 )));
903 }
904 };
905
906 let state = placed_order_state(
909 ExchangeId::BinanceMargin,
910 &instrument,
911 quantity,
912 PlacementResponse {
913 status: data.status.as_deref(),
914 order_id: exchange_order_id,
915 time_exchange,
916 executed_qty: data.executed_qty.as_deref(),
917 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
918 },
919 );
920 self.known_live
921 .lock()
922 .placed(&order_key, quantity, kind, &state);
923
924 Order {
925 key: order_key,
926 side,
927 price,
928 quantity,
929 kind,
930 time_in_force,
931 state,
932 }
933 }
934
935 async fn cancel_order(
945 &self,
946 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
947 ) -> UnindexedOrderResponseCancel {
948 let instrument = request.key.instrument.clone();
949 let key = OrderKey {
950 exchange: request.key.exchange,
951 instrument: instrument.clone(),
952 strategy: request.key.strategy.clone(),
953 cid: request.key.cid.clone(),
954 };
955
956 let params = match build_cancel_order_params(
957 instrument.name().to_string(),
958 request.state.id.as_ref().and_then(VenueOrderId::assigned),
959 &request.key.cid,
960 self.config.is_isolated,
961 ) {
962 Ok(p) => p,
963 Err(e) => {
964 error!(%e, "BinanceMargin failed to build cancel order params");
965 return UnindexedOrderResponseCancel {
966 key,
967 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
968 };
969 }
970 };
971
972 let response =
973 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
974 let params = params.clone();
975 Box::pin(async move { rest.margin_account_cancel_order(params).await })
976 })
977 .await
978 {
979 Ok(response) => response,
980 Err(e) => {
981 return UnindexedOrderResponseCancel {
982 key,
983 state: Err(classify_rest_order_error(&e, &instrument)),
984 };
985 }
986 };
987
988 let data = match response.data().await {
989 Ok(data) => data,
990 Err(e) => {
991 return UnindexedOrderResponseCancel {
994 key,
995 state: Err(OrderError::Rejected(ApiError::OrderRejected(e.to_string()))),
996 };
997 }
998 };
999
1000 let time_exchange = Utc::now();
1002
1003 let exchange_order_id = match data.order_id {
1004 Some(id) => OrderId(SmolStr::new(id)),
1007 None => {
1008 error!("BinanceMargin cancel response missing orderId");
1009 return UnindexedOrderResponseCancel {
1010 key,
1011 state: Err(OrderError::Rejected(ApiError::OrderRejected(
1012 "cancel response missing orderId".into(),
1013 ))),
1014 };
1015 }
1016 };
1017
1018 let filled_qty = binance_filled_qty(
1019 ExchangeId::BinanceMargin,
1020 &exchange_order_id,
1021 data.executed_qty.as_deref(),
1022 );
1023
1024 self.known_live.lock().ended(&key.cid);
1025 UnindexedOrderResponseCancel {
1026 key,
1027 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1028 }
1029 }
1030
1031 async fn account_snapshot(
1054 &self,
1055 assets: &[AssetNameExchange],
1056 instruments: &[InstrumentNameExchange],
1057 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1058 if self.config.is_isolated {
1059 return self.isolated_account_snapshot(instruments).await;
1060 }
1061 let response =
1062 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1063 Box::pin(async move {
1064 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1065 rest.query_cross_margin_account_details(params).await
1066 })
1067 })
1068 .await
1069 .map_err(|e| classify_rest_query_error(&e, None))?;
1070
1071 let account = response.data().await.map_err(response_decode_error)?;
1072
1073 let balances =
1074 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
1075
1076 use futures::{StreamExt as _, TryStreamExt as _};
1081 let instrument_snapshots: Vec<_> =
1082 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1083 fetch_margin_open_orders_for_instrument(
1084 self.rest.clone(),
1085 self.rate_limiter.clone(),
1086 instrument,
1087 false,
1089 RequestKind::Query,
1090 )
1091 }))
1092 .buffer_unordered(8)
1093 .map(|result| {
1094 let (inst, listing) = result?;
1095 self.remember_live(&listing.orders);
1096 let wrapped = listing
1097 .orders
1098 .into_iter()
1099 .map(active_order_snapshot)
1100 .collect();
1101 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1102 inst,
1103 wrapped,
1104 listing.complete,
1105 PositionReport::Unreported,
1106 None,
1107 ))
1108 })
1109 .try_collect()
1110 .await?;
1111
1112 Ok(AccountSnapshot::new(
1113 ExchangeId::BinanceMargin,
1114 balances,
1115 instrument_snapshots,
1116 ))
1117 }
1118
1119 async fn fetch_balances(
1133 &self,
1134 assets: &[AssetNameExchange],
1135 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1136 if self.config.is_isolated {
1137 return Ok(Vec::new());
1138 }
1139 let response =
1140 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1141 Box::pin(async move {
1142 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1143 rest.query_cross_margin_account_details(params).await
1144 })
1145 })
1146 .await
1147 .map_err(|e| classify_rest_query_error(&e, None))?;
1148
1149 let account = response.data().await.map_err(response_decode_error)?;
1150
1151 Ok(filter_and_convert_margin_balances(
1152 account.user_assets.unwrap_or_default(),
1153 assets,
1154 ))
1155 }
1156
1157 async fn fetch_open_orders(
1176 &self,
1177 instruments: &[InstrumentNameExchange],
1178 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1179 use futures::{StreamExt as _, TryStreamExt as _};
1180
1181 if self.config.is_isolated {
1185 let effective = self.effective_isolated_set(instruments);
1186 let capacity = effective.len();
1187 return futures::stream::iter(effective.into_iter().map(|instrument| {
1188 fetch_margin_open_orders_for_instrument(
1189 self.rest.clone(),
1190 self.rate_limiter.clone(),
1191 instrument,
1192 true,
1193 RequestKind::Query,
1194 )
1195 }))
1196 .buffer_unordered(8)
1197 .try_fold(
1198 Vec::with_capacity(capacity),
1199 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1200 (_, listing)| async move {
1201 acc.extend(listing.orders);
1202 Ok(acc)
1203 },
1204 )
1205 .await
1206 .inspect(|orders| self.remember_live(orders));
1207 }
1208
1209 if instruments.is_empty() {
1212 let orders =
1213 fetch_margin_all_open_orders(self.rest.clone(), self.rate_limiter.clone(), false)
1214 .await?;
1215 self.remember_live(&orders);
1216 return Ok(orders);
1217 }
1218 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1219 fetch_margin_open_orders_for_instrument(
1220 self.rest.clone(),
1221 self.rate_limiter.clone(),
1222 instrument,
1223 false,
1224 RequestKind::Query,
1225 )
1226 }))
1227 .buffer_unordered(8)
1228 .try_fold(
1229 Vec::with_capacity(instruments.len()),
1230 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1231 acc.extend(listing.orders);
1232 Ok(acc)
1233 },
1234 )
1235 .await
1236 .inspect(|orders| self.remember_live(orders))
1237 }
1238
1239 async fn fetch_trades(
1268 &self,
1269 start: DateTime<Utc>,
1270 end: DateTime<Utc>,
1271 instruments: &[InstrumentNameExchange],
1272 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1273 use futures::StreamExt as _;
1274
1275 if start > end {
1276 return Ok(TradesRead::complete(Vec::new()));
1277 }
1278
1279 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1282 self.effective_isolated_set(instruments)
1283 } else {
1284 instruments.to_vec()
1285 };
1286 if effective.is_empty() {
1287 debug!(
1290 is_isolated = self.config.is_isolated,
1291 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1292 );
1293 return Ok(TradesRead::complete(Vec::new()));
1294 }
1295 let span = MyTradesFrom::Span {
1297 start: start.timestamp_millis(),
1298 end: end.timestamp_millis(),
1299 };
1300 let is_isolated = self.config.is_isolated;
1301 let mut all_trades = Vec::new();
1302
1303 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1306 let rest = self.rest.clone();
1307 let rate_limiter = self.rate_limiter.clone();
1308 async move {
1309 let pages = paginate_margin_my_trades(
1310 &rest,
1311 &rate_limiter,
1312 &inst,
1313 span,
1314 is_isolated,
1315 RequestKind::Query,
1316 )
1317 .await?;
1318 Ok::<_, UnindexedClientError>((inst, pages))
1319 }
1320 }))
1321 .buffer_unordered(8);
1322 while let Some(result) = stream.next().await {
1323 let (instrument, trades_data) = result?;
1324 for t in trades_data {
1325 if let Some(trade) = convert_margin_trade(&t, &instrument)
1326 && (start..=end).contains(&trade.time_exchange)
1327 {
1328 all_trades.push(trade);
1329 }
1330 }
1331 }
1332
1333 Ok(TradesRead::complete(all_trades))
1334 }
1335
1336 async fn account_stream(
1473 &self,
1474 _assets: &[AssetNameExchange],
1477 instruments: &[InstrumentNameExchange],
1478 ) -> Result<Self::AccountStream, UnindexedClientError> {
1479 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1480 let dedup = new_dedup_cache();
1481 let rest = self.rest.clone();
1482 let rest_config = self.rest_config.clone();
1483 let ws_config = self.ws_config.clone();
1484 let rate_limiter = self.rate_limiter.clone();
1485 let known_live = self.known_live.clone();
1486
1487 let cm_handle = if self.config.is_isolated {
1488 let symbols = self.config.isolated_symbols.clone();
1492 debug_assert!(
1495 symbols.iter().all(|i| i.name().len() <= 23),
1496 "instrument name exceeds SmolStr inline capacity: {:?}",
1497 symbols.iter().find(|i| i.name().len() > 23)
1498 );
1499
1500 let assets = fetch_isolated_margin_account_info(
1506 rest.clone(),
1507 rate_limiter.clone(),
1508 symbols.clone(),
1509 )
1510 .await?;
1511 let base_quote = Arc::new(build_base_quote_map(&assets));
1512 for sym in &symbols {
1513 if !base_quote.contains_key(sym) {
1514 warn!(
1515 symbol = %sym.name(),
1516 "BinanceMargin isolated: account info returned no base/quote for configured \
1517 symbol — its live per-pair balance frames will be dropped (fills/orders \
1518 unaffected; balances available via account_snapshot)"
1519 );
1520 }
1521 }
1522
1523 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1527 rate_limiter.before_stream_open().await?;
1530 let live = isolated_connect_and_subscribe(
1531 &ws_config,
1532 &tx,
1533 &dedup,
1534 &known_live,
1535 &base_quote,
1536 &tokens,
1537 )
1538 .await
1539 .map_err(|e| {
1540 rate_limiter.stream_open_error(&e, |e| {
1541 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1542 })
1543 })?;
1544
1545 tokio::spawn(isolated_connection_manager(
1546 tx,
1547 dedup,
1548 ws_config,
1549 rest_config,
1550 rest,
1551 rate_limiter,
1552 known_live,
1553 symbols,
1554 base_quote,
1555 Some(live),
1556 ))
1557 } else {
1558 let instruments = instruments.to_vec();
1560 debug_assert!(
1564 instruments.iter().all(|i| i.name().len() <= 23),
1565 "instrument name exceeds SmolStr inline capacity: {:?}",
1566 instruments.iter().find(|i| i.name().len() > 23)
1567 );
1568
1569 let initial_token =
1575 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1576 rate_limiter.before_stream_open().await?;
1578 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1579 rate_limiter.stream_open_error(&e, |e| {
1580 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1581 })
1582 })?;
1583
1584 let risk = RiskStreamTask::spawn(
1588 SapiRiskListenKeys {
1589 rest: rest.clone(),
1590 rate_limiter: rate_limiter.clone(),
1591 },
1592 RISK_STREAM_URL.to_owned(),
1593 tx.clone(),
1594 dedup.clone(),
1595 );
1596 let manager = margin_connection_manager(
1597 tx,
1598 dedup,
1599 ws_config,
1600 rest_config,
1601 rest,
1602 rate_limiter,
1603 known_live,
1604 instruments,
1605 Some((initial_ws, initial_token)),
1606 );
1607 tokio::spawn(async move {
1608 let _risk = risk;
1609 manager.await;
1610 })
1611 };
1612
1613 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1614 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1615 Ok(futures::StreamExt::boxed(guarded_stream))
1616 }
1617}
1618
1619impl OrderStatusClient for BinanceMargin {
1636 async fn fetch_ended_orders(
1637 &self,
1638 orders: &[UnindexedOrderKey],
1639 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1640 fetch_ended_by_key(orders, |key| {
1641 let recorded_kind = self.known_live.lock().kind_of(&key.cid);
1642 fetch_margin_order_lookup(
1643 self.rest.clone(),
1644 self.rate_limiter.clone(),
1645 key,
1646 self.config.is_isolated,
1647 RequestKind::Query,
1648 recorded_kind,
1649 )
1650 })
1651 .await
1652 }
1653}
1654
1655const HOURS_PER_YEAR: u32 = 24 * 365;
1661
1662impl ShortabilityClient for BinanceMargin {
1663 async fn fetch_shortability(
1690 &self,
1691 instrument: &InstrumentNameExchange,
1692 ) -> Result<Shortability, UnindexedClientError> {
1693 let pair = self.fetch_margin_pair(instrument).await?;
1694 let (borrowable, hourly_rate, delisting_at) = tokio::try_join!(
1695 self.fetch_asset_borrowable(&pair.base),
1696 self.fetch_next_hourly_interest_rate(&pair.base),
1697 self.fetch_delisting_at(instrument, &pair),
1698 )?;
1699
1700 let mut shortability = Shortability::new();
1701 if let Some(delisting_at) = delisting_at {
1702 shortability = shortability.with_delisting_at(delisting_at);
1703 }
1704 let not_delisted = Some(delisting_at.is_none_or(|delisting_at| delisting_at > Utc::now()));
1708 let flags = [
1709 pair.is_margin_trade,
1710 pair.is_sell_allowed,
1711 borrowable,
1712 not_delisted,
1713 ];
1714 if flags.contains(&Some(false)) {
1715 shortability = shortability.with_shortable(false);
1716 } else if flags.iter().all(|flag| *flag == Some(true)) {
1717 shortability = shortability.with_shortable(true);
1718 }
1719 if let Some(hourly_rate) = hourly_rate {
1720 shortability = shortability.with_fee_rate(hourly_rate * Decimal::from(HOURS_PER_YEAR));
1721 }
1722 Ok(shortability)
1723 }
1724}
1725
1726impl BorrowCapacityClient for BinanceMargin {
1727 async fn fetch_borrow_capacity(
1744 &self,
1745 instrument: &InstrumentNameExchange,
1746 side: Side,
1747 ) -> Result<BorrowCapacity, UnindexedClientError> {
1748 let pair = self.fetch_margin_pair(instrument).await?;
1749 let asset = match side {
1750 Side::Buy => pair.quote,
1751 Side::Sell => pair.base,
1752 };
1753 let isolated_symbol = self
1754 .config
1755 .is_isolated
1756 .then(|| instrument.name().to_string());
1757 let response =
1758 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1759 let asset = asset.name().to_string();
1760 let isolated_symbol = isolated_symbol.clone();
1761 Box::pin(async move {
1762 let params = QueryMaxBorrowParams::builder(asset)
1763 .isolated_symbol(isolated_symbol)
1764 .build()?;
1765 rest.query_max_borrow(params).await
1766 })
1767 })
1768 .await
1769 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1770 let max_borrow = response.data().await.map_err(response_decode_error)?;
1771
1772 let mut capacity = BorrowCapacity::new(asset);
1773 if let Some(amount) = optional_decimal("amount", max_borrow.amount.as_deref())? {
1774 capacity = capacity.with_borrowable_now(amount);
1775 }
1776 if let Some(limit) = optional_decimal("borrowLimit", max_borrow.borrow_limit.as_deref())? {
1777 capacity = capacity.with_account_limit(limit);
1778 }
1779 Ok(capacity)
1780 }
1781}
1782
1783#[derive(Debug)]
1785struct MarginPair {
1786 base: AssetNameExchange,
1787 quote: AssetNameExchange,
1788 is_margin_trade: Option<bool>,
1789 is_sell_allowed: Option<bool>,
1790}
1791
1792impl BinanceMargin {
1793 async fn fetch_margin_pair(
1797 &self,
1798 instrument: &InstrumentNameExchange,
1799 ) -> Result<MarginPair, UnindexedClientError> {
1800 let symbol = instrument.name().to_string();
1801 let listed = if self.config.is_isolated {
1802 let response =
1803 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1804 let symbol = symbol.clone();
1805 Box::pin(async move {
1806 let params = GetAllIsolatedMarginSymbolParams::builder()
1807 .symbol(symbol)
1808 .build()?;
1809 rest.get_all_isolated_margin_symbol(params).await
1810 })
1811 })
1812 .await
1813 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1814 response
1815 .data()
1816 .await
1817 .map_err(response_decode_error)?
1818 .into_iter()
1819 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
1820 .map(|pair| {
1821 (
1822 pair.base,
1823 pair.quote,
1824 pair.is_margin_trade,
1825 pair.is_sell_allowed,
1826 )
1827 })
1828 } else {
1829 let response =
1830 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1831 let symbol = symbol.clone();
1832 Box::pin(async move {
1833 let params = GetAllCrossMarginPairsParams::builder()
1834 .symbol(symbol)
1835 .build()?;
1836 rest.get_all_cross_margin_pairs(params).await
1837 })
1838 })
1839 .await
1840 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1841 response
1842 .data()
1843 .await
1844 .map_err(response_decode_error)?
1845 .into_iter()
1846 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
1847 .map(|pair| {
1848 (
1849 pair.base,
1850 pair.quote,
1851 pair.is_margin_trade,
1852 pair.is_sell_allowed,
1853 )
1854 })
1855 };
1856 let margin = if self.config.is_isolated {
1857 "isolated"
1858 } else {
1859 "cross"
1860 };
1861 let Some((base, quote, is_margin_trade, is_sell_allowed)) = listed else {
1862 return Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(
1863 instrument.clone(),
1864 format!("Binance lists no {margin} margin pair {symbol}"),
1865 )));
1866 };
1867 let (Some(base), Some(quote)) = (base, quote) else {
1868 return Err(UnindexedClientError::Internal(format!(
1869 "Binance lists {margin} margin pair {symbol} without its base or quote asset"
1870 )));
1871 };
1872 Ok(MarginPair {
1873 base: AssetNameExchange::new(base),
1874 quote: AssetNameExchange::new(quote),
1875 is_margin_trade,
1876 is_sell_allowed,
1877 })
1878 }
1879
1880 async fn fetch_asset_borrowable(
1883 &self,
1884 asset: &AssetNameExchange,
1885 ) -> Result<Option<bool>, UnindexedClientError> {
1886 let name = asset.name().to_string();
1887 let response =
1888 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1889 let name = name.clone();
1890 Box::pin(async move {
1891 let params = GetAllMarginAssetsParams::builder().asset(name).build()?;
1892 rest.get_all_margin_assets(params).await
1893 })
1894 })
1895 .await
1896 .map_err(|e| classify_rest_query_error(&e, None))?;
1897 Ok(response
1898 .data()
1899 .await
1900 .map_err(response_decode_error)?
1901 .into_iter()
1902 .find(|listed| listed.asset_name.as_deref() == Some(name.as_str()))
1903 .and_then(|listed| listed.is_borrowable))
1904 }
1905
1906 async fn fetch_delisting_at(
1911 &self,
1912 instrument: &InstrumentNameExchange,
1913 pair: &MarginPair,
1914 ) -> Result<Option<DateTime<Utc>>, UnindexedClientError> {
1915 let response =
1916 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1917 Box::pin(async move {
1918 let params = GetDelistScheduleParams::builder().build()?;
1919 rest.get_delist_schedule(params).await
1920 })
1921 })
1922 .await
1923 .map_err(|e| classify_rest_query_error(&e, None))?;
1924 let schedule = response.data().await.map_err(response_decode_error)?;
1925
1926 let symbol = instrument.name().as_str();
1927 let (base, quote) = (pair.base.name().as_str(), pair.quote.name().as_str());
1928 let mut earliest: Option<DateTime<Utc>> = None;
1929 for entry in schedule {
1930 let scheduled = if self.config.is_isolated {
1931 entry
1932 .isolated_margin_symbols
1933 .iter()
1934 .flatten()
1935 .any(|listed| listed == symbol)
1936 } else {
1937 entry
1938 .cross_margin_assets
1939 .iter()
1940 .flatten()
1941 .any(|listed| listed == base || listed == quote)
1942 };
1943 if !scheduled {
1944 continue;
1945 }
1946 let delisting_at = entry
1948 .delist_time
1949 .and_then(DateTime::from_timestamp_millis)
1950 .ok_or_else(|| {
1951 UnindexedClientError::Internal(format!(
1952 "decoding Binance response: delist-schedule lists {symbol} with delistTime \
1953 {:?}",
1954 entry.delist_time
1955 ))
1956 })?;
1957 earliest = Some(earliest.map_or(delisting_at, |earliest| earliest.min(delisting_at)));
1958 }
1959 Ok(earliest)
1960 }
1961
1962 async fn fetch_next_hourly_interest_rate(
1966 &self,
1967 asset: &AssetNameExchange,
1968 ) -> Result<Option<Decimal>, UnindexedClientError> {
1969 let name = asset.name().to_string();
1970 let is_isolated = self.config.is_isolated;
1971 let response =
1972 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1973 let name = name.clone();
1974 Box::pin(async move {
1975 let params = GetFutureHourlyInterestRateParams::builder(
1976 name,
1977 IsolatedFlag::from_flag(is_isolated),
1978 )
1979 .build()?;
1980 rest.get_future_hourly_interest_rate(params).await
1981 })
1982 })
1983 .await
1984 .map_err(|e| classify_rest_query_error(&e, None))?;
1985 let rate = response
1986 .data()
1987 .await
1988 .map_err(response_decode_error)?
1989 .into_iter()
1990 .find(|listed| listed.asset.as_deref() == Some(name.as_str()))
1991 .and_then(|listed| listed.next_hourly_interest_rate);
1992 optional_decimal("nextHourlyInterestRate", rate.as_deref())
1993 }
1994}
1995
1996fn optional_decimal(
1999 field: &str,
2000 value: Option<&str>,
2001) -> Result<Option<Decimal>, UnindexedClientError> {
2002 value
2003 .map(|value| {
2004 Decimal::from_str(value).map_err(|error| {
2005 UnindexedClientError::Internal(format!(
2006 "decoding Binance response: {field} {value:?}: {error}"
2007 ))
2008 })
2009 })
2010 .transpose()
2011}
2012
2013const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
2021
2022struct UserListenToken {
2024 token: String,
2025 expiration_time_ms: i64,
2026}
2027
2028fn build_listen_token_query(
2032 symbol: Option<&InstrumentNameExchange>,
2033) -> BTreeMap<String, serde_json::Value> {
2034 let mut query = BTreeMap::new();
2035 if let Some(sym) = symbol {
2036 query.insert(
2037 "isIsolated".to_string(),
2038 serde_json::Value::String("TRUE".to_string()),
2039 );
2040 query.insert(
2041 "symbol".to_string(),
2042 serde_json::Value::String(sym.name().to_string()),
2043 );
2044 }
2045 query
2046}
2047
2048#[derive(Deserialize)]
2050struct UserListenTokenResponse {
2051 #[serde(alias = "listenToken")]
2053 token: String,
2054 #[serde(rename = "expirationTime")]
2055 expiration_time: i64,
2056}
2057
2058async fn acquire_user_listen_token(
2073 rest_config: &Arc<ConfigurationRestApi>,
2074 rate_limiter: &RateLimitTracker,
2075 symbol: Option<&InstrumentNameExchange>,
2076) -> Result<UserListenToken, UnindexedClientError> {
2077 let query = build_listen_token_query(symbol);
2080 let response = rest_call_with_retry(rest_config, rate_limiter, RequestKind::Essential, |cfg| {
2081 let query = query.clone();
2082 Box::pin(async move {
2083 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
2084 &cfg,
2085 "/sapi/v1/userListenToken",
2086 reqwest::Method::POST,
2087 query,
2090 BTreeMap::new(),
2091 None,
2092 true, )
2094 .await
2095 })
2096 })
2097 .await
2098 .map_err(|e| classify_rest_query_error(&e, symbol))?;
2099
2100 let data = response.data().await.map_err(response_decode_error)?;
2101 Ok(UserListenToken {
2102 token: data.token,
2103 expiration_time_ms: data.expiration_time,
2104 })
2105}
2106
2107fn token_renew_after(expiration_time_ms: i64) -> Duration {
2110 let now_ms = Utc::now().timestamp_millis();
2111 let remaining_secs = expiration_time_ms
2115 .saturating_sub(now_ms)
2116 .saturating_div(1_000)
2117 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
2118 let secs = u64::try_from(remaining_secs).unwrap_or(0);
2119 if secs < TOKEN_MIN_LIFETIME_SECS {
2122 warn!(
2123 computed_secs = secs,
2124 floor_secs = TOKEN_MIN_LIFETIME_SECS,
2125 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
2126 );
2127 }
2128 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
2129}
2130
2131async fn connect_margin_ws(
2138 ws_config: &ConfigurationWebsocketApi,
2139) -> anyhow::Result<DisconnectOnDrop<Arc<WsApiBase>>> {
2140 let ws = WsApiBase::new(ws_config.clone(), vec![]);
2141 tokio::time::timeout(
2144 Duration::from_secs(CONNECT_TIMEOUT_SECS),
2145 ws.clone().connect(),
2146 )
2147 .await
2148 .map_err(|_| {
2149 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
2150 })??;
2151 Ok(DisconnectOnDrop::new(ws, "BinanceMargin account stream"))
2153}
2154
2155async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
2162 let mut payload = BTreeMap::new();
2165 payload.insert(
2166 "listenToken".to_string(),
2167 serde_json::Value::String(token.to_string()),
2168 );
2169 ws.send_message::<serde_json::Value>(
2170 "userDataStream.subscribe.listenToken",
2171 payload,
2172 WebsocketMessageSendOptions::new(),
2173 )
2174 .await
2175 .map_err(subscribe_error)?;
2176 Ok(())
2177}
2178
2179fn subscribe_error(e: WebsocketError) -> anyhow::Error {
2182 let message = format!("userDataStream.subscribe.listenToken failed: {e}");
2183 anyhow::Error::new(e).context(message)
2184}
2185
2186async fn subscribe_listen_token_capture(
2199 ws: &Arc<WsApiBase>,
2200 token: &str,
2201) -> anyhow::Result<Option<i64>> {
2202 #[derive(Deserialize)]
2205 struct SubscribeAck {
2206 #[serde(rename = "subscriptionId", default)]
2207 subscription_id: Option<i64>,
2208 }
2209
2210 let mut payload = BTreeMap::new();
2211 payload.insert(
2212 "listenToken".to_string(),
2213 serde_json::Value::String(token.to_string()),
2214 );
2215 let result = ws
2216 .send_message::<SubscribeAck>(
2217 "userDataStream.subscribe.listenToken",
2218 payload,
2219 WebsocketMessageSendOptions::new(),
2220 )
2221 .await
2222 .map_err(subscribe_error)?;
2223 let ack = match result {
2224 SendWebsocketMessageResult::Single(resp) => resp.data()?,
2225 SendWebsocketMessageResult::Multiple(responses) => responses
2227 .into_iter()
2228 .next()
2229 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
2230 .data()?,
2231 };
2232 Ok(ack.subscription_id)
2233}
2234
2235fn cross_account_position_handler(
2239 position: OutboundAccountPosition,
2240 _subscription_id: Option<i64>,
2241 buf: &mut Vec<UnindexedAccountEvent>,
2242) {
2243 convert_margin_account_position(position, buf);
2244}
2245
2246fn cross_notice_instrument(_subscription_id: Option<i64>) -> Option<InstrumentNameExchange> {
2250 None
2251}
2252
2253fn isolated_notice_instrument(
2259 subscription_id: Option<i64>,
2260 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
2261) -> Option<InstrumentNameExchange> {
2262 let Some(sub_id) = subscription_id else {
2263 warn!(
2264 "BinanceMargin isolated: MARGIN_LEVEL_STATUS_CHANGE without subscriptionId — \
2265 delivering the notice without an instrument"
2266 );
2267 return None;
2268 };
2269 let instrument = sub_map
2270 .lock()
2271 .unwrap_or_else(|p| p.into_inner())
2272 .get(&sub_id)
2273 .cloned();
2274 if instrument.is_none() {
2275 warn!(
2276 subscription_id = sub_id,
2277 "BinanceMargin isolated: MARGIN_LEVEL_STATUS_CHANGE for unmapped subscriptionId — \
2278 delivering the notice without an instrument"
2279 );
2280 }
2281 instrument
2282}
2283
2284#[cfg(test)]
2290fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2291 matches!(
2292 convert_margin_user_data_events_with(
2293 frame,
2294 buf,
2295 &mut cross_account_position_handler,
2296 &mut cross_notice_instrument,
2297 ),
2298 FrameOutcome::Pushed { terminated: true }
2299 )
2300}
2301
2302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2304enum FrameOutcome {
2305 Response,
2307 Pushed { terminated: bool },
2309}
2310
2311fn convert_margin_user_data_events_with(
2329 frame: &str,
2330 buf: &mut Vec<UnindexedAccountEvent>,
2331 handle_position: &mut impl FnMut(
2332 OutboundAccountPosition,
2333 Option<i64>,
2334 &mut Vec<UnindexedAccountEvent>,
2335 ),
2336 notice_instrument: &mut impl FnMut(Option<i64>) -> Option<InstrumentNameExchange>,
2337) -> FrameOutcome {
2338 let (subscription_id, event_type, event_raw) = match parse_user_data_frame(frame) {
2339 UserDataFrame::Response => return FrameOutcome::Response,
2341 UserDataFrame::Event {
2342 subscription_id,
2343 event_type,
2344 event,
2345 } => (subscription_id, event_type, event),
2346 UserDataFrame::Unrecognised => {
2347 static SEEN: AtomicU64 = AtomicU64::new(0);
2348 log_unrecognised_frame("BinanceMargin", &SEEN, frame);
2349 return FrameOutcome::Pushed { terminated: false };
2350 }
2351 };
2352 let terminated = match event_type {
2353 "executionReport" => {
2354 match serde_json::from_str::<ExecutionReport>(event_raw) {
2357 Ok(report) => {
2358 debug!(
2362 symbol = report.symbol.as_deref().unwrap_or("?"),
2363 order_id = ?report.order_id,
2364 client_order_id = report.client_order_id.as_deref().unwrap_or("?"),
2365 order_type = report.order_type.as_deref().unwrap_or("?"),
2366 execution_type = report.execution_type.as_deref().unwrap_or("?"),
2367 order_status = report.order_status.as_deref().unwrap_or("?"),
2368 "BinanceMargin executionReport"
2369 );
2370 convert_execution_report(&report, ExchangeId::BinanceMargin, buf);
2371 }
2372 Err(e) => {
2373 warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable executionReport, dropping")
2374 }
2375 }
2376 false
2377 }
2378 "outboundAccountPosition" => {
2379 match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
2380 Ok(position) => handle_position(position, subscription_id, buf),
2382 Err(e) => {
2383 warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable outboundAccountPosition, dropping")
2384 }
2385 }
2386 false
2387 }
2388 "balanceUpdate" => {
2389 false
2393 }
2394 "USER_LIABILITY_CHANGE" | "userLiabilityChange" => {
2398 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
2405 Ok(c) => log_liability_change(&c, "user data"),
2406 Err(e) => warn!(
2407 error = %e,
2408 frame = frame_excerpt(event_raw),
2409 "BinanceMargin: undeserializable USER_LIABILITY_CHANGE, dropping"
2410 ),
2411 }
2412 false
2413 }
2414 "MARGIN_LEVEL_STATUS_CHANGE" | "marginLevelStatusChange" => {
2415 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
2418 Ok(change) => {
2419 log_margin_level_change(&change, "user data");
2420 buf.extend(margin_level_notice(
2421 change,
2422 notice_instrument(subscription_id),
2423 ));
2424 }
2425 Err(e) => warn!(
2426 error = %e,
2427 frame = frame_excerpt(event_raw),
2428 "BinanceMargin: undeserializable MARGIN_LEVEL_STATUS_CHANGE, dropping"
2429 ),
2430 }
2431 false
2432 }
2433 "eventStreamTerminated" => {
2434 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
2435 true
2436 }
2437 "listStatus" => {
2438 trace!("BinanceMargin ignoring listStatus user data event");
2441 false
2442 }
2443 other => {
2444 static SEEN: UnhandledEvents = UnhandledEvents::new();
2445 log_unhandled_event("BinanceMargin", &SEEN, other, event_raw);
2446 false
2447 }
2448 };
2449 FrameOutcome::Pushed { terminated }
2450}
2451
2452fn convert_margin_account_position(
2457 position: OutboundAccountPosition,
2458 buf: &mut Vec<UnindexedAccountEvent>,
2459) {
2460 let time_exchange = position
2461 .u
2462 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2463 .unwrap_or_else(Utc::now);
2464
2465 for b in position.b_uppercase.unwrap_or_default() {
2466 let asset = match b.a {
2467 Some(a) => AssetNameExchange::new(a),
2468 None => {
2469 warn!("BinanceMargin account position entry missing asset name");
2470 continue;
2471 }
2472 };
2473 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2474 Some(v) => v,
2475 None => {
2476 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
2477 continue;
2478 }
2479 };
2480 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2481 Some(v) => v,
2482 None => {
2483 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
2484 continue;
2485 }
2486 };
2487 let update =
2488 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2489 buf.push(UnindexedAccountEvent::new(
2490 ExchangeId::BinanceMargin,
2491 AccountEventKind::BalanceStreamUpdate(
2492 rustrade_integration::collection::snapshot::Snapshot::new(update),
2493 ),
2494 ));
2495 }
2496}
2497
2498fn route_isolated_account_position(
2514 position: OutboundAccountPosition,
2515 subscription_id: Option<i64>,
2516 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
2517 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
2518 buf: &mut Vec<UnindexedAccountEvent>,
2519) {
2520 let Some(sub_id) = subscription_id else {
2521 warn!(
2522 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
2523 (per-instrument balance routing requires it)"
2524 );
2525 return;
2526 };
2527 let instrument = {
2528 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
2531 match map.get(&sub_id) {
2532 Some(inst) => inst.clone(),
2533 None => {
2534 warn!(
2535 subscription_id = sub_id,
2536 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
2537 );
2538 return;
2539 }
2540 }
2541 };
2542 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
2543 warn!(
2544 instrument = %instrument.name(),
2545 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
2546 );
2547 return;
2548 };
2549
2550 let time_exchange = position
2551 .u
2552 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2553 .unwrap_or_else(Utc::now);
2554
2555 for b in position.b_uppercase.unwrap_or_default() {
2556 let Some(asset) = b.a.as_deref() else {
2557 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
2558 continue;
2559 };
2560 if asset != base_asset.name().as_str() && asset != quote_asset.name().as_str() {
2562 debug!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position: skipping asset outside the pair");
2563 continue;
2564 }
2565 let (Some(free), Some(locked)) = (
2566 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2567 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2568 ) else {
2569 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
2570 continue;
2571 };
2572 buf.push(UnindexedAccountEvent::new(
2573 ExchangeId::BinanceMargin,
2574 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
2575 instrument.clone(),
2576 AssetBalanceUpdate::new(
2577 AssetNameExchange::new(asset),
2578 BalanceUpdate::new(free, locked),
2579 time_exchange,
2580 ),
2581 )),
2582 ));
2583 }
2584}
2585
2586fn register_user_data_listener(
2599 ws: &Arc<WsApiBase>,
2600 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2601 dedup: SharedDedupCache,
2602 known: SharedKnownLiveOrders,
2603 heartbeat_flag: Arc<AtomicBool>,
2604 signal_tx: oneshot::Sender<()>,
2605 mut handle_position: impl FnMut(
2606 OutboundAccountPosition,
2607 Option<i64>,
2608 &mut Vec<UnindexedAccountEvent>,
2609 ) + Send
2610 + 'static,
2611 mut notice_instrument: impl FnMut(Option<i64>) -> Option<InstrumentNameExchange> + Send + 'static,
2612) -> UnsubscribeOnDrop {
2613 let mut signal_tx_opt = Some(signal_tx);
2614 let mut event_tx = Some(tx);
2615 let mut event_buf = Vec::with_capacity(32);
2619
2620 UnsubscribeOnDrop::new(ws.common.events.subscribe(move |event| {
2624 let Some(ref sender) = event_tx else { return };
2625 match event {
2626 WebsocketEvent::Message(json_str) => {
2627 let terminated = match convert_margin_user_data_events_with(
2630 &json_str,
2631 &mut event_buf,
2632 &mut handle_position,
2633 &mut notice_instrument,
2634 ) {
2635 FrameOutcome::Response => return,
2638 FrameOutcome::Pushed { terminated } => {
2639 heartbeat_flag.store(true, Ordering::Release);
2640 terminated
2641 }
2642 };
2643 for ev in event_buf.drain(..) {
2644 if let Some(key) = dedup_key_from_event(&ev)
2645 && is_duplicate(&dedup, key)
2646 {
2647 trace!("BinanceMargin dedup: skipping duplicate event");
2648 continue;
2649 }
2650 let held = KnownLiveOrders::observes(&ev.kind).then(|| {
2654 let mut held = known.lock();
2655 held.observe(&ev);
2656 held
2657 });
2658 if sender.send(ev).is_err() {
2659 drop(held);
2660 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
2661 event_tx.take();
2662 if let Some(s) = signal_tx_opt.take() {
2663 let _ = s.send(());
2664 }
2665 return;
2666 }
2667 }
2668 if terminated {
2669 event_tx.take();
2670 if let Some(s) = signal_tx_opt.take() {
2671 let _ = s.send(());
2672 }
2673 }
2674 }
2675 WebsocketEvent::Ping | WebsocketEvent::Pong => {
2676 heartbeat_flag.store(true, Ordering::Release);
2677 }
2678 WebsocketEvent::Error(e) => {
2679 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
2680 event_tx.take();
2681 if let Some(s) = signal_tx_opt.take() {
2682 let _ = s.send(());
2683 }
2684 }
2685 WebsocketEvent::Close(code, reason) => {
2686 warn!(code, %reason, "BinanceMargin WebSocket closed");
2687 event_tx.take();
2688 if let Some(s) = signal_tx_opt.take() {
2689 let _ = s.send(());
2690 }
2691 }
2692 _ => {
2693 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
2694 }
2695 }
2696 }))
2697}
2698
2699async fn recover_margin_fills(
2721 rest: &Arc<RestApi>,
2722 rate_limiter: &Arc<RateLimitTracker>,
2723 unrecovered: &mut UnrecoveredFills,
2724 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2725 dedup: &SharedDedupCache,
2726 known: &SharedKnownLiveOrders,
2727 is_isolated: bool,
2728) {
2729 use futures::StreamExt as _;
2730
2731 let due = unrecovered.due(tokio::time::Instant::now());
2732 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2733 return;
2734 };
2735 info!(
2736 gaps = due.len(),
2737 oldest = %gap_time(oldest),
2738 is_isolated,
2739 "BinanceMargin recovering fills missed while disconnected"
2740 );
2741
2742 let mut settled = vec![false; due.len()];
2745 let started = AtomicUsize::new(0);
2746 let recovery = async {
2747 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2748 let mut recovered = 0u32;
2749 let mut duplicates = 0u32;
2750
2751 let mut stream =
2752 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2753 started.store(index + 1, Ordering::Relaxed);
2754 let rest = rest.clone();
2755 let rl = rate_limiter.clone();
2756 async move {
2757 let span = MyTradesFrom::Span {
2758 start: gap.start_ms,
2759 end: gap.end_ms,
2760 };
2761 let raw = match paginate_margin_my_trades(
2762 &rest,
2763 &rl,
2764 &inst,
2765 span,
2766 is_isolated,
2767 RequestKind::Essential,
2768 )
2769 .await
2770 {
2771 Ok(pages) => pages,
2772 Err(e) => return (index, Err(e)),
2773 };
2774 let totals = recovered_order_totals(
2778 ExchangeId::BinanceMargin,
2779 &inst,
2780 &raw,
2781 order_executions_deadline,
2782 |order_id| {
2783 paginate_margin_my_trades(
2784 &rest,
2785 &rl,
2786 &inst,
2787 MyTradesFrom::Order(order_id),
2788 is_isolated,
2789 RequestKind::Essential,
2790 )
2791 },
2792 )
2793 .await;
2794 let cids = recovered_order_cids(
2797 ExchangeId::BinanceMargin,
2798 &inst,
2799 &raw,
2800 order_executions_deadline,
2801 |order_id| fetch_margin_order_cid(&rest, &rl, &inst, order_id, is_isolated),
2802 )
2803 .await;
2804 let trades = raw
2805 .iter()
2806 .filter_map(|t| {
2807 let mut trade = convert_margin_trade(t, &inst)?;
2808 trade.order_filled_quantity =
2809 t.id.and_then(|id| totals.get(&id).copied());
2810 trade.client_order_id =
2811 t.order_id.and_then(|id| cids.get(&id).cloned());
2812 Some(trade)
2813 })
2814 .collect::<Vec<_>>();
2815 (index, Ok(trades))
2816 }
2817 }))
2818 .buffer_unordered(8);
2819 while let Some((index, result)) = stream.next().await {
2820 let (inst, gap) = &due[index];
2821 settled[index] = true;
2822 let trades = match result {
2823 Ok(trades) => trades,
2824 Err(e) => {
2825 gap_failed(
2826 unrecovered,
2827 tx,
2828 ExchangeId::BinanceMargin,
2829 inst,
2830 gap,
2831 FillRecoveryFailure::Request(e.to_string()),
2832 );
2833 continue;
2834 }
2835 };
2836 for trade in trades {
2837 let event = UnindexedAccountEvent::new(
2838 ExchangeId::BinanceMargin,
2839 AccountEventKind::Trade(trade),
2840 );
2841 if let Some(key) = dedup_key_from_event(&event)
2842 && is_duplicate(dedup, key)
2843 {
2844 duplicates += 1;
2845 continue;
2846 }
2847 let sent = {
2850 let mut known = known.lock();
2851 known.observe(&event);
2852 tx.send(event)
2853 };
2854 if sent.is_err() {
2855 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2856 return;
2857 }
2858 recovered += 1;
2859 }
2860 unrecovered.recovered(inst, gap);
2861 }
2862 info!(
2863 recovered,
2864 duplicates, is_isolated, "BinanceMargin fill recovery complete"
2865 );
2866 };
2867 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2871 .await
2872 .is_err()
2873 {
2874 let started = started.load(Ordering::Relaxed);
2875 for ((inst, gap), _) in due[..started]
2876 .iter()
2877 .zip(&settled)
2878 .filter(|(_, settled)| !**settled)
2879 {
2880 gap_failed(
2881 unrecovered,
2882 tx,
2883 ExchangeId::BinanceMargin,
2884 inst,
2885 gap,
2886 FillRecoveryFailure::TimedOut {
2887 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2888 },
2889 );
2890 }
2891 }
2892}
2893
2894fn open_reconnect_recovery(
2899 unrecovered: &mut UnrecoveredFills,
2900 unchecked: &mut UncheckedOrders,
2901 known: &SharedKnownLiveOrders,
2902 instruments: &[InstrumentNameExchange],
2903 disconnected: DateTime<Utc>,
2904) {
2905 unrecovered.open(instruments, disconnected, Utc::now());
2906 let held = known.lock().instruments_among(instruments);
2907 unchecked.open(held);
2908}
2909
2910#[allow(
2918 clippy::cognitive_complexity,
2919 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2920 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2921)]
2922async fn margin_connection_manager(
2923 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2924 dedup: SharedDedupCache,
2925 ws_config: ConfigurationWebsocketApi,
2926 rest_config: Arc<ConfigurationRestApi>,
2927 rest: Arc<RestApi>,
2928 rate_limiter: Arc<RateLimitTracker>,
2929 known: SharedKnownLiveOrders,
2930 instruments: Vec<InstrumentNameExchange>,
2931 initial: Option<(DisconnectOnDrop<Arc<WsApiBase>>, UserListenToken)>,
2932) {
2933 enum DisconnectReason {
2934 Signal,
2935 HeartbeatTimeout,
2936 TokenRefresh,
2937 ConsumerDropped,
2938 }
2939
2940 let mut budget = ReconnectBudget::new("Binance", BackoffLimits::ACCOUNT_STREAM);
2941 let mut disconnect_time: Option<DateTime<Utc>> = None;
2942 let mut unrecovered = UnrecoveredFills::default();
2944 let mut unchecked = UncheckedOrders::default();
2946 let (mut current_ws, mut current_token) = match initial {
2947 Some((ws, token)) => (Some(ws), Some(token)),
2948 None => (None, None),
2949 };
2950
2951 loop {
2952 let token = match current_token.take() {
2954 Some(t) => t,
2955 None => {
2956 if let Err(reason) = rate_limiter.before_stream_connect().await {
2959 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
2960 break;
2961 }
2962 match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
2964 Ok(t) => t,
2965 Err(e) => {
2966 error!(%e, "BinanceMargin userListenToken acquisition failed");
2967 if let Err(reason) =
2968 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
2969 {
2970 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
2971 break;
2972 }
2973 continue;
2974 }
2975 }
2976 }
2977 };
2978
2979 let ws = match current_ws.take() {
2981 Some(ws) => ws,
2982 None => match connect_margin_ws(&ws_config).await {
2983 Ok(ws) => ws,
2984 Err(e) => {
2985 error!(%e, "BinanceMargin WS connect failed");
2986 rate_limiter.on_stream_refused(&e);
2987 if let Err(reason) =
2988 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
2989 {
2990 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
2991 break;
2992 }
2993 continue;
2994 }
2995 },
2996 };
2997
2998 let (signal_tx, signal_rx) = oneshot::channel::<()>();
3000 let heartbeat_flag = Arc::new(AtomicBool::new(false));
3003 let subscription = register_user_data_listener(
3006 &ws,
3007 tx.clone(),
3008 dedup.clone(),
3009 known.clone(),
3010 heartbeat_flag.clone(),
3011 signal_tx,
3012 cross_account_position_handler,
3013 cross_notice_instrument,
3014 );
3015
3016 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
3018 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
3019 subscription.unsubscribe();
3020 if let Err(de) = ws.disconnect().await {
3021 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
3022 }
3023 rate_limiter.on_stream_refused(&e);
3024 if let Err(reason) = retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await {
3026 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3027 break;
3028 }
3029 continue;
3030 }
3031 info!("BinanceMargin account_stream connected and subscribed");
3032 let token_deadline =
3039 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
3040
3041 if let Some(dt) = disconnect_time.take() {
3046 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &instruments, dt);
3047 }
3048 recover_margin_fills(
3051 &rest,
3052 &rate_limiter,
3053 &mut unrecovered,
3054 &tx,
3055 &dedup,
3056 &known,
3057 false,
3058 )
3059 .await;
3060 recover_margin_ended_orders(
3061 &rest,
3062 &rate_limiter,
3063 &known,
3064 &mut unchecked,
3065 &unrecovered,
3066 &tx,
3067 false,
3068 )
3069 .await;
3070
3071 let reason = {
3073 let mut signal_rx = signal_rx;
3074 let retry_gaps = async {
3080 loop {
3081 let next_check = unchecked.next_due(&unrecovered);
3082 match unrecovered.next_due().into_iter().chain(next_check).min() {
3083 Some(due) => tokio::time::sleep_until(due).await,
3084 None => std::future::pending::<()>().await,
3085 }
3086 recover_margin_fills(
3087 &rest,
3088 &rate_limiter,
3089 &mut unrecovered,
3090 &tx,
3091 &dedup,
3092 &known,
3093 false,
3094 )
3095 .await;
3096 recover_margin_ended_orders(
3097 &rest,
3098 &rate_limiter,
3099 &known,
3100 &mut unchecked,
3101 &unrecovered,
3102 &tx,
3103 false,
3104 )
3105 .await;
3106 }
3107 };
3108 tokio::pin!(retry_gaps);
3109 let mut grace = true;
3111 loop {
3112 tokio::select! {
3113 biased;
3116 _ = tx.closed() => {
3117 debug!("BinanceMargin account_stream consumer dropped, terminating");
3118 break DisconnectReason::ConsumerDropped;
3119 }
3120 _ = &mut signal_rx => {
3121 warn!("BinanceMargin WS disconnected, will attempt reconnect");
3122 break DisconnectReason::Signal;
3123 }
3124 () = tokio::time::sleep_until(token_deadline) => {
3125 info!("BinanceMargin userListenToken nearing expiry, renewing");
3126 break DisconnectReason::TokenRefresh;
3127 }
3128 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
3129 let in_grace = std::mem::take(&mut grace);
3130 if heartbeat_flag.swap(false, Ordering::AcqRel) {
3131 budget.reset();
3134 continue;
3135 }
3136 if in_grace {
3137 continue;
3140 }
3141 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
3142 break DisconnectReason::HeartbeatTimeout;
3143 }
3144 () = &mut retry_gaps => {}
3146 }
3147 }
3148 };
3149 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
3150
3151 if should_reconnect {
3152 disconnect_time = Some(match reason {
3153 DisconnectReason::HeartbeatTimeout => {
3154 Utc::now()
3155 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
3156 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3157 }
3158 DisconnectReason::TokenRefresh => {
3159 Utc::now()
3164 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
3165 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3166 }
3167 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
3168 });
3169 }
3170
3171 subscription.unsubscribe();
3173 if let Err(e) = ws.disconnect().await {
3174 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
3175 }
3176
3177 if !should_reconnect || tx.is_closed() {
3178 debug!("BinanceMargin connection manager exiting");
3180 break;
3181 }
3182
3183 if !matches!(reason, DisconnectReason::TokenRefresh) {
3186 let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
3189 let last_error = || disconnect_error(heartbeat_timed_out);
3190 if let Err(reason) = retry_or_end(&mut budget, &rate_limiter, last_error).await {
3191 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3192 break;
3193 }
3194 }
3195 }
3196}
3197
3198fn build_base_quote_map(
3210 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
3211) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
3212 let mut map = HashMap::with_capacity(assets.len());
3213 for entry in assets {
3214 let (Some(symbol), Some(base), Some(quote)) = (
3215 entry.symbol.as_deref(),
3216 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
3217 entry
3218 .quote_asset
3219 .as_deref()
3220 .and_then(|q| q.asset.as_deref()),
3221 ) else {
3222 warn!(
3223 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
3224 skipping base/quote map entry"
3225 );
3226 continue;
3227 };
3228 map.insert(
3229 InstrumentNameExchange::new(symbol),
3230 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
3231 );
3232 }
3233 map
3234}
3235
3236async fn acquire_all_isolated_tokens(
3249 rest_config: &Arc<ConfigurationRestApi>,
3250 rate_limiter: &Arc<RateLimitTracker>,
3251 symbols: &[InstrumentNameExchange],
3252) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
3253 use futures::{StreamExt as _, TryStreamExt as _};
3254
3255 futures::stream::iter(symbols.iter().cloned().map(|sym| {
3256 let rest_config = rest_config.clone();
3257 let rate_limiter = rate_limiter.clone();
3258 async move {
3259 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
3260 Ok::<_, UnindexedClientError>((sym, token))
3261 }
3262 }))
3263 .buffer_unordered(8)
3264 .try_collect()
3265 .await
3266}
3267
3268fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
3273 tokens
3274 .iter()
3275 .map(|(_, t)| t.expiration_time_ms)
3276 .min()
3277 .unwrap_or(i64::MAX)
3278}
3279
3280struct IsolatedLiveConn {
3283 ws: DisconnectOnDrop<Arc<WsApiBase>>,
3284 subscription: UnsubscribeOnDrop,
3285 signal_rx: oneshot::Receiver<()>,
3286 heartbeat_flag: Arc<AtomicBool>,
3287 earliest_expiry_ms: i64,
3290}
3291
3292async fn isolated_connect_and_subscribe(
3302 ws_config: &ConfigurationWebsocketApi,
3303 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3304 dedup: &SharedDedupCache,
3305 known: &SharedKnownLiveOrders,
3306 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
3307 tokens: &[(InstrumentNameExchange, UserListenToken)],
3308) -> anyhow::Result<IsolatedLiveConn> {
3309 let ws = connect_margin_ws(ws_config).await?;
3310 let sub_map = Arc::new(Mutex::new(
3316 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
3317 ));
3318 let (signal_tx, signal_rx) = oneshot::channel::<()>();
3319 let heartbeat_flag = Arc::new(AtomicBool::new(false));
3321
3322 let handle_position = {
3323 let sub_map = sub_map.clone();
3324 let base_quote = base_quote.clone();
3325 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
3326 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
3327 }
3328 };
3329 let notice_instrument = {
3330 let sub_map = sub_map.clone();
3331 move |subscription_id| isolated_notice_instrument(subscription_id, &sub_map)
3332 };
3333 let subscription = register_user_data_listener(
3335 &ws,
3336 tx.clone(),
3337 dedup.clone(),
3338 known.clone(),
3339 heartbeat_flag.clone(),
3340 signal_tx,
3341 handle_position,
3342 notice_instrument,
3343 );
3344
3345 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
3346 for (sym, token) in tokens {
3347 match subscribe_listen_token_capture(&ws, &token.token).await {
3348 Ok(Some(id)) => {
3349 sub_map
3350 .lock()
3351 .unwrap_or_else(|p| p.into_inner())
3352 .insert(id, sym.clone());
3353 }
3354 Ok(None) => warn!(
3355 symbol = %sym.name(),
3356 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
3357 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
3358 ),
3359 Err(e) => {
3360 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
3363 subscription.unsubscribe();
3364 if let Err(de) = ws.disconnect().await {
3365 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
3366 }
3367 return Err(e);
3368 }
3369 }
3370 }
3371
3372 Ok(IsolatedLiveConn {
3373 ws,
3374 subscription,
3375 signal_rx,
3376 heartbeat_flag,
3377 earliest_expiry_ms,
3378 })
3379}
3380
3381#[allow(
3390 clippy::cognitive_complexity,
3391 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
3392 + backoff); mirrors the cross manager, not worth splitting further"
3393)]
3394async fn isolated_connection_manager(
3395 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
3396 dedup: SharedDedupCache,
3397 ws_config: ConfigurationWebsocketApi,
3398 rest_config: Arc<ConfigurationRestApi>,
3399 rest: Arc<RestApi>,
3400 rate_limiter: Arc<RateLimitTracker>,
3401 known: SharedKnownLiveOrders,
3402 symbols: Vec<InstrumentNameExchange>,
3403 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
3404 initial: Option<IsolatedLiveConn>,
3405) {
3406 enum DisconnectReason {
3407 Signal,
3408 HeartbeatTimeout,
3409 TokenRefresh,
3410 ConsumerDropped,
3411 }
3412
3413 let mut budget = ReconnectBudget::new("Binance", BackoffLimits::ACCOUNT_STREAM);
3414 let mut disconnect_time: Option<DateTime<Utc>> = None;
3415 let mut unrecovered = UnrecoveredFills::default();
3417 let mut unchecked = UncheckedOrders::default();
3419 let mut current = initial;
3420
3421 loop {
3422 let IsolatedLiveConn {
3424 ws,
3425 subscription,
3426 signal_rx,
3427 heartbeat_flag,
3428 earliest_expiry_ms,
3429 } = match current.take() {
3430 Some(live) => live,
3432 None => {
3433 if let Err(reason) = rate_limiter.before_stream_connect().await {
3435 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3436 break;
3437 }
3438 let tokens = match acquire_all_isolated_tokens(
3441 &rest_config,
3442 &rate_limiter,
3443 &symbols,
3444 )
3445 .await
3446 {
3447 Ok(t) => t,
3448 Err(e) => {
3449 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
3450 if let Err(reason) =
3451 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
3452 {
3453 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3454 break;
3455 }
3456 continue;
3457 }
3458 };
3459 match isolated_connect_and_subscribe(
3460 &ws_config,
3461 &tx,
3462 &dedup,
3463 &known,
3464 &base_quote,
3465 &tokens,
3466 )
3467 .await
3468 {
3469 Ok(live) => live,
3470 Err(e) => {
3471 error!(%e, "BinanceMargin isolated connect/subscribe failed");
3472 rate_limiter.on_stream_refused(&e);
3473 if let Err(reason) =
3474 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
3475 {
3476 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3477 break;
3478 }
3479 continue;
3480 }
3481 }
3482 }
3483 };
3484
3485 info!(
3486 symbols = symbols.len(),
3487 "BinanceMargin isolated account_stream connected and subscribed"
3488 );
3489 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
3495
3496 if let Some(dt) = disconnect_time.take() {
3499 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &symbols, dt);
3500 }
3501 recover_margin_fills(
3503 &rest,
3504 &rate_limiter,
3505 &mut unrecovered,
3506 &tx,
3507 &dedup,
3508 &known,
3509 true,
3510 )
3511 .await;
3512 recover_margin_ended_orders(
3513 &rest,
3514 &rate_limiter,
3515 &known,
3516 &mut unchecked,
3517 &unrecovered,
3518 &tx,
3519 true,
3520 )
3521 .await;
3522
3523 let reason = {
3526 let mut signal_rx = signal_rx;
3527 let retry_gaps = async {
3530 loop {
3531 let next_check = unchecked.next_due(&unrecovered);
3532 match unrecovered.next_due().into_iter().chain(next_check).min() {
3533 Some(due) => tokio::time::sleep_until(due).await,
3534 None => std::future::pending::<()>().await,
3535 }
3536 recover_margin_fills(
3537 &rest,
3538 &rate_limiter,
3539 &mut unrecovered,
3540 &tx,
3541 &dedup,
3542 &known,
3543 true,
3544 )
3545 .await;
3546 recover_margin_ended_orders(
3547 &rest,
3548 &rate_limiter,
3549 &known,
3550 &mut unchecked,
3551 &unrecovered,
3552 &tx,
3553 true,
3554 )
3555 .await;
3556 }
3557 };
3558 tokio::pin!(retry_gaps);
3559 let mut grace = true;
3561 loop {
3562 tokio::select! {
3563 biased;
3564 _ = tx.closed() => {
3565 debug!("BinanceMargin isolated consumer dropped, terminating");
3566 break DisconnectReason::ConsumerDropped;
3567 }
3568 _ = &mut signal_rx => {
3569 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
3570 break DisconnectReason::Signal;
3571 }
3572 () = tokio::time::sleep_until(token_deadline) => {
3573 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
3574 break DisconnectReason::TokenRefresh;
3575 }
3576 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
3577 let in_grace = std::mem::take(&mut grace);
3578 if heartbeat_flag.swap(false, Ordering::AcqRel) {
3579 budget.reset();
3581 continue;
3582 }
3583 if in_grace {
3584 continue;
3586 }
3587 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
3588 break DisconnectReason::HeartbeatTimeout;
3589 }
3590 () = &mut retry_gaps => {}
3592 }
3593 }
3594 };
3595 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
3596
3597 if should_reconnect {
3598 disconnect_time = Some(match reason {
3599 DisconnectReason::HeartbeatTimeout => {
3600 Utc::now()
3601 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
3602 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3603 }
3604 DisconnectReason::TokenRefresh => {
3605 Utc::now()
3609 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
3610 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3611 }
3612 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
3613 });
3614 }
3615
3616 subscription.unsubscribe();
3618 if let Err(e) = ws.disconnect().await {
3619 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
3620 }
3621
3622 if !should_reconnect || tx.is_closed() {
3623 debug!("BinanceMargin isolated connection manager exiting");
3625 break;
3626 }
3627
3628 if !matches!(reason, DisconnectReason::TokenRefresh) {
3631 let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
3634 let last_error = || disconnect_error(heartbeat_timed_out);
3635 if let Err(reason) = retry_or_end(&mut budget, &rate_limiter, last_error).await {
3636 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3637 break;
3638 }
3639 }
3640 }
3641}
3642
3643async fn fetch_margin_open_orders_for_instrument(
3650 rest: Arc<RestApi>,
3651 rate_limiter: Arc<RateLimitTracker>,
3652 instrument: InstrumentNameExchange,
3653 is_isolated: bool,
3654 kind: RequestKind,
3655) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
3656 let symbol_str = instrument.name().to_string();
3658 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3659 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3660 let sym = symbol_str.clone();
3661 let isolated = isolated.clone();
3662 Box::pin(async move {
3663 let params = QueryMarginAccountsOpenOrdersParams::builder()
3664 .symbol(sym)
3665 .is_isolated(isolated)
3666 .build()?;
3667 rest.query_margin_accounts_open_orders(params).await
3668 })
3669 })
3670 .await
3671 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
3672
3673 let orders_data = response.data().await.map_err(response_decode_error)?;
3674
3675 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceMargin, &instrument);
3676
3677 Ok((instrument, listing))
3678}
3679
3680async fn fetch_margin_all_open_orders(
3687 rest: Arc<RestApi>,
3688 rate_limiter: Arc<RateLimitTracker>,
3689 is_isolated: bool,
3690) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
3691 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3692 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3693 let isolated = isolated.clone();
3694 Box::pin(async move {
3695 let params = QueryMarginAccountsOpenOrdersParams::builder()
3696 .is_isolated(isolated)
3697 .build()?;
3698 rest.query_margin_accounts_open_orders(params).await
3699 })
3700 })
3701 .await
3702 .map_err(|e| classify_rest_query_error(&e, None))?;
3703
3704 let orders_data = response.data().await.map_err(response_decode_error)?;
3705
3706 let orders = orders_data
3707 .into_iter()
3708 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin))
3709 .collect();
3710
3711 Ok(orders)
3712}
3713
3714async fn fetch_margin_order_lookup(
3721 rest: Arc<RestApi>,
3722 rate_limiter: Arc<RateLimitTracker>,
3723 key: UnindexedOrderKey,
3724 is_isolated: bool,
3725 kind: RequestKind,
3726 recorded_kind: Option<OrderKind>,
3727) -> Result<OrderLookup, UnindexedClientError> {
3728 let symbol = key.instrument.name().to_string();
3730 let cid = key.cid.0.to_string();
3731 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
3732 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3733 let (symbol, cid, isolated) = (symbol.clone(), cid.clone(), isolated.clone());
3734 Box::pin(async move {
3735 let params = QueryMarginAccountsOrderParams::builder(symbol)
3736 .orig_client_order_id(cid)
3737 .is_isolated(isolated)
3738 .build()?;
3739 rest.query_margin_accounts_order(params).await
3740 })
3741 })
3742 .await
3743 {
3744 Ok(response) => response,
3745 Err(e) if is_unknown_order(&e) => {
3746 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceMargin does not know this order");
3747 return Ok(OrderLookup::Unknown);
3748 }
3749 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
3750 };
3751
3752 let row = response.data().await.map_err(response_decode_error)?;
3753
3754 Ok(
3755 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, recorded_kind)
3756 .map_or(OrderLookup::NotEnded, |order| {
3757 OrderLookup::Ended(Box::new(order))
3758 }),
3759 )
3760}
3761
3762async fn fetch_margin_order_cid(
3767 rest: &Arc<RestApi>,
3768 rate_limiter: &RateLimitTracker,
3769 instrument: &InstrumentNameExchange,
3770 order_id: i64,
3771 is_isolated: bool,
3772) -> Result<Option<ClientOrderId>, UnindexedClientError> {
3773 let symbol = instrument.name().to_string();
3774 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
3775 let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
3776 let (symbol, isolated) = (symbol.clone(), isolated.clone());
3777 Box::pin(async move {
3778 let params = QueryMarginAccountsOrderParams::builder(symbol)
3779 .order_id(order_id)
3780 .is_isolated(isolated)
3781 .build()?;
3782 rest.query_margin_accounts_order(params).await
3783 })
3784 })
3785 .await
3786 {
3787 Ok(response) => response,
3788 Err(e) if is_unknown_order(&e) => {
3789 debug!(%instrument, order_id, error = %e, "BinanceMargin does not know this order");
3790 return Ok(None);
3791 }
3792 Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
3793 };
3794 let row = response.data().await.map_err(response_decode_error)?;
3795 Ok(row
3796 .client_order_id
3797 .filter(|cid| !cid.is_empty())
3798 .map(ClientOrderId::new))
3799}
3800
3801async fn listed_margin_open_cids(
3804 rest: Arc<RestApi>,
3805 rate_limiter: Arc<RateLimitTracker>,
3806 instruments: Vec<InstrumentNameExchange>,
3807 is_isolated: bool,
3808) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3809 let mut listed = FnvHashSet::default();
3810 for instrument in instruments {
3811 let (_, listing) = fetch_margin_open_orders_for_instrument(
3812 rest.clone(),
3813 rate_limiter.clone(),
3814 instrument,
3815 is_isolated,
3816 RequestKind::Essential,
3817 )
3818 .await?;
3819 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
3820 }
3821 Ok(listed)
3822}
3823
3824async fn recover_margin_ended_orders(
3827 rest: &Arc<RestApi>,
3828 rate_limiter: &Arc<RateLimitTracker>,
3829 known: &SharedKnownLiveOrders,
3830 unchecked: &mut UncheckedOrders,
3831 unrecovered: &UnrecoveredFills,
3832 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3833 is_isolated: bool,
3834) {
3835 recover_ended_orders(
3836 ExchangeId::BinanceMargin,
3837 known,
3838 unchecked,
3839 unrecovered,
3840 tx,
3841 OpenListing::PerInstrument,
3842 |instruments| {
3843 listed_margin_open_cids(rest.clone(), rate_limiter.clone(), instruments, is_isolated)
3844 },
3845 |key| {
3846 let recorded_kind = known.lock().kind_of(&key.cid);
3848 fetch_margin_order_lookup(
3849 rest.clone(),
3850 rate_limiter.clone(),
3851 key,
3852 is_isolated,
3853 RequestKind::Essential,
3854 recorded_kind,
3855 )
3856 },
3857 )
3858 .await;
3859}
3860
3861const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
3867
3868#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3871enum MarginTradesQuery {
3872 Window { start: i64, end: i64 },
3874 FromId(i64),
3876 Order(i64),
3878 OrderFromId { order_id: i64, from_id: i64 },
3880}
3881
3882impl MarginTradesQuery {
3883 fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
3887 match from {
3888 MyTradesFrom::Span { start, .. } => {
3889 (start <= now_ms).then(|| Self::window(start, now_ms))
3890 }
3891 MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
3892 }
3893 }
3894
3895 fn window(start: i64, now_ms: i64) -> Self {
3897 Self::Window {
3898 start,
3899 end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
3900 }
3901 }
3902
3903 fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
3906 let full = page_len >= BINANCE_MAX_TRADES;
3907 let after_last = || last_id?.checked_add(1);
3908 match self {
3909 Self::Window { end, .. } if page_len == 0 => {
3914 (end < now_ms).then(|| Self::window(end, now_ms))
3915 }
3916 Self::Window { end, .. } if end >= now_ms && !full => None,
3918 Self::Window { .. } => after_last().map(Self::FromId),
3921 Self::FromId(_) if full => after_last().map(Self::FromId),
3922 Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
3923 after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
3924 }
3925 Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
3926 }
3927 }
3928}
3929
3930async fn paginate_margin_my_trades(
3952 rest: &Arc<RestApi>,
3953 rate_limiter: &Arc<RateLimitTracker>,
3954 instrument: &InstrumentNameExchange,
3955 from: MyTradesFrom,
3956 is_isolated: bool,
3957 kind: RequestKind,
3958) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
3959 let symbol_str = instrument.name().to_string();
3961 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
3962 #[allow(clippy::cast_possible_wrap)]
3967 let limit = BINANCE_MAX_TRADES as i64;
3968 let now_ms = match from {
3971 MyTradesFrom::Span { end, .. } => end.min(Utc::now().timestamp_millis()),
3972 MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
3973 };
3974 let mut all_pages = Vec::new();
3975 let mut requests = 0u32;
3976 let mut next = MarginTradesQuery::first(from, now_ms);
3977 while let Some(query) = next {
3978 requests += 1;
3979 let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
3980 let sym = symbol_str.clone();
3981 let isolated = isolated.clone();
3982 Box::pin(async move {
3983 let builder = QueryMarginAccountsTradeListParams::builder(sym)
3984 .is_isolated(isolated)
3985 .limit(limit);
3986 let params = match query {
3987 MarginTradesQuery::Window { start, end } => {
3988 builder.start_time(start).end_time(end)
3989 }
3990 MarginTradesQuery::FromId(id) => builder.from_id(id),
3991 MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
3992 MarginTradesQuery::OrderFromId { order_id, from_id } => {
3993 builder.order_id(order_id).from_id(from_id)
3994 }
3995 }
3996 .build()?;
3997 rest.query_margin_accounts_trade_list(params).await
3998 })
3999 })
4000 .await
4001 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
4002
4003 let mut page = response.data().await.map_err(response_decode_error)?;
4004
4005 let page_len = page.len();
4006 let last_id = page.last().and_then(|t| t.id);
4007 let past_end = match from {
4009 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
4010 MyTradesFrom::Order(_) => false,
4011 };
4012 all_pages.extend(page);
4013 if past_end {
4014 break;
4015 }
4016
4017 if page_len > 0 && last_id.is_none() {
4018 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
4019 break;
4020 }
4021 next = query.next(page_len, last_id, now_ms);
4022 }
4023 debug!(
4024 %instrument,
4025 requests,
4026 trades = all_pages.len(),
4027 "BinanceMargin paginate_my_trades: walk complete"
4028 );
4029 Ok(all_pages)
4030}
4031
4032fn convert_margin_balance_entry(
4042 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
4043 now: DateTime<Utc>,
4044) -> Option<AssetBalance<AssetNameExchange>> {
4045 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
4046 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4047 Some(v) => v,
4048 None => {
4049 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
4050 return None;
4051 }
4052 };
4053 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4054 Some(v) => v,
4055 None => {
4056 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
4057 return None;
4058 }
4059 };
4060 let borrowed = b
4065 .borrowed
4066 .as_deref()
4067 .and_then(|s| Decimal::from_str(s).ok())
4068 .unwrap_or(Decimal::ZERO);
4069 let interest = b
4070 .interest
4071 .as_deref()
4072 .and_then(|s| Decimal::from_str(s).ok())
4073 .unwrap_or(Decimal::ZERO);
4074 Some(AssetBalance::new(
4075 asset_name,
4076 Balance::new_margin(free + locked, free, borrowed, interest),
4077 now,
4078 ))
4079}
4080
4081fn filter_and_convert_margin_balances(
4084 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
4085 assets: &[AssetNameExchange],
4086) -> Vec<AssetBalance<AssetNameExchange>> {
4087 let now = Utc::now();
4088 if assets.is_empty() {
4090 return user_assets
4091 .into_iter()
4092 .filter_map(|b| convert_margin_balance_entry(b, now))
4093 .collect();
4094 }
4095 if assets.len() <= 16 {
4097 return user_assets
4098 .into_iter()
4099 .filter_map(|b| {
4100 let asset_name_str = b.asset.as_deref()?;
4101 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
4102 return None;
4103 }
4104 convert_margin_balance_entry(b, now)
4105 })
4106 .collect();
4107 }
4108 use std::collections::HashSet;
4109 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
4110 user_assets
4111 .into_iter()
4112 .filter_map(|b| {
4113 let asset_name_str = b.asset.as_deref()?;
4114 if !asset_set.contains(asset_name_str) {
4115 return None;
4116 }
4117 convert_margin_balance_entry(b, now)
4118 })
4119 .collect()
4120}
4121
4122fn active_order_snapshot(
4129 o: Order<ExchangeId, InstrumentNameExchange, Open>,
4130) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
4131{
4132 Order {
4133 key: o.key,
4134 side: o.side,
4135 price: o.price,
4136 quantity: o.quantity,
4137 kind: o.kind,
4138 time_in_force: o.time_in_force,
4139 state: OrderState::active(o.state),
4140 }
4141}
4142
4143fn convert_isolated_asset_balance(
4153 asset: Option<&str>,
4154 free: Option<&str>,
4155 locked: Option<&str>,
4156 borrowed: Option<&str>,
4157 interest: Option<&str>,
4158 now: DateTime<Utc>,
4159) -> Option<AssetBalance<AssetNameExchange>> {
4160 let asset_name = AssetNameExchange::new(asset?);
4161 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
4162 Some(v) => v,
4163 None => {
4164 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
4165 return None;
4166 }
4167 };
4168 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
4169 Some(v) => v,
4170 None => {
4171 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
4172 return None;
4173 }
4174 };
4175 let borrowed = borrowed
4176 .and_then(|s| Decimal::from_str(s).ok())
4177 .unwrap_or(Decimal::ZERO);
4178 let interest = interest
4179 .and_then(|s| Decimal::from_str(s).ok())
4180 .unwrap_or(Decimal::ZERO);
4181 Some(AssetBalance::new(
4182 asset_name,
4183 Balance::new_margin(free + locked, free, borrowed, interest),
4184 now,
4185 ))
4186}
4187
4188fn convert_isolated_margin_assets(
4196 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
4197) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
4198 let now = Utc::now();
4201 let mut map = HashMap::with_capacity(assets.len());
4202 for entry in assets {
4203 let Some(symbol) = entry.symbol.as_deref() else {
4204 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
4205 continue;
4206 };
4207 let instrument = InstrumentNameExchange::new(symbol);
4208
4209 let Some(base_raw) = entry.base_asset.as_deref() else {
4210 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
4211 continue;
4212 };
4213 let Some(quote_raw) = entry.quote_asset.as_deref() else {
4214 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
4215 continue;
4216 };
4217
4218 let Some(base) = convert_isolated_asset_balance(
4219 base_raw.asset.as_deref(),
4220 base_raw.free.as_deref(),
4221 base_raw.locked.as_deref(),
4222 base_raw.borrowed.as_deref(),
4223 base_raw.interest.as_deref(),
4224 now,
4225 ) else {
4226 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
4227 continue;
4228 };
4229 let Some(quote) = convert_isolated_asset_balance(
4230 quote_raw.asset.as_deref(),
4231 quote_raw.free.as_deref(),
4232 quote_raw.locked.as_deref(),
4233 quote_raw.borrowed.as_deref(),
4234 quote_raw.interest.as_deref(),
4235 now,
4236 ) else {
4237 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
4238 continue;
4239 };
4240
4241 let risk = IsolatedMarginRisk {
4242 margin_level: entry
4243 .margin_level
4244 .as_deref()
4245 .and_then(|s| Decimal::from_str(s).ok()),
4246 margin_ratio: entry
4247 .margin_ratio
4248 .as_deref()
4249 .and_then(|s| Decimal::from_str(s).ok()),
4250 liquidation_price: entry
4251 .liquidate_price
4252 .as_deref()
4253 .and_then(|s| Decimal::from_str(s).ok()),
4254 };
4255
4256 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
4257 }
4258 map
4259}
4260
4261fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
4264 symbols
4265 .chunks(5)
4266 .map(|chunk| {
4267 chunk
4268 .iter()
4269 .map(|s| s.name().as_str())
4270 .collect::<Vec<_>>()
4271 .join(",")
4272 })
4273 .collect()
4274}
4275
4276async fn fetch_isolated_margin_account_info(
4282 rest: Arc<RestApi>,
4283 rate_limiter: Arc<RateLimitTracker>,
4284 symbols: Vec<InstrumentNameExchange>,
4285) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
4286 use futures::{StreamExt as _, TryStreamExt as _};
4287
4288 let chunks = chunk_symbols(&symbols);
4289
4290 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
4294 let rest = rest.clone();
4295 let rate_limiter = rate_limiter.clone();
4296 async move {
4297 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
4298 let symbols_param = symbols_param.clone();
4299 Box::pin(async move {
4300 let params = QueryIsolatedMarginAccountInfoParams::builder()
4301 .symbols(symbols_param)
4302 .build()?;
4303 rest.query_isolated_margin_account_info(params).await
4304 })
4305 })
4306 .await
4307 .map_err(|e| classify_rest_query_error(&e, None))?;
4308
4309 let info = response.data().await.map_err(response_decode_error)?;
4310 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
4311 }
4312 }))
4313 .buffer_unordered(8)
4314 .try_fold(
4315 Vec::with_capacity(symbols.len()),
4316 |mut acc, assets| async move {
4317 acc.extend(assets);
4318 Ok(acc)
4319 },
4320 )
4321 .await
4322}
4323
4324fn convert_margin_trade(
4331 t: &QueryMarginAccountsTradeListResponseInner,
4332 instrument: &InstrumentNameExchange,
4333) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4334 let trade_id_raw = match t.id {
4335 Some(id) => id,
4336 None => {
4337 warn!(%instrument, "BinanceMargin trade missing id");
4338 return None;
4339 }
4340 };
4341 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
4342 let order_id = match t.order_id {
4343 Some(id) => OrderId(format_smolstr!("{id}")),
4344 None => {
4345 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
4346 return None;
4347 }
4348 };
4349 let side = match t.is_buyer {
4350 Some(true) => Side::Buy,
4351 Some(false) => Side::Sell,
4352 None => {
4353 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
4354 return None;
4355 }
4356 };
4357 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4358 Some(v) => v,
4359 None => {
4360 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
4361 return None;
4362 }
4363 };
4364 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4365 Some(v) => v,
4366 None => {
4367 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
4368 return None;
4369 }
4370 };
4371 let commission = t
4372 .commission
4373 .as_deref()
4374 .and_then(|s| Decimal::from_str(s).ok())
4375 .unwrap_or(Decimal::ZERO);
4376 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
4377 Some(ts) => ts,
4378 None => {
4379 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
4380 Utc::now()
4381 }
4382 };
4383
4384 let fee_asset = t
4389 .commission_asset
4390 .as_deref()
4391 .map(AssetNameExchange::from)
4392 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
4393
4394 Some(Trade::new(
4395 trade_id,
4396 order_id,
4397 instrument.clone(),
4398 StrategyId::unknown(), time_exchange,
4400 side,
4401 price,
4402 quantity,
4403 None,
4405 AssetFees::new(fee_asset, commission, None),
4406 ))
4407}
4408
4409#[derive(Debug)]
4415enum BuildOrderError {
4416 Unsupported,
4418 Build(String),
4420 Trailing(TrailingOrderError),
4422}
4423
4424trait IsolatedFlag {
4435 fn from_flag(is_isolated: bool) -> Self;
4437}
4438
4439macro_rules! impl_isolated_flag {
4444 ($($t:ty),* $(,)?) => {
4445 $(
4446 impl IsolatedFlag for $t {
4447 fn from_flag(is_isolated: bool) -> Self {
4448 if is_isolated { Self::True } else { Self::False }
4449 }
4450 }
4451 )*
4452 };
4453}
4454
4455impl_isolated_flag!(
4456 MarginAccountNewOrderIsIsolatedEnum,
4457 MarginAccountCancelOrderIsIsolatedEnum,
4458 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
4459 QueryMarginAccountsOrderIsIsolatedEnum,
4460 QueryMarginAccountsTradeListIsIsolatedEnum,
4461 GetFutureHourlyInterestRateIsIsolatedEnum,
4462);
4463
4464fn build_cancel_order_params(
4472 symbol: String,
4473 id: Option<&OrderId>,
4474 cid: &ClientOrderId,
4475 is_isolated: bool,
4476) -> Result<MarginAccountCancelOrderParams, String> {
4477 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
4478 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
4479 );
4480
4481 match id {
4482 Some(order_id) => match order_id.0.parse::<i64>() {
4483 Ok(id) => builder = builder.order_id(id),
4484 Err(_) => {
4485 error!(
4487 order_id = %order_id.0,
4488 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
4489 );
4490 builder = builder.orig_client_order_id(cid.0.to_string());
4491 }
4492 },
4493 None => builder = builder.orig_client_order_id(cid.0.to_string()),
4494 }
4495
4496 builder.build().map_err(|e| e.to_string())
4497}
4498
4499#[allow(clippy::too_many_arguments)] fn build_new_order_params(
4508 symbol: String,
4509 side: Side,
4510 price: Option<Decimal>,
4511 quantity: Decimal,
4512 kind: OrderKind,
4513 time_in_force: TimeInForce,
4514 new_client_order_id: String,
4515 margin_effect: MarginEffect,
4516 auto_repay_at_cancel: bool,
4517 is_isolated: bool,
4518) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
4519 let binance_side = match side {
4520 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
4521 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
4522 };
4523
4524 let (binance_type, binance_tif) =
4525 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
4526
4527 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
4529 .quantity(quantity)
4530 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
4531 .side_effect_type(binance_side_effect(margin_effect))
4532 .new_client_order_id(new_client_order_id)
4533 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
4534
4535 if margin_effect == MarginEffect::Borrow {
4538 builder = builder.auto_repay_at_cancel(auto_repay_at_cancel);
4539 }
4540
4541 if let Some(tif) = binance_tif {
4542 builder = builder.time_in_force(tif);
4543 }
4544
4545 if let Some(trailing) = trailing_params(&kind, price).map_err(BuildOrderError::Trailing)? {
4548 builder = builder.trailing_delta(i64::from(trailing.trailing_delta));
4549 if let Some(limit) = trailing.price {
4550 builder = builder.price(limit);
4551 }
4552 if let Some(activation_price) = trailing.stop_price {
4553 builder = builder.stop_price(activation_price);
4554 }
4555 }
4556
4557 match kind {
4561 OrderKind::Limit => {
4562 builder = builder.price(price);
4563 }
4564 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
4565 builder = builder.stop_price(trigger_price);
4566 }
4567 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
4568 builder = builder.price(price).stop_price(trigger_price);
4569 }
4570 OrderKind::TrailingStop { .. }
4573 | OrderKind::TrailingStopFixedLimit { .. }
4574 | OrderKind::Market
4575 | OrderKind::TrailingStopLimit { .. } => {}
4576 }
4577
4578 builder
4579 .build()
4580 .map_err(|e| BuildOrderError::Build(e.to_string()))
4581}
4582
4583fn convert_order_kind_tif_margin(
4594 kind: OrderKind,
4595 tif: TimeInForce,
4596) -> Option<(
4597 MarginAccountNewOrderTypeEnum,
4598 Option<MarginAccountNewOrderTimeInForceEnum>,
4599)> {
4600 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
4601 let margin_type = match binance_type {
4602 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
4603 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
4604 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
4605 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
4606 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
4607 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
4608 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
4609 };
4610 let margin_tif = binance_tif.map(|t| match t {
4611 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
4612 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
4613 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
4614 });
4615 Some((margin_type, margin_tif))
4616}
4617
4618#[cfg(test)]
4619#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
4621 use super::*;
4622 use crate::client::binance::shared::{
4623 convert_open_order,
4624 test_ws_api::{self, Lifetime},
4625 unix_ms,
4626 };
4627 use crate::error::StreamTerminationReason;
4628 use crate::notice::{AccountNotice, NoticeKind};
4629 use crate::order::state::ActiveOrderState;
4630 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
4631
4632 #[tokio::test]
4635 async fn a_banned_listen_token_subscribe_starts_the_cooldown() {
4636 let until = i64::try_from(unix_ms()).unwrap() + 120_000;
4637 let error = subscribe_error(WebsocketError::ResponseError {
4638 code: -1003,
4639 message: "Way too much request weight used".to_string(),
4640 retry_after: Some(until),
4641 });
4642 assert!(
4643 error
4644 .to_string()
4645 .starts_with("userDataStream.subscribe.listenToken failed: "),
4646 "{error}"
4647 );
4648 let tracker = RateLimitTracker::new(WeightPool::Sapi);
4649
4650 assert!(tracker.on_stream_refused(&error));
4651 assert!(
4652 tracker
4653 .cooldown_remaining()
4654 .is_some_and(|left| left > Duration::from_secs(115)),
4655 "{:?}",
4656 tracker.cooldown_remaining()
4657 );
4658 }
4659
4660 struct ManagerRig {
4664 ws_config: ConfigurationWebsocketApi,
4665 rest_config: Arc<ConfigurationRestApi>,
4666 rest: Arc<RestApi>,
4667 rate_limiter: Arc<RateLimitTracker>,
4668 requests: Arc<parking_lot::Mutex<Vec<tokio::time::Instant>>>,
4670 }
4671
4672 impl ManagerRig {
4673 async fn new(
4674 answer: fn(&serde_json::Value) -> serde_json::Value,
4675 lifetime: fn(usize) -> Lifetime,
4676 ) -> Self {
4677 let (ws_url, requests) = test_ws_api::serve(answer, lifetime).await;
4678
4679 let rest_url = crate::client::test_http::serve(|method, path| {
4681 if (method, path) != ("POST", "/sapi/v1/userListenToken") {
4682 return (404, String::new());
4683 }
4684 let token = serde_json::json!({
4685 "token": "token",
4686 "expirationTime": Utc::now().timestamp_millis() + 86_400_000,
4687 });
4688 (200, token.to_string())
4689 })
4690 .await;
4691 let rest_config = Arc::new(
4692 ConfigurationRestApi::builder()
4693 .api_key("key")
4694 .api_secret("secret")
4695 .base_path(rest_url)
4696 .build()
4697 .unwrap(),
4698 );
4699 Self {
4700 ws_config: ConfigurationWebsocketApi::builder()
4701 .api_key("key")
4702 .api_secret("secret")
4703 .ws_url(ws_url)
4704 .build()
4705 .unwrap(),
4706 rest: Arc::new(MarginTradingRestApi::from_config((*rest_config).clone())),
4707 rest_config,
4708 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
4709 requests,
4710 }
4711 }
4712
4713 fn token() -> UserListenToken {
4715 UserListenToken {
4716 token: "token".to_string(),
4717 expiration_time_ms: Utc::now().timestamp_millis() + 86_400_000,
4718 }
4719 }
4720
4721 async fn cross(&self) -> mpsc::UnboundedReceiver<UnindexedAccountEvent> {
4723 let (tx, rx) = mpsc::unbounded_channel();
4724 let ws = connect_margin_ws(&self.ws_config).await.unwrap();
4725 tokio::spawn(margin_connection_manager(
4726 tx,
4727 new_dedup_cache(),
4728 self.ws_config.clone(),
4729 Arc::clone(&self.rest_config),
4730 Arc::clone(&self.rest),
4731 Arc::clone(&self.rate_limiter),
4732 KnownLiveOrders::shared(ExchangeId::BinanceMargin),
4733 Vec::new(),
4734 Some((ws, Self::token())),
4735 ));
4736 rx
4737 }
4738
4739 async fn isolated(&self) -> mpsc::UnboundedReceiver<UnindexedAccountEvent> {
4742 let (tx, rx) = mpsc::unbounded_channel();
4743 let (dedup, known) = (
4744 new_dedup_cache(),
4745 KnownLiveOrders::shared(ExchangeId::BinanceMargin),
4746 );
4747 let symbol = InstrumentNameExchange::new("BTCUSDT");
4748 let base_quote = Arc::new(HashMap::from([(
4749 symbol.clone(),
4750 (
4751 AssetNameExchange::new("BTC"),
4752 AssetNameExchange::new("USDT"),
4753 ),
4754 )]));
4755 let tokens = [(symbol.clone(), Self::token())];
4756 let live = isolated_connect_and_subscribe(
4757 &self.ws_config,
4758 &tx,
4759 &dedup,
4760 &known,
4761 &base_quote,
4762 &tokens,
4763 )
4764 .await
4765 .unwrap();
4766 tokio::spawn(isolated_connection_manager(
4767 tx,
4768 dedup,
4769 self.ws_config.clone(),
4770 Arc::clone(&self.rest_config),
4771 Arc::clone(&self.rest),
4772 Arc::clone(&self.rate_limiter),
4773 known,
4774 vec![symbol],
4775 base_quote,
4776 Some(live),
4777 ));
4778 rx
4779 }
4780 }
4781
4782 fn subscribed(request: &serde_json::Value) -> serde_json::Value {
4784 serde_json::json!({
4785 "id": request["id"],
4786 "status": 200,
4787 "result": { "subscriptionId": 0 },
4788 })
4789 }
4790
4791 fn banned_for_two_minutes(request: &serde_json::Value) -> serde_json::Value {
4793 let until = unix_ms() + 120_000;
4794 serde_json::json!({
4795 "id": request["id"],
4796 "status": 418,
4797 "error": {
4798 "code": -1003,
4799 "msg": format!("Way too much request weight used; IP banned until {until}."),
4800 "data": { "serverTime": unix_ms(), "retryAfter": until },
4801 },
4802 })
4803 }
4804
4805 async fn rate_limited_end(rx: &mut mpsc::UnboundedReceiver<UnindexedAccountEvent>) -> Duration {
4808 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
4809 .await
4810 .expect("the stream ends rate-limited within the timeout")
4811 .expect("it reports why before it ends");
4812 let AccountEventKind::StreamTerminated(StreamTerminationReason::RateLimited {
4813 retry_after,
4814 }) = event.kind
4815 else {
4816 panic!("expected a rate-limited termination, got {:?}", event.kind);
4817 };
4818 assert!(rx.recv().await.is_none(), "the stream has ended");
4819 retry_after
4820 }
4821
4822 #[tokio::test]
4825 async fn a_banned_subscribe_ends_the_cross_margin_stream_rate_limited() {
4826 let rig = ManagerRig::new(banned_for_two_minutes, |_| Lifetime::Open).await;
4827 let mut events = rig.cross().await;
4828
4829 let wait = rate_limited_end(&mut events).await;
4830
4831 assert!(wait > Duration::from_secs(115), "{wait:?}");
4832 assert_eq!(rig.requests.lock().len(), 1, "no second subscribe");
4833 }
4834
4835 #[tokio::test]
4838 async fn a_banned_isolated_subscribe_is_recognised_as_a_refusal() {
4839 let rig = ManagerRig::new(banned_for_two_minutes, |_| Lifetime::Open).await;
4840 let (tx, _rx) = mpsc::unbounded_channel();
4841 let symbol = InstrumentNameExchange::new("BTCUSDT");
4842
4843 let Err(error) = isolated_connect_and_subscribe(
4844 &rig.ws_config,
4845 &tx,
4846 &new_dedup_cache(),
4847 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
4848 &Arc::new(HashMap::new()),
4849 &[(symbol, ManagerRig::token())],
4850 )
4851 .await
4852 else {
4853 panic!("a banned subscribe fails");
4854 };
4855
4856 assert!(rig.rate_limiter.on_stream_refused(&error), "{error:#}");
4857 assert!(
4858 rig.rate_limiter
4859 .cooldown_remaining()
4860 .is_some_and(|left| left > Duration::from_secs(115))
4861 );
4862 }
4863
4864 #[tokio::test]
4867 async fn a_drop_during_a_long_cooldown_ends_the_isolated_margin_stream_rate_limited() {
4868 let rig = ManagerRig::new(subscribed, |_| Lifetime::Silent(Duration::from_secs(1))).await;
4870 let mut events = rig.isolated().await;
4871 rig.rate_limiter
4872 .on_rate_limited(Some(Duration::from_secs(60)));
4873
4874 let wait = rate_limited_end(&mut events).await;
4875
4876 assert!(wait > Duration::from_secs(55), "{wait:?}");
4877 assert_eq!(rig.requests.lock().len(), 1, "no second subscribe");
4878 }
4879
4880 #[tokio::test]
4883 async fn a_dropped_margin_connection_backs_off_before_reconnecting() {
4884 for isolated in [false, true] {
4885 let rig =
4886 ManagerRig::new(subscribed, |_| Lifetime::Silent(Duration::from_millis(100))).await;
4887 let _events = if isolated {
4888 rig.isolated().await
4889 } else {
4890 rig.cross().await
4891 };
4892
4893 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
4894 while rig.requests.lock().len() < 2 {
4895 assert!(
4896 tokio::time::Instant::now() < deadline,
4897 "isolated {isolated}: no reconnect"
4898 );
4899 tokio::time::sleep(Duration::from_millis(20)).await;
4900 }
4901
4902 let requests = rig.requests.lock();
4903 let between = requests[1] - requests[0];
4904 assert!(
4906 between >= BackoffLimits::ACCOUNT_STREAM.initial,
4907 "isolated {isolated}: reconnected {between:?} after the first subscribe"
4908 );
4909 }
4910 }
4911
4912 #[tokio::test(start_paused = true)]
4917 async fn a_margin_reconnect_budget_resets_only_once_a_connection_proves_stable() {
4918 async fn run(isolated: bool, pinging: bool) {
4919 let lifetime = if pinging {
4920 test_ws_api::dropped_then_pinging
4921 } else {
4922 test_ws_api::dropped_then_silent
4923 };
4924 let rig = ManagerRig::new(subscribed, lifetime).await;
4925 let _events = if isolated {
4926 rig.isolated().await
4927 } else {
4928 rig.cross().await
4929 };
4930 let label = format!("isolated {isolated}");
4931 if pinging {
4932 test_ws_api::assert_reset_once_pinged(&rig.requests, &label).await;
4933 } else {
4934 test_ws_api::assert_no_reset_when_silent(&rig.requests, &label).await;
4935 }
4936 }
4937
4938 tokio::join!(
4939 run(false, true),
4940 run(true, true),
4941 run(false, false),
4942 run(true, false)
4943 );
4944 }
4945
4946 #[tokio::test]
4949 async fn a_dropped_margin_connection_is_closed() {
4950 use futures::StreamExt as _;
4951 use tokio_tungstenite::tungstenite::Message;
4952
4953 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
4954 let url = format!("ws://{}", listener.local_addr().unwrap());
4955 let (closed_tx, mut closed) = mpsc::unbounded_channel();
4956 tokio::spawn(async move {
4957 let (stream, _) = listener.accept().await.unwrap();
4958 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
4959 while let Some(Ok(message)) = ws.next().await {
4960 if let Message::Close(_) = message {
4961 let _ = closed_tx.send(());
4962 return;
4963 }
4964 }
4965 });
4966 let config = ConfigurationWebsocketApi::builder()
4967 .api_key("key")
4968 .api_secret("secret")
4969 .ws_url(url)
4970 .build()
4971 .unwrap();
4972
4973 drop(connect_margin_ws(&config).await.unwrap());
4974
4975 tokio::time::timeout(Duration::from_secs(5), closed.recv())
4976 .await
4977 .expect("the dropped connection is closed")
4978 .unwrap();
4979 }
4980
4981 #[test]
4982 fn margin_effect_default_is_no_side_effect() {
4983 assert_eq!(MarginEffect::default(), MarginEffect::NoSideEffect);
4984 }
4985
4986 #[test]
4987 fn margin_effect_wire_values() {
4988 for (effect, wire) in [
4989 (MarginEffect::NoSideEffect, "NO_SIDE_EFFECT"),
4990 (MarginEffect::Borrow, "MARGIN_BUY"),
4991 (MarginEffect::Repay, "AUTO_REPAY"),
4992 ] {
4993 assert_eq!(binance_side_effect(effect).as_str(), wire);
4994 }
4995 }
4996
4997 #[test]
4998 fn config_debug_redacts_secrets() {
4999 let config = BinanceMarginConfig::new(
5000 "my_api_key".to_string(),
5001 "my_secret_key".to_string(),
5002 false,
5003 false,
5004 Vec::new(),
5005 MarginEffect::default(),
5006 true,
5007 );
5008 let debug = format!("{config:?}");
5009 assert!(!debug.contains("my_api_key"));
5010 assert!(!debug.contains("my_secret_key"));
5011 assert!(debug.contains("***"));
5012 }
5013
5014 #[test]
5015 fn cross_margin_uses_common_case_defaults() {
5016 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
5017 assert!(!config.testnet);
5018 assert!(!config.is_isolated);
5019 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
5020 assert!(config.auto_repay_at_cancel);
5021 assert_eq!(config.api_key(), "k");
5022 }
5023
5024 #[test]
5025 fn config_deserializes_with_defaults() {
5026 let config: BinanceMarginConfig =
5029 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
5030 .expect("deserialize");
5031 assert!(!config.is_isolated);
5032 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
5033 assert!(config.auto_repay_at_cancel);
5034 }
5035
5036 #[test]
5037 fn config_deserializes_explicit_margin_effect() {
5038 let config: BinanceMarginConfig = serde_json::from_str(
5039 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"margin_effect":"borrow","auto_repay_at_cancel":false}"#,
5040 )
5041 .expect("deserialize");
5042 assert!(config.is_isolated);
5043 assert_eq!(config.margin_effect, MarginEffect::Borrow);
5044 assert!(!config.auto_repay_at_cancel);
5045 }
5046
5047 #[test]
5048 fn config_refuses_the_retired_combined_effect() {
5049 let parsed = serde_json::from_str::<BinanceMarginConfig>(
5051 r#"{"api_key":"k","secret_key":"s","testnet":false,"margin_effect":"auto_borrow_repay"}"#,
5052 );
5053 assert!(parsed.is_err());
5054 }
5055
5056 #[test]
5057 fn isolated_ctor_sets_symbols_and_flag() {
5058 let symbols = vec![
5059 InstrumentNameExchange::new("BTCUSDT"),
5060 InstrumentNameExchange::new("ETHUSDT"),
5061 ];
5062 let config =
5063 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
5064 assert!(config.is_isolated);
5065 assert_eq!(config.isolated_symbols, symbols);
5066 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
5067 }
5068
5069 #[test]
5070 fn isolated_config_with_symbols_constructs() {
5071 let config = BinanceMarginConfig::isolated(
5073 "k".to_string(),
5074 "s".to_string(),
5075 vec![InstrumentNameExchange::new("BTCUSDT")],
5076 );
5077 let _client = BinanceMargin::new(config); }
5079
5080 #[test]
5081 #[should_panic(expected = "non-empty isolated_symbols")]
5082 fn isolated_empty_symbols_panics_at_new() {
5083 let config = BinanceMarginConfig::new(
5085 "k".to_string(),
5086 "s".to_string(),
5087 false,
5088 true,
5089 Vec::new(),
5090 MarginEffect::default(),
5091 true,
5092 );
5093 let _ = BinanceMargin::new(config);
5094 }
5095
5096 #[test]
5097 #[should_panic(expected = "non-empty isolated_symbols")]
5098 fn isolated_empty_symbols_panics_via_deserialize_path() {
5099 let config: BinanceMarginConfig = serde_json::from_str(
5102 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
5103 )
5104 .expect("deserialize");
5105 assert!(config.isolated_symbols.is_empty());
5106 let _ = BinanceMargin::new(config);
5107 }
5108
5109 #[test]
5110 fn isolated_flag_maps_mode() {
5111 assert_eq!(
5114 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
5115 "TRUE"
5116 );
5117 assert_eq!(
5118 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
5119 "FALSE"
5120 );
5121 assert_eq!(
5122 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
5123 "TRUE"
5124 );
5125 assert_eq!(
5126 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
5127 "FALSE"
5128 );
5129 assert_eq!(
5130 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
5131 "TRUE"
5132 );
5133 assert_eq!(
5134 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
5135 "FALSE"
5136 );
5137 assert_eq!(
5138 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
5139 "TRUE"
5140 );
5141 assert_eq!(
5142 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
5143 "FALSE"
5144 );
5145 }
5146
5147 #[test]
5152 fn cancel_params_cross_with_parseable_order_id() {
5153 let p = build_cancel_order_params(
5154 "BTCUSDT".to_string(),
5155 Some(&OrderId::new("12345")),
5156 &ClientOrderId::new("cid-1"),
5157 false,
5158 )
5159 .expect("build");
5160 assert_eq!(p.symbol, "BTCUSDT");
5161 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
5162 assert_eq!(p.order_id, Some(12345));
5163 assert_eq!(p.orig_client_order_id, None);
5164 }
5165
5166 #[test]
5167 fn cancel_params_isolated_is_config_driven() {
5168 let p = build_cancel_order_params(
5169 "BTCUSDT".to_string(),
5170 Some(&OrderId::new("12345")),
5171 &ClientOrderId::new("cid-1"),
5172 true,
5173 )
5174 .expect("build");
5175 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
5176 assert_eq!(p.order_id, Some(12345));
5177 }
5178
5179 #[test]
5180 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
5181 let p = build_cancel_order_params(
5182 "BTCUSDT".to_string(),
5183 Some(&OrderId::new("not-an-i64")),
5184 &ClientOrderId::new("cid-1"),
5185 false,
5186 )
5187 .expect("build");
5188 assert_eq!(p.order_id, None);
5189 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
5190 }
5191
5192 #[test]
5193 fn cancel_params_absent_order_id_uses_cid() {
5194 let p = build_cancel_order_params(
5195 "BTCUSDT".to_string(),
5196 None,
5197 &ClientOrderId::new("cid-1"),
5198 false,
5199 )
5200 .expect("build");
5201 assert_eq!(p.order_id, None);
5202 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
5203 }
5204
5205 use crate::order::TrailingOffsetType;
5210
5211 fn params(
5213 side: Side,
5214 price: Option<Decimal>,
5215 kind: OrderKind,
5216 tif: TimeInForce,
5217 margin_effect: MarginEffect,
5218 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
5219 build_new_order_params(
5220 "BTCUSDT".to_string(),
5221 side,
5222 price,
5223 Decimal::from(2),
5224 kind,
5225 tif,
5226 "cid-1".to_string(),
5227 margin_effect,
5228 true,
5229 false, )
5231 }
5232
5233 fn gtc() -> TimeInForce {
5234 TimeInForce::GoodUntilCancelled { post_only: false }
5235 }
5236
5237 #[test]
5238 fn new_order_limit_maps_core_fields() {
5239 let p = params(
5240 Side::Buy,
5241 Some(Decimal::from(50_000)),
5242 OrderKind::Limit,
5243 gtc(),
5244 MarginEffect::Borrow,
5245 )
5246 .expect("build");
5247
5248 assert_eq!(p.symbol, "BTCUSDT");
5249 assert_eq!(p.side.as_str(), "BUY");
5250 assert_eq!(p.r#type.as_str(), "LIMIT");
5251 assert_eq!(p.quantity, Some(Decimal::from(2)));
5252 assert_eq!(p.price, Some(Decimal::from(50_000)));
5253 assert_eq!(p.stop_price, None);
5254 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
5255 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
5256 assert_eq!(
5258 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
5259 Some("FULL")
5260 );
5261 }
5262
5263 #[test]
5264 fn new_order_is_isolated_is_config_driven() {
5265 let cross = build_new_order_params(
5267 "BTCUSDT".to_string(),
5268 Side::Sell,
5269 Some(Decimal::from(10)),
5270 Decimal::from(2),
5271 OrderKind::Limit,
5272 gtc(),
5273 "cid-1".to_string(),
5274 MarginEffect::Borrow,
5275 true,
5276 false,
5277 )
5278 .expect("build");
5279 assert_eq!(
5280 cross.is_isolated.as_ref().map(|i| i.as_str()),
5281 Some("FALSE")
5282 );
5283 assert_eq!(cross.side.as_str(), "SELL");
5284
5285 let isolated = build_new_order_params(
5287 "BTCUSDT".to_string(),
5288 Side::Sell,
5289 Some(Decimal::from(10)),
5290 Decimal::from(2),
5291 OrderKind::Limit,
5292 gtc(),
5293 "cid-1".to_string(),
5294 MarginEffect::Borrow,
5295 true,
5296 true,
5297 )
5298 .expect("build");
5299 assert_eq!(
5300 isolated.is_isolated.as_ref().map(|i| i.as_str()),
5301 Some("TRUE")
5302 );
5303 }
5304
5305 #[test]
5306 fn side_effect_and_auto_repay_gating() {
5307 let build = |effect, auto_repay_at_cancel| {
5308 build_new_order_params(
5309 "BTCUSDT".to_string(),
5310 Side::Buy,
5311 Some(Decimal::from(1)),
5312 Decimal::from(2),
5313 OrderKind::Limit,
5314 gtc(),
5315 "cid-1".to_string(),
5316 effect,
5317 auto_repay_at_cancel,
5318 false,
5319 )
5320 .expect("build")
5321 };
5322 let wire = |params: &MarginAccountNewOrderParams| {
5323 params
5324 .side_effect_type
5325 .as_ref()
5326 .map(|s| s.as_str().to_owned())
5327 };
5328
5329 let borrow = build(MarginEffect::Borrow, true);
5331 assert_eq!(wire(&borrow).as_deref(), Some("MARGIN_BUY"));
5332 assert_eq!(borrow.auto_repay_at_cancel, Some(true));
5333 let borrow_off = build(MarginEffect::Borrow, false);
5334 assert_eq!(borrow_off.auto_repay_at_cancel, Some(false));
5335
5336 let repay = build(MarginEffect::Repay, true);
5338 assert_eq!(wire(&repay).as_deref(), Some("AUTO_REPAY"));
5339 assert_eq!(repay.auto_repay_at_cancel, None);
5340 let none = build(MarginEffect::NoSideEffect, true);
5341 assert_eq!(wire(&none).as_deref(), Some("NO_SIDE_EFFECT"));
5342 assert_eq!(none.auto_repay_at_cancel, None);
5343 }
5344
5345 #[test]
5346 fn new_order_market_has_no_price_or_tif() {
5347 let p = params(
5348 Side::Buy,
5349 None,
5350 OrderKind::Market,
5351 gtc(),
5352 MarginEffect::Borrow,
5353 )
5354 .expect("build");
5355 assert_eq!(p.r#type.as_str(), "MARKET");
5356 assert_eq!(p.price, None);
5357 assert_eq!(p.stop_price, None);
5358 assert!(p.time_in_force.is_none());
5359 }
5360
5361 #[test]
5362 fn post_only_limit_maps_to_limit_maker() {
5363 let p = params(
5364 Side::Buy,
5365 Some(Decimal::from(10)),
5366 OrderKind::Limit,
5367 TimeInForce::GoodUntilCancelled { post_only: true },
5368 MarginEffect::Borrow,
5369 )
5370 .expect("build");
5371 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
5372 assert!(p.time_in_force.is_none());
5374 }
5375
5376 #[test]
5377 fn conditional_kinds_set_stop_price() {
5378 let trigger = Decimal::from(48_000);
5379
5380 let stop = params(
5381 Side::Sell,
5382 None,
5383 OrderKind::Stop {
5384 trigger_price: trigger,
5385 },
5386 gtc(),
5387 MarginEffect::Borrow,
5388 )
5389 .expect("build");
5390 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
5391 assert_eq!(stop.stop_price, Some(trigger));
5392 assert_eq!(stop.price, None);
5393
5394 let stop_limit = params(
5395 Side::Sell,
5396 Some(Decimal::from(47_900)),
5397 OrderKind::StopLimit {
5398 trigger_price: trigger,
5399 },
5400 gtc(),
5401 MarginEffect::Borrow,
5402 )
5403 .expect("build");
5404 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
5405 assert_eq!(stop_limit.stop_price, Some(trigger));
5406 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
5407 assert_eq!(
5408 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
5409 Some("GTC")
5410 );
5411
5412 let take_profit = params(
5413 Side::Sell,
5414 None,
5415 OrderKind::TakeProfit {
5416 trigger_price: trigger,
5417 },
5418 gtc(),
5419 MarginEffect::Borrow,
5420 )
5421 .expect("build");
5422 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
5423 assert_eq!(take_profit.stop_price, Some(trigger));
5424 }
5425
5426 #[test]
5429 fn a_trailing_stop_maps_to_stop_loss_with_trailing_delta() {
5430 let cases = [
5431 (Decimal::from(25), TrailingOffsetType::BasisPoints, 25),
5432 (Decimal::new(15, 1), TrailingOffsetType::Percentage, 150),
5433 ];
5434 for (offset, offset_type, trailing_delta) in cases {
5435 let p = params(
5436 Side::Sell,
5437 None,
5438 OrderKind::TrailingStop {
5439 offset,
5440 offset_type,
5441 activation_price: None,
5442 },
5443 gtc(),
5444 MarginEffect::NoSideEffect,
5445 )
5446 .expect("build");
5447 assert_eq!(p.r#type.as_str(), "STOP_LOSS");
5448 assert_eq!(p.trailing_delta, Some(trailing_delta), "{offset_type:?}");
5449 assert_eq!(p.stop_price, None);
5450 assert_eq!(p.price, None);
5451 assert!(p.time_in_force.is_none());
5452 }
5453 }
5454
5455 #[test]
5459 fn trailing_orders_carry_their_activation_price_and_fixed_limit() {
5460 let activation = Some(Decimal::from(50_000));
5461 let trailing_stop = params(
5462 Side::Sell,
5463 None,
5464 OrderKind::TrailingStop {
5465 offset: Decimal::from(25),
5466 offset_type: TrailingOffsetType::BasisPoints,
5467 activation_price: activation,
5468 },
5469 gtc(),
5470 MarginEffect::NoSideEffect,
5471 )
5472 .expect("build");
5473 assert_eq!(trailing_stop.r#type.as_str(), "STOP_LOSS");
5474 assert_eq!(trailing_stop.trailing_delta, Some(25));
5475 assert_eq!(trailing_stop.stop_price, activation);
5476 assert_eq!(trailing_stop.price, None);
5477
5478 for activation_price in [None, activation] {
5479 let fixed_limit = params(
5480 Side::Sell,
5481 Some(Decimal::from(49_000)),
5482 OrderKind::TrailingStopFixedLimit {
5483 offset: Decimal::new(15, 1),
5484 offset_type: TrailingOffsetType::Percentage,
5485 activation_price,
5486 },
5487 TimeInForce::ImmediateOrCancel,
5488 MarginEffect::NoSideEffect,
5489 )
5490 .expect("build");
5491 assert_eq!(fixed_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
5492 assert_eq!(fixed_limit.trailing_delta, Some(150));
5493 assert_eq!(fixed_limit.price, Some(Decimal::from(49_000)));
5494 assert_eq!(fixed_limit.stop_price, activation_price);
5495 assert_eq!(
5496 fixed_limit.time_in_force.as_ref().map(|t| t.as_str()),
5497 Some("IOC")
5498 );
5499 }
5500
5501 let absolute = params(
5502 Side::Sell,
5503 Some(Decimal::from(49_000)),
5504 OrderKind::TrailingStopFixedLimit {
5505 offset: Decimal::from(100),
5506 offset_type: TrailingOffsetType::Absolute,
5507 activation_price: None,
5508 },
5509 gtc(),
5510 MarginEffect::NoSideEffect,
5511 );
5512 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
5513
5514 let no_limit = params(
5516 Side::Sell,
5517 None,
5518 OrderKind::TrailingStopFixedLimit {
5519 offset: Decimal::from(25),
5520 offset_type: TrailingOffsetType::BasisPoints,
5521 activation_price: None,
5522 },
5523 gtc(),
5524 MarginEffect::NoSideEffect,
5525 );
5526 assert!(matches!(
5527 no_limit,
5528 Err(BuildOrderError::Trailing(TrailingOrderError::LimitPrice(
5529 None
5530 )))
5531 ));
5532 let zero_activation = params(
5533 Side::Sell,
5534 None,
5535 OrderKind::TrailingStop {
5536 offset: Decimal::from(25),
5537 offset_type: TrailingOffsetType::BasisPoints,
5538 activation_price: Some(Decimal::ZERO),
5539 },
5540 gtc(),
5541 MarginEffect::NoSideEffect,
5542 );
5543 assert!(matches!(
5544 zero_activation,
5545 Err(BuildOrderError::Trailing(
5546 TrailingOrderError::NonPositiveActivation(_)
5547 ))
5548 ));
5549 }
5550
5551 #[test]
5552 fn absolute_trailing_offsets_and_trailing_stop_limits_are_unsupported() {
5553 let absolute = params(
5554 Side::Sell,
5555 None,
5556 OrderKind::TrailingStop {
5557 offset: Decimal::from(100),
5558 offset_type: TrailingOffsetType::Absolute,
5559 activation_price: None,
5560 },
5561 gtc(),
5562 MarginEffect::Borrow,
5563 );
5564 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
5565
5566 let trailing_stop_limit = params(
5567 Side::Sell,
5568 Some(Decimal::from(100)),
5569 OrderKind::TrailingStopLimit {
5570 offset: Decimal::from(100),
5571 offset_type: TrailingOffsetType::BasisPoints,
5572 stop_price: Decimal::from(95),
5573 limit_offset: Decimal::from(10),
5574 },
5575 gtc(),
5576 MarginEffect::Borrow,
5577 );
5578 assert!(matches!(
5579 trailing_stop_limit,
5580 Err(BuildOrderError::Unsupported)
5581 ));
5582 }
5583
5584 #[test]
5586 fn a_fractional_trailing_offset_is_refused() {
5587 let fractional = params(
5588 Side::Sell,
5589 None,
5590 OrderKind::TrailingStop {
5591 offset: Decimal::new(155, 3),
5592 offset_type: TrailingOffsetType::Percentage,
5593 activation_price: None,
5594 },
5595 gtc(),
5596 MarginEffect::Borrow,
5597 );
5598 assert!(matches!(
5599 fractional,
5600 Err(BuildOrderError::Trailing(TrailingOrderError::Delta(
5601 crate::client::binance::shared::TrailingDeltaError::Invalid(_)
5602 )))
5603 ));
5604 }
5605
5606 #[test]
5607 fn tif_variants_map_to_margin_enum() {
5608 for (tif, expected) in [
5609 (TimeInForce::ImmediateOrCancel, "IOC"),
5610 (TimeInForce::FillOrKill, "FOK"),
5611 (gtc(), "GTC"),
5612 ] {
5613 let p = params(
5614 Side::Buy,
5615 Some(Decimal::from(1)),
5616 OrderKind::Limit,
5617 tif,
5618 MarginEffect::Borrow,
5619 )
5620 .expect("build");
5621 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
5622 }
5623 }
5624
5625 fn user_asset(
5630 asset: &str,
5631 free: &str,
5632 locked: &str,
5633 borrowed: &str,
5634 interest: &str,
5635 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
5636 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
5637 a.asset = Some(asset.to_string());
5638 a.free = Some(free.to_string());
5639 a.locked = Some(locked.to_string());
5640 a.borrowed = Some(borrowed.to_string());
5641 a.interest = Some(interest.to_string());
5642 a
5643 }
5644
5645 #[test]
5646 fn margin_balance_entry_maps_debt() {
5647 let ab =
5649 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
5650 .expect("convert");
5651 assert_eq!(ab.asset.name().as_str(), "USDT");
5652 assert_eq!(ab.balance.total, Decimal::from(12));
5653 assert_eq!(ab.balance.free, Decimal::from(10));
5654 assert!(
5655 ab.balance.margin.is_some(),
5656 "REST snapshot must populate margin"
5657 );
5658 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
5660 }
5661
5662 #[test]
5663 fn margin_balance_short_is_negative_net() {
5664 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
5666 .expect("convert");
5667 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
5668 }
5669
5670 #[test]
5671 fn margin_balance_missing_debt_defaults_to_zero() {
5672 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
5675 a.asset = Some("ETH".to_string());
5676 a.free = Some("4".to_string());
5677 a.locked = Some("0".to_string());
5678 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
5679 assert!(ab.balance.margin.is_some());
5680 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
5681 }
5682
5683 #[test]
5684 fn margin_balance_missing_free_is_dropped() {
5685 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
5687 a.asset = Some("USDT".to_string());
5688 a.locked = Some("0".to_string());
5689 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
5690 }
5691
5692 #[test]
5693 fn margin_balance_filtering() {
5694 let assets = vec![
5695 user_asset("USDT", "10", "0", "0", "0"),
5696 user_asset("BTC", "1", "0", "0", "0"),
5697 user_asset("ETH", "5", "0", "0", "0"),
5698 ];
5699 assert_eq!(
5701 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
5702 3
5703 );
5704 let filtered = filter_and_convert_margin_balances(
5706 assets,
5707 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
5708 );
5709 assert_eq!(filtered.len(), 2);
5710 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
5711 }
5712
5713 #[test]
5714 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
5715 let assets = vec![
5717 user_asset("BTC", "1", "0", "0", "0"),
5718 user_asset("ETH", "5", "0", "0", "0"),
5719 user_asset("DOGE", "9", "0", "0", "0"), ];
5721 let mut requested: Vec<AssetNameExchange> = (0..17)
5723 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
5724 .collect();
5725 requested.push(AssetNameExchange::new("BTC"));
5726 requested.push(AssetNameExchange::new("ETH"));
5727 assert!(
5728 requested.len() > 16,
5729 "must exceed the linear-scan threshold"
5730 );
5731
5732 let filtered = filter_and_convert_margin_balances(assets, &requested);
5733 assert_eq!(filtered.len(), 2);
5734 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
5735 }
5736
5737 use binance_sdk::margin_trading::rest_api::{
5742 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
5743 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
5744 };
5745
5746 fn d(s: &str) -> Decimal {
5747 Decimal::from_str(s).unwrap()
5748 }
5749
5750 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
5751 let mut b = IsoBase::new();
5752 b.asset = Some(asset.to_string());
5753 b.free = Some(free.to_string());
5754 b.locked = Some(locked.to_string());
5755 b.borrowed = Some(borrowed.to_string());
5756 b.interest = Some(interest.to_string());
5757 b
5758 }
5759
5760 fn iso_quote(
5761 asset: &str,
5762 free: &str,
5763 locked: &str,
5764 borrowed: &str,
5765 interest: &str,
5766 ) -> IsoQuote {
5767 let mut q = IsoQuote::new();
5768 q.asset = Some(asset.to_string());
5769 q.free = Some(free.to_string());
5770 q.locked = Some(locked.to_string());
5771 q.borrowed = Some(borrowed.to_string());
5772 q.interest = Some(interest.to_string());
5773 q
5774 }
5775
5776 fn iso_entry(
5777 symbol: &str,
5778 base: IsoBase,
5779 quote: IsoQuote,
5780 margin_level: Option<&str>,
5781 margin_ratio: Option<&str>,
5782 liquidate_price: Option<&str>,
5783 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
5784 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
5785 e.symbol = Some(symbol.to_string());
5786 e.base_asset = Some(Box::new(base));
5787 e.quote_asset = Some(Box::new(quote));
5788 e.margin_level = margin_level.map(str::to_string);
5789 e.margin_ratio = margin_ratio.map(str::to_string);
5790 e.liquidate_price = liquidate_price.map(str::to_string);
5791 e
5792 }
5793
5794 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
5795 let config = BinanceMarginConfig::isolated(
5796 "k".to_string(),
5797 "s".to_string(),
5798 symbols
5799 .iter()
5800 .map(|s| InstrumentNameExchange::new(*s))
5801 .collect(),
5802 );
5803 BinanceMargin::new(config)
5804 }
5805
5806 #[test]
5807 fn client_debug_shows_mode_and_margin_effect_without_credentials() {
5808 let debug = format!("{:?}", isolated_client(&["BTCUSDT"]));
5809 assert!(debug.contains("BinanceMargin"));
5810 assert!(debug.contains("is_isolated: true"));
5811 assert!(debug.contains("margin_effect: NoSideEffect"));
5812 assert!(!debug.contains("\"k\"") && !debug.contains("\"s\""));
5813 }
5814
5815 #[test]
5816 fn isolated_assets_map_base_quote_and_risk() {
5817 let entry = iso_entry(
5820 "BTCUSDT",
5821 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
5822 iso_quote("USDT", "1000", "50", "300", "1.5"),
5823 Some("3.5"),
5824 Some("0.12"),
5825 Some("48000"),
5826 );
5827 let map = convert_isolated_margin_assets(vec![entry]);
5828 let state = map
5829 .get(&InstrumentNameExchange::new("BTCUSDT"))
5830 .expect("entry present");
5831
5832 assert_eq!(state.base.asset.name().as_str(), "BTC");
5833 assert_eq!(state.base.balance.total, d("0.6"));
5834 assert_eq!(state.base.balance.free, d("0.5"));
5835 assert_eq!(state.base.balance.net_asset(), d("0.4"));
5836
5837 assert_eq!(state.quote.asset.name().as_str(), "USDT");
5838 assert_eq!(state.quote.balance.total, d("1050"));
5839 assert_eq!(state.quote.balance.net_asset(), d("750"));
5840
5841 assert_eq!(state.risk.margin_level, Some(d("3.5")));
5842 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
5843 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
5844 }
5845
5846 #[test]
5847 fn isolated_base_short_is_negative_net() {
5848 let entry = iso_entry(
5850 "BTCUSDT",
5851 iso_base("BTC", "0", "0", "1.5", "0.001"),
5852 iso_quote("USDT", "100", "0", "0", "0"),
5853 None,
5854 None,
5855 None,
5856 );
5857 let map = convert_isolated_margin_assets(vec![entry]);
5858 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
5859 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
5860 }
5861
5862 #[test]
5863 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
5864 let mut base = IsoBase::new();
5867 base.asset = Some("BTC".to_string());
5868 base.free = Some("0.5".to_string());
5869 base.locked = Some("0".to_string());
5870 let mut quote = IsoQuote::new();
5871 quote.asset = Some("USDT".to_string());
5872 quote.free = Some("100".to_string());
5873 quote.locked = Some("0".to_string());
5874
5875 let map = convert_isolated_margin_assets(vec![iso_entry(
5876 "BTCUSDT", base, quote, None, None, None,
5877 )]);
5878 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
5879 assert!(state.base.balance.margin.is_some());
5880 assert_eq!(state.base.balance.net_asset(), d("0.5"));
5881 assert_eq!(state.risk.margin_level, None);
5882 assert_eq!(state.risk.liquidation_price, None);
5883 }
5884
5885 #[test]
5886 fn isolated_unparseable_risk_is_none_but_entry_kept() {
5887 let entry = iso_entry(
5888 "BTCUSDT",
5889 iso_base("BTC", "1", "0", "0", "0"),
5890 iso_quote("USDT", "1", "0", "0", "0"),
5891 Some("not_a_number"),
5892 None,
5893 None,
5894 );
5895 let map = convert_isolated_margin_assets(vec![entry]);
5896 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
5897 assert_eq!(state.risk.margin_level, None);
5898 }
5899
5900 #[test]
5901 fn isolated_missing_base_free_drops_entry() {
5902 let mut base = IsoBase::new();
5904 base.asset = Some("BTC".to_string());
5905 base.locked = Some("0".to_string()); let entry = iso_entry(
5907 "BTCUSDT",
5908 base,
5909 iso_quote("USDT", "100", "0", "0", "0"),
5910 None,
5911 None,
5912 None,
5913 );
5914 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
5915 }
5916
5917 #[test]
5918 fn isolated_missing_symbol_drops_entry() {
5919 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
5920 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
5921 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
5922 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
5924 }
5925
5926 #[test]
5927 fn chunk_symbols_batches_by_five() {
5928 let syms: Vec<InstrumentNameExchange> = (0..12)
5929 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
5930 .collect();
5931 let chunks = chunk_symbols(&syms);
5932 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
5933 assert_eq!(chunks[0].split(',').count(), 5);
5934 assert_eq!(chunks[1].split(',').count(), 5);
5935 assert_eq!(chunks[2].split(',').count(), 2);
5936
5937 let three: Vec<_> = (0..3)
5939 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
5940 .collect();
5941 assert_eq!(chunk_symbols(&three).len(), 1);
5942
5943 let five: Vec<_> = (0..5)
5945 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
5946 .collect();
5947 let c = chunk_symbols(&five);
5948 assert_eq!(c.len(), 1);
5949 assert_eq!(c[0].split(',').count(), 5);
5950
5951 assert!(chunk_symbols(&[]).is_empty());
5953 }
5954
5955 #[test]
5956 fn effective_isolated_set_empty_returns_all_configured() {
5957 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
5958 assert_eq!(
5959 client.effective_isolated_set(&[]),
5960 vec![
5961 InstrumentNameExchange::new("BTCUSDT"),
5962 InstrumentNameExchange::new("ETHUSDT"),
5963 ]
5964 );
5965 }
5966
5967 #[test]
5968 fn effective_isolated_set_intersects_requested() {
5969 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
5970 assert_eq!(
5971 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
5972 vec![InstrumentNameExchange::new("ETHUSDT")]
5973 );
5974 }
5975
5976 #[test]
5977 fn effective_isolated_set_skips_out_of_set() {
5978 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
5979 assert_eq!(
5981 client.effective_isolated_set(&[
5982 InstrumentNameExchange::new("BTCUSDT"),
5983 InstrumentNameExchange::new("DOGEUSDT"),
5984 ]),
5985 vec![InstrumentNameExchange::new("BTCUSDT")]
5986 );
5987 }
5988
5989 #[tokio::test]
5990 async fn fetch_balances_isolated_returns_empty() {
5991 let client = isolated_client(&["BTCUSDT"]);
5994 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
5995 }
5996
5997 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
5998 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
5999 o.order_id = order_id;
6000 o.client_order_id = Some("cid-9".to_string());
6001 o.side = Some(side.to_string());
6002 o.price = Some("50000".to_string());
6003 o.orig_qty = Some("2".to_string());
6004 o.executed_qty = Some("0.5".to_string());
6005 o.r#type = Some("LIMIT".to_string());
6006 o.time_in_force = Some("GTC".to_string());
6007 o.time = Some(1_700_000_000_000);
6008 o.status = Some("PARTIALLY_FILLED".to_string());
6009 o
6010 }
6011
6012 #[test]
6013 fn margin_open_order_converts() {
6014 let inst = InstrumentNameExchange::new("BTCUSDT");
6015 let order = convert_open_order(
6016 &open_order(Some(42), "BUY"),
6017 ExchangeId::BinanceMargin,
6018 &inst,
6019 )
6020 .expect("convert");
6021 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
6022 assert_eq!(
6023 order.state.id.assigned().map(|id| id.0.as_str()),
6024 Some("42")
6025 );
6026 assert_eq!(order.key.cid.0.as_str(), "cid-9");
6027 assert_eq!(order.side, Side::Buy);
6028 assert_eq!(order.price, Some(Decimal::from(50_000)));
6029 assert_eq!(order.quantity, Decimal::from(2));
6030 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
6031 assert_eq!(order.kind, OrderKind::Limit);
6032 }
6033
6034 #[test]
6037 fn margin_open_order_that_is_not_live_is_dropped() {
6038 let inst = InstrumentNameExchange::new("BTCUSDT");
6039 let mut o = open_order(Some(42), "BUY");
6040 o.status = Some("CANCELED".to_string());
6041 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst).is_none());
6042 }
6043
6044 #[test]
6045 fn margin_open_order_missing_order_id_is_dropped() {
6046 let inst = InstrumentNameExchange::new("BTCUSDT");
6047 assert!(
6048 convert_open_order(&open_order(None, "BUY"), ExchangeId::BinanceMargin, &inst)
6049 .is_none()
6050 );
6051 }
6052
6053 #[test]
6059 fn margin_open_order_prefers_update_time_over_creation_time() {
6060 let inst = InstrumentNameExchange::new("BTCUSDT");
6061
6062 let mut updated = open_order(Some(42), "BUY");
6063 updated.update_time = Some(1_700_000_005_000);
6064 let order =
6065 convert_open_order(&updated, ExchangeId::BinanceMargin, &inst).expect("convert");
6066 assert_eq!(
6067 order.state.time_exchange,
6068 Utc.timestamp_millis_opt(1_700_000_005_000)
6069 .single()
6070 .unwrap(),
6071 "update_time must win over time"
6072 );
6073 }
6074
6075 #[test]
6078 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
6079 let inst = InstrumentNameExchange::new("BTCUSDT");
6080
6081 let base = open_order(Some(42), "BUY");
6082 assert!(base.update_time.is_none(), "fixture must omit update_time");
6083 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst).expect("convert");
6084 assert_eq!(
6085 order.state.time_exchange,
6086 Utc.timestamp_millis_opt(1_700_000_000_000)
6087 .single()
6088 .unwrap(),
6089 "absent update_time falls back to time"
6090 );
6091 }
6092
6093 #[test]
6094 fn margin_open_order_owned_symbol_recovers_instrument() {
6095 let mut o = open_order(Some(42), "SELL");
6097 o.symbol = Some("ETHUSDT".to_string());
6098 let order =
6099 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).expect("convert");
6100 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
6101 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
6102 assert_eq!(order.side, Side::Sell);
6103 }
6104
6105 #[test]
6106 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
6107 let o = open_order(Some(42), "BUY");
6109 assert!(o.symbol.is_none());
6110 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).is_none());
6111 }
6112
6113 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
6114 let mut t = QueryMarginAccountsTradeListResponseInner::new();
6115 t.id = id;
6116 t.order_id = Some(7);
6117 t.is_buyer = is_buyer;
6118 t.price = Some("48000".to_string());
6119 t.qty = Some("0.25".to_string());
6120 t.commission = Some("0.001".to_string());
6121 t.commission_asset = Some("BNB".to_string());
6122 t.time = Some(1_700_000_000_000);
6123 t
6124 }
6125
6126 #[test]
6127 fn margin_trade_converts() {
6128 let inst = InstrumentNameExchange::new("BTCUSDT");
6129 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
6130 assert_eq!(tr.id.0.as_str(), "11");
6131 assert_eq!(tr.order_id.0.as_str(), "7");
6132 assert_eq!(tr.side, Side::Sell);
6133 assert_eq!(tr.price, Decimal::from(48_000));
6134 assert_eq!(tr.quantity, Decimal::new(25, 2));
6135 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
6137 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
6138 }
6139
6140 #[test]
6141 fn margin_trade_missing_fields_are_dropped() {
6142 let inst = InstrumentNameExchange::new("BTCUSDT");
6143 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
6145 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
6146 }
6147
6148 #[test]
6151 fn token_renew_after_applies_margin_and_floor() {
6152 let now_ms = Utc::now().timestamp_millis();
6153 let far = token_renew_after(now_ms + 86_400_000).as_secs();
6155 assert!(
6156 far > 80_000 && far <= 86_400,
6157 "expected ~24h-minus-margin, got {far}"
6158 );
6159 assert_eq!(
6161 token_renew_after(now_ms - 10_000).as_secs(),
6162 TOKEN_MIN_LIFETIME_SECS
6163 );
6164 assert_eq!(
6166 token_renew_after(now_ms + 60_000).as_secs(),
6167 TOKEN_MIN_LIFETIME_SECS
6168 );
6169 }
6170
6171 fn push(event: serde_json::Value) -> String {
6176 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
6177 }
6178
6179 fn cross_outcome(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> FrameOutcome {
6180 convert_margin_user_data_events_with(
6181 frame,
6182 buf,
6183 &mut cross_account_position_handler,
6184 &mut cross_notice_instrument,
6185 )
6186 }
6187
6188 #[test]
6191 fn margin_ws_rpc_ack_yields_no_events() {
6192 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
6193 let mut buf = Vec::new();
6194 assert_eq!(cross_outcome(&frame, &mut buf), FrameOutcome::Response);
6195 assert!(buf.is_empty());
6196 }
6197
6198 #[test]
6201 fn margin_ws_unrecognised_frames_yield_no_events() {
6202 for frame in [
6203 r#"{"e":"eventStreamTerminated","E":1}"#,
6204 r#"{"subscriptionId":1,"event":{"E":1}}"#,
6205 ] {
6206 let mut buf = Vec::new();
6207 assert_eq!(
6208 cross_outcome(frame, &mut buf),
6209 FrameOutcome::Pushed { terminated: false },
6210 "{frame}"
6211 );
6212 assert!(buf.is_empty(), "{frame}");
6213 }
6214 }
6215
6216 #[test]
6219 fn margin_ws_trailing_stop_report_decodes_as_a_trailing_stop() {
6220 for trailing_delta in [serde_json::json!(25), serde_json::json!("25")] {
6221 let frame = push(serde_json::json!({
6222 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "STOP_LOSS",
6223 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "cid-1", "t": -1_i64,
6224 "p": "0.00000000", "P": "0.00000000", "d": trailing_delta, "D": 1_700_000_000_000_i64,
6225 "q": "2", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
6226 }));
6227 let mut buf = Vec::new();
6228 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6229 let [event] = buf.as_slice() else {
6230 panic!("one snapshot expected for {frame}, got {buf:?}");
6231 };
6232 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6233 panic!("expected an order snapshot, got {event:?}");
6234 };
6235 assert_eq!(
6236 snapshot.0.kind,
6237 OrderKind::TrailingStop {
6238 offset: Decimal::from(25),
6239 offset_type: TrailingOffsetType::BasisPoints,
6240 activation_price: None,
6241 }
6242 );
6243 assert_eq!(snapshot.0.price, None);
6244 }
6245 }
6246
6247 #[test]
6255 fn margin_ws_execution_report_trade_maps_to_trade_and_order_snapshot() {
6256 let frame = push(serde_json::json!({
6257 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6258 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
6259 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
6260 "p": "48000", "q": "2", "f": "GTC",
6261 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
6262 }));
6263 let mut buf = Vec::new();
6264 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6265 assert_eq!(
6266 buf.len(),
6267 2,
6268 "a fill must produce the execution and the order snapshot, got: {buf:?}"
6269 );
6270 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
6271 match &buf[0].kind {
6272 AccountEventKind::Trade(t) => {
6273 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
6274 assert_eq!(t.side, Side::Buy);
6275 assert_eq!(t.price, Decimal::from(48_000));
6276 assert_eq!(t.quantity, Decimal::new(5, 1));
6277 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
6279 assert_eq!(t.client_order_id, Some(ClientOrderId::new("cid-1")));
6280 }
6281 other => panic!("expected Trade, got {other:?}"),
6282 }
6283 assert_eq!(buf[1].exchange, ExchangeId::BinanceMargin);
6284 match &buf[1].kind {
6285 AccountEventKind::OrderSnapshot(snap) => {
6286 assert_eq!(
6287 snap.0.quantity,
6288 Decimal::from(2),
6289 "the order's total quantity"
6290 );
6291 match &snap.0.state {
6292 OrderState::Active(ActiveOrderState::Open(open)) => {
6293 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("12345"));
6294 assert_eq!(
6295 open.filled_quantity,
6296 Decimal::new(5, 1),
6297 "the snapshot must carry the order's cumulative filled quantity (z)"
6298 );
6299 assert_eq!(
6300 open.time_exchange.timestamp_millis(),
6301 1_700_000_000_000,
6302 "stamped with the execution's transaction time (T), which is what \
6303 gives the engine's staleness gate an ordering key"
6304 );
6305 }
6306 other => panic!("expected an Open order, got {other:?}"),
6307 }
6308 }
6309 other => panic!("expected OrderSnapshot, got {other:?}"),
6310 }
6311 }
6312
6313 #[test]
6317 fn margin_trade_for_an_order_no_longer_working_emits_no_snapshot() {
6318 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
6319 let frame = push(serde_json::json!({
6320 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6321 "x": "TRADE", "X": status, "i": 12_345_i64, "c": "cid-1",
6322 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
6323 "p": "48000", "q": "2", "f": "GTC", "T": 1_700_000_000_000_i64,
6324 }));
6325 let mut buf = Vec::new();
6326 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6327 assert_eq!(
6328 buf.len(),
6329 1,
6330 "status {status} must produce the execution alone, got: {buf:?}"
6331 );
6332 assert!(
6333 matches!(buf[0].kind, AccountEventKind::Trade(_)),
6334 "the execution must still be reported for status {status}"
6335 );
6336 }
6337 }
6338
6339 #[test]
6340 fn margin_ws_new_report_maps_to_active_order_snapshot() {
6341 let frame = push(serde_json::json!({
6342 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
6343 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
6344 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
6345 }));
6346 let mut buf = Vec::new();
6347 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6348 assert_eq!(buf.len(), 1);
6349 match &buf[0].kind {
6350 AccountEventKind::OrderSnapshot(snap) => {
6351 assert_eq!(snap.0.side, Side::Sell);
6352 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
6353 assert_eq!(snap.0.quantity, Decimal::from(1));
6354 assert!(
6355 matches!(snap.0.state, OrderState::Active(_)),
6356 "NEW should be an active (resting) order"
6357 );
6358 }
6359 other => panic!("expected OrderSnapshot, got {other:?}"),
6360 }
6361 }
6362
6363 #[test]
6364 fn margin_ws_canceled_report_maps_to_order_cancelled() {
6365 let frame = push(serde_json::json!({
6366 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6367 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
6368 "z": "0", "T": 1_700_000_000_000_i64,
6369 }));
6370 let mut buf = Vec::new();
6371 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6372 assert_eq!(buf.len(), 1);
6373 match &buf[0].kind {
6374 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
6375 other => panic!("expected OrderCancelled, got {other:?}"),
6376 }
6377 }
6378
6379 #[test]
6382 fn margin_ws_trade_without_a_client_order_id_reports_none() {
6383 let frame = push(serde_json::json!({
6384 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "MARKET",
6385 "x": "TRADE", "X": "FILLED", "i": 12_345_i64,
6386 "t": 99_i64, "l": "1", "L": "48000", "z": "1",
6387 "q": "1", "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
6388 }));
6389 let mut buf = Vec::new();
6390 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6391 let AccountEventKind::Trade(trade) = &buf[0].kind else {
6392 panic!("expected Trade, got {:?}", buf[0].kind);
6393 };
6394 assert_eq!(trade.order_id.0.as_str(), "12345");
6395 assert_eq!(trade.client_order_id, None);
6396 }
6397
6398 #[test]
6399 fn margin_ws_execution_report_missing_symbol_is_dropped() {
6400 let frame = push(serde_json::json!({
6402 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
6403 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
6404 }));
6405 let mut buf = Vec::new();
6406 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6407 assert!(buf.is_empty());
6408 }
6409
6410 #[test]
6411 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
6412 let frame = push(serde_json::json!({
6413 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
6414 "B": [
6415 { "a": "USDT", "f": "100.0", "l": "5.0" },
6416 { "a": "BTC", "f": "0.5", "l": "0" },
6417 ],
6418 }));
6419 let mut buf = Vec::new();
6420 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6421 assert_eq!(buf.len(), 2);
6422 for ev in &buf {
6424 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
6425 }
6426 }
6427
6428 #[test]
6429 fn margin_ws_stream_terminated_signals_reconnect() {
6430 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
6431 let mut buf = Vec::new();
6432 assert!(
6433 convert_margin_user_data_events(&frame, &mut buf),
6434 "eventStreamTerminated must signal reconnect"
6435 );
6436 assert!(buf.is_empty());
6437 }
6438
6439 #[test]
6440 fn margin_ws_liability_change_is_observable_only() {
6441 let mut buf = Vec::new();
6444 assert!(!convert_margin_user_data_events(
6445 &push(documented_liability_change("USER_LIABILITY_CHANGE")),
6446 &mut buf
6447 ));
6448 assert!(buf.is_empty());
6449 }
6450
6451 fn only_notice(buf: &[UnindexedAccountEvent]) -> &AccountNotice<InstrumentNameExchange> {
6453 match buf {
6454 [
6455 UnindexedAccountEvent {
6456 exchange: ExchangeId::BinanceMargin,
6457 kind: AccountEventKind::Notice(notice),
6458 },
6459 ] => notice,
6460 other => panic!("expected one BinanceMargin notice, got {other:?}"),
6461 }
6462 }
6463
6464 #[test]
6465 fn margin_ws_margin_level_change_is_forwarded_as_a_notice() {
6466 let mut buf = Vec::new();
6467 assert!(!convert_margin_user_data_events(
6468 &push(documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE")),
6469 &mut buf
6470 ));
6471 assert_eq!(
6472 only_notice(&buf),
6473 &AccountNotice::new(
6474 NoticeKind::MarginCall,
6475 None,
6476 Utc.timestamp_millis_opt(1_573_200_697_110).unwrap(),
6477 Some(Decimal::new(125, 2)),
6478 SmolStr::new("MARGIN_CALL"),
6479 ),
6480 "cross names no instrument"
6481 );
6482 }
6483
6484 #[test]
6485 fn margin_level_statuses_map_to_notice_kinds() {
6486 for (status, kind) in [
6487 ("MARGIN_CALL", NoticeKind::MarginCall),
6488 ("PRE_LIQUIDATION", NoticeKind::LiquidationWarning),
6489 ("FORCE_LIQUIDATION", NoticeKind::Liquidation),
6490 ("NORMAL", NoticeKind::MarginRestored),
6491 ("EXCESSIVE", NoticeKind::MarginRestored),
6492 ("SOME_FUTURE_STATUS", NoticeKind::Other),
6493 ] {
6494 let mut buf = Vec::new();
6495 let frame = push(serde_json::json!({
6496 "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": 1_700_000_000_000_i64, "l": "1.5",
6497 "s": status,
6498 }));
6499 convert_margin_user_data_events(&frame, &mut buf);
6500 let notice = only_notice(&buf);
6501 assert_eq!(notice.kind, kind, "{status}");
6502 assert_eq!(notice.status, status, "the raw status is kept");
6503 }
6504 }
6505
6506 #[test]
6507 fn a_margin_level_change_missing_parts_is_still_delivered_where_it_can_be() {
6508 let mut buf = Vec::new();
6510 convert_margin_user_data_events(
6511 &push(serde_json::json!({
6512 "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": 1_700_000_000_000_i64, "l": "n/a",
6513 "s": "FORCE_LIQUIDATION",
6514 })),
6515 &mut buf,
6516 );
6517 let notice = only_notice(&buf);
6518 assert_eq!(notice.kind, NoticeKind::Liquidation);
6519 assert_eq!(notice.margin_level, None);
6520
6521 let before = Utc::now();
6523 let mut buf = Vec::new();
6524 convert_margin_user_data_events(
6525 &push(serde_json::json!({ "e": "MARGIN_LEVEL_STATUS_CHANGE", "s": "MARGIN_CALL" })),
6526 &mut buf,
6527 );
6528 assert!(only_notice(&buf).time_exchange >= before);
6529
6530 let mut buf = Vec::new();
6532 convert_margin_user_data_events(
6533 &push(serde_json::json!({
6534 "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": 1_700_000_000_000_i64, "l": "1.5",
6535 })),
6536 &mut buf,
6537 );
6538 assert!(buf.is_empty());
6539 }
6540
6541 fn documented_liability_change(e: &str) -> serde_json::Value {
6543 serde_json::json!({
6544 "e": e, "E": 1_573_200_697_110_i64, "a": "BTC", "t": "BORROW",
6545 "p": "1.03453430", "i": "0",
6546 })
6547 }
6548
6549 fn documented_margin_level_change(e: &str) -> serde_json::Value {
6551 serde_json::json!({
6552 "e": e, "E": 1_573_200_697_110_i64, "l": "1.25", "s": "MARGIN_CALL",
6553 })
6554 }
6555
6556 #[test]
6557 fn margin_ws_margin_specific_events_match_their_documented_names() {
6558 for (e, frame, expected) in [
6563 (
6564 "USER_LIABILITY_CHANGE",
6565 documented_liability_change("USER_LIABILITY_CHANGE"),
6566 "USER_LIABILITY_CHANGE (observable",
6567 ),
6568 (
6569 "MARGIN_LEVEL_STATUS_CHANGE",
6570 documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE"),
6571 "MARGIN_LEVEL_STATUS_CHANGE received",
6572 ),
6573 (
6575 "userLiabilityChange",
6576 documented_liability_change("userLiabilityChange"),
6577 "USER_LIABILITY_CHANGE (observable",
6578 ),
6579 (
6580 "marginLevelStatusChange",
6581 documented_margin_level_change("marginLevelStatusChange"),
6582 "MARGIN_LEVEL_STATUS_CHANGE received",
6583 ),
6584 ] {
6585 let mut buf = Vec::new();
6586 let (terminated, messages) =
6587 log_messages(|| convert_margin_user_data_events(&push(frame), &mut buf));
6588 assert!(!terminated, "{e}");
6589 assert_eq!(
6591 buf.len(),
6592 usize::from(expected.starts_with("MARGIN")),
6593 "{e}"
6594 );
6595 assert!(
6596 messages.iter().any(|m| m.contains(expected)),
6597 "{e}: expected its own arm's log, got {messages:?}"
6598 );
6599 assert!(
6600 !messages.iter().any(|m| m.contains("undeserializable")
6601 || m.contains("unhandled")
6602 || m.contains("does not handle")),
6603 "{e}: {messages:?}"
6604 );
6605 }
6606 }
6607
6608 fn log_messages<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
6612 use std::sync::{Arc, Mutex};
6613 use tracing_subscriber::layer::SubscriberExt;
6614
6615 #[derive(Default)]
6616 struct Messages(Arc<Mutex<Vec<String>>>);
6617
6618 struct Message<'a>(&'a mut String);
6619
6620 impl tracing::field::Visit for Message<'_> {
6621 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
6622 if field.name() == "message" {
6623 *self.0 = format!("{value:?}");
6624 }
6625 }
6626 }
6627
6628 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Messages {
6629 fn on_event(
6630 &self,
6631 event: &tracing::Event<'_>,
6632 _: tracing_subscriber::layer::Context<'_, S>,
6633 ) {
6634 let mut message = String::new();
6635 event.record(&mut Message(&mut message));
6636 self.0.lock().unwrap().push(message);
6637 }
6638 }
6639
6640 let layer = Messages::default();
6641 let messages = Arc::clone(&layer.0);
6642 let value =
6644 tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), f);
6645 let messages = messages.lock().unwrap().clone();
6646 (value, messages)
6647 }
6648
6649 #[test]
6650 fn margin_ws_unhandled_event_types_are_ignored_without_teardown() {
6651 for (e, expected) in [
6654 ("listStatus", "ignoring listStatus"),
6655 ("someFutureEvent", "of a type this client does not handle"),
6657 ] {
6658 let mut buf = Vec::new();
6659 let frame = push(serde_json::json!({ "e": e, "E": 1_700_000_000_000_i64 }));
6660 let (terminated, messages) =
6661 log_messages(|| convert_margin_user_data_events(&frame, &mut buf));
6662 assert!(!terminated, "{e}");
6663 assert!(buf.is_empty(), "{e}");
6664 assert!(
6665 messages.iter().any(|m| m.contains(expected)),
6666 "{e}: {messages:?}"
6667 );
6668 }
6669 }
6670
6671 #[test]
6674 fn margin_trade_event_dedup_by_key() {
6675 let frame = push(serde_json::json!({
6678 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6679 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
6680 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
6681 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
6682 }));
6683 let mut buf = Vec::new();
6684 convert_margin_user_data_events(&frame, &mut buf);
6685 let event = buf.first().expect("a Trade event");
6688 assert!(
6689 matches!(event.kind, AccountEventKind::Trade(_)),
6690 "the execution is the first of the two events a fill produces"
6691 );
6692
6693 let cache = new_dedup_cache();
6694 let first = dedup_key_from_event(event).expect("Trade events have a dedup key");
6696 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
6697 let second = dedup_key_from_event(event).expect("Trade events have a dedup key");
6698 assert!(
6699 is_duplicate(&cache, second),
6700 "second sighting is a duplicate"
6701 );
6702 }
6703
6704 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
6712 let cache = new_dedup_cache();
6713 let mut delivered = Vec::new();
6714 let mut buf = Vec::new();
6715 for frame in frames {
6716 let _ = convert_margin_user_data_events(frame, &mut buf);
6717 for ev in buf.drain(..) {
6718 if let Some(key) = dedup_key_from_event(&ev)
6719 && is_duplicate(&cache, key)
6720 {
6721 continue;
6722 }
6723 delivered.push(ev);
6724 }
6725 }
6726 delivered
6727 }
6728
6729 fn ws_new(cumulative_filled: &str) -> String {
6730 push(serde_json::json!({
6731 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6732 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "client-1",
6733 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
6734 "T": 1_700_000_000_000_i64,
6735 }))
6736 }
6737
6738 fn ws_partial_fill(cumulative_filled: &str) -> String {
6739 push(serde_json::json!({
6740 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6741 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "client-1",
6742 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
6743 "l": "1", "L": "100", "t": 555_i64, "n": "0.1", "N": "USDT",
6744 "T": 1_700_000_001_000_i64,
6745 }))
6746 }
6747
6748 fn open_filled_quantities(events: &[UnindexedAccountEvent]) -> Vec<Decimal> {
6749 events
6750 .iter()
6751 .filter_map(|ev| match &ev.kind {
6752 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
6753 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
6754 _ => None,
6755 },
6756 _ => None,
6757 })
6758 .collect()
6759 }
6760
6761 #[test]
6769 fn margin_ws_fill_snapshot_survives_the_dedup_gate() {
6770 let delivered = drive_ws_pipeline(&[&ws_new("0"), &ws_partial_fill("1")]);
6771
6772 assert_eq!(
6773 delivered.len(),
6774 3,
6775 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
6776 );
6777 assert_eq!(
6778 open_filled_quantities(&delivered),
6779 vec![Decimal::ZERO, Decimal::ONE],
6780 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
6781 );
6782 }
6783
6784 #[test]
6787 fn margin_ws_replayed_frame_is_still_deduplicated() {
6788 let delivered = drive_ws_pipeline(&[
6789 &ws_new("0"),
6790 &ws_partial_fill("1"),
6791 &ws_new("0"),
6792 &ws_partial_fill("1"),
6793 ]);
6794
6795 assert_eq!(
6796 delivered.len(),
6797 3,
6798 "re-delivering both frames must add nothing, got: {delivered:?}"
6799 );
6800 }
6801
6802 #[test]
6808 fn margin_ws_an_ack_and_its_fill_reporting_one_state_deliver_that_state_once() {
6809 let delivered = drive_ws_pipeline(&[&ws_new("1"), &ws_partial_fill("1")]);
6810
6811 assert_eq!(
6812 delivered.len(),
6813 2,
6814 "the ack snapshot and the execution, with the fill's duplicate snapshot collapsed, \
6815 got: {delivered:?}"
6816 );
6817 assert!(
6818 delivered
6819 .iter()
6820 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
6821 "the execution must reach the consumer regardless"
6822 );
6823 assert_eq!(
6824 open_filled_quantities(&delivered),
6825 vec![Decimal::ONE],
6826 "one snapshot, carrying the state both reports agree on"
6827 );
6828 }
6829
6830 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
6835 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
6836 }
6837
6838 fn isolated_handler(
6841 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
6842 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
6843 ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
6844 move |position, subscription_id, buf| {
6845 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
6846 }
6847 }
6848
6849 fn btcusdt() -> InstrumentNameExchange {
6850 InstrumentNameExchange::new("BTCUSDT")
6851 }
6852
6853 fn isolated_balances(buf: &[UnindexedAccountEvent]) -> Vec<(&str, &str, Decimal, Decimal)> {
6856 buf.iter()
6857 .map(|event| match &event.kind {
6858 AccountEventKind::InstrumentBalanceUpdate(ibu) => (
6859 ibu.instrument.name().as_str(),
6860 ibu.balance.asset.name().as_str(),
6861 ibu.balance.update.free,
6862 ibu.balance.update.locked,
6863 ),
6864 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
6865 })
6866 .collect()
6867 }
6868
6869 fn token(expiration_time_ms: i64) -> UserListenToken {
6870 UserListenToken {
6871 token: "t".to_string(),
6872 expiration_time_ms,
6873 }
6874 }
6875
6876 #[test]
6877 fn listen_token_query_cross_vs_isolated() {
6878 assert!(build_listen_token_query(None).is_empty());
6880
6881 let q = build_listen_token_query(Some(&btcusdt()));
6883 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
6884 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
6885 assert_eq!(q.len(), 2);
6886 }
6887
6888 #[test]
6889 fn earliest_token_expiry_picks_min() {
6890 let tokens = vec![
6891 (btcusdt(), token(3_000)),
6892 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
6893 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
6894 ];
6895 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
6896 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
6898 }
6899
6900 #[test]
6901 fn base_quote_map_extracts_base_and_quote() {
6902 let entries = vec![
6903 iso_entry(
6904 "BTCUSDT",
6905 iso_base("BTC", "0", "0", "0", "0"),
6906 iso_quote("USDT", "0", "0", "0", "0"),
6907 None,
6908 None,
6909 None,
6910 ),
6911 iso_entry(
6912 "ETHBTC",
6913 iso_base("ETH", "0", "0", "0", "0"),
6914 iso_quote("BTC", "0", "0", "0", "0"),
6915 None,
6916 None,
6917 None,
6918 ),
6919 ];
6920 let map = build_base_quote_map(&entries);
6921 assert_eq!(
6922 map.get(&btcusdt())
6923 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
6924 Some(("BTC", "USDT"))
6925 );
6926 assert_eq!(
6929 map.get(&InstrumentNameExchange::new("ETHBTC"))
6930 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
6931 Some(("ETH", "BTC"))
6932 );
6933 }
6934
6935 #[test]
6936 fn isolated_margin_level_change_names_its_subscriptions_pair_or_none() {
6937 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
6938 let notice_on = |subscription_id: i64| {
6939 let mut buf = Vec::new();
6940 let frame = push_with_sub(
6941 subscription_id,
6942 documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE"),
6943 );
6944 assert_eq!(
6945 convert_margin_user_data_events_with(
6946 &frame,
6947 &mut buf,
6948 &mut cross_account_position_handler,
6949 &mut |id| isolated_notice_instrument(id, &sub_map),
6950 ),
6951 FrameOutcome::Pushed { terminated: false }
6952 );
6953 only_notice(&buf).instrument.clone()
6954 };
6955
6956 assert_eq!(notice_on(7), Some(btcusdt()));
6957 assert_eq!(notice_on(99), None);
6959 assert_eq!(isolated_notice_instrument(None, &sub_map), None);
6961 }
6962
6963 #[test]
6964 fn the_two_streams_key_one_notice_alike() {
6965 use crate::client::binance::margin_risk::{RiskFrame, convert_risk_frame};
6967
6968 let change = documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE");
6969 let mut from_user_data = Vec::new();
6970 convert_margin_user_data_events(&push(change.clone()), &mut from_user_data);
6971 let mut from_risk = Vec::new();
6972 let risk_frame = serde_json::json!({ "stream": "listen-key", "data": change });
6973 assert_eq!(
6974 convert_risk_frame(&risk_frame.to_string(), "listen-key", &mut from_risk),
6975 RiskFrame::Continue
6976 );
6977
6978 let dedup = new_dedup_cache();
6979 let key = |events: &[UnindexedAccountEvent]| dedup_key_from_event(&events[0]).unwrap();
6980 assert!(!is_duplicate(&dedup, key(&from_user_data)));
6981 assert!(
6982 is_duplicate(&dedup, key(&from_risk)),
6983 "the risk data stream's copy is dropped"
6984 );
6985 }
6986
6987 #[test]
6988 fn a_notice_from_both_streams_is_delivered_once() {
6989 let dedup = new_dedup_cache();
6993 let notice = |time_ms: i64| {
6994 let mut buf = Vec::new();
6995 convert_margin_user_data_events(
6996 &push(serde_json::json!({
6997 "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": time_ms, "l": "1.1",
6998 "s": "MARGIN_CALL",
6999 })),
7000 &mut buf,
7001 );
7002 buf.pop().unwrap()
7003 };
7004 let is_new = |event: &UnindexedAccountEvent| {
7005 !is_duplicate(&dedup, dedup_key_from_event(event).unwrap())
7006 };
7007
7008 assert!(is_new(¬ice(1_700_000_000_000)));
7009 assert!(!is_new(¬ice(1_700_000_000_000)), "second copy dropped");
7010 assert!(is_new(¬ice(1_700_000_060_000)), "a later notice is new");
7011 }
7012
7013 #[test]
7014 fn isolated_outbound_position_routes_to_instrument_balance_update() {
7015 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7016 let base_quote = Arc::new(HashMap::from([(
7017 btcusdt(),
7018 (
7019 AssetNameExchange::new("BTC"),
7020 AssetNameExchange::new("USDT"),
7021 ),
7022 )]));
7023 let mut handler = isolated_handler(sub_map, base_quote);
7024
7025 let frame = push_with_sub(
7027 7,
7028 serde_json::json!({
7029 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7030 "B": [
7031 { "a": "USDT", "f": "1000.0", "l": "50.0" },
7032 { "a": "BTC", "f": "0.5", "l": "0.1" },
7033 ],
7034 }),
7035 );
7036 let mut buf = Vec::new();
7037 assert_eq!(
7038 convert_margin_user_data_events_with(
7039 &frame,
7040 &mut buf,
7041 &mut handler,
7042 &mut cross_notice_instrument,
7043 ),
7044 FrameOutcome::Pushed { terminated: false }
7045 );
7046 assert_eq!(
7049 isolated_balances(&buf),
7050 vec![
7051 ("BTCUSDT", "USDT", Decimal::from(1000), Decimal::from(50)),
7052 ("BTCUSDT", "BTC", Decimal::new(5, 1), Decimal::new(1, 1)),
7053 ]
7054 );
7055 let time = Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
7056 assert!(buf.iter().all(|event| matches!(
7057 &event.kind,
7058 AccountEventKind::InstrumentBalanceUpdate(ibu) if ibu.balance.time_exchange == time
7059 )));
7060 }
7061
7062 #[test]
7063 fn isolated_outbound_position_unknown_subscription_dropped() {
7064 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7066 let base_quote = Arc::new(HashMap::from([(
7067 btcusdt(),
7068 (
7069 AssetNameExchange::new("BTC"),
7070 AssetNameExchange::new("USDT"),
7071 ),
7072 )]));
7073 let mut handler = isolated_handler(sub_map, base_quote);
7074
7075 let frame = push_with_sub(
7076 99,
7077 serde_json::json!({
7078 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7079 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
7080 }),
7081 );
7082 let mut buf = Vec::new();
7083 assert_eq!(
7084 convert_margin_user_data_events_with(
7085 &frame,
7086 &mut buf,
7087 &mut handler,
7088 &mut cross_notice_instrument,
7089 ),
7090 FrameOutcome::Pushed { terminated: false }
7091 );
7092 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
7093 }
7094
7095 #[test]
7096 fn isolated_outbound_position_with_one_side_emits_that_side() {
7097 for (listed, free) in [("BTC", Decimal::new(5, 1)), ("USDT", Decimal::from(20))] {
7100 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7101 let base_quote = Arc::new(HashMap::from([(
7102 btcusdt(),
7103 (
7104 AssetNameExchange::new("BTC"),
7105 AssetNameExchange::new("USDT"),
7106 ),
7107 )]));
7108 let mut handler = isolated_handler(sub_map, base_quote);
7109
7110 let frame = push_with_sub(
7111 7,
7112 serde_json::json!({
7113 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7114 "B": [{ "a": listed, "f": free.to_string(), "l": "0" }],
7115 }),
7116 );
7117 let mut buf = Vec::new();
7118 assert_eq!(
7119 convert_margin_user_data_events_with(
7120 &frame,
7121 &mut buf,
7122 &mut handler,
7123 &mut cross_notice_instrument,
7124 ),
7125 FrameOutcome::Pushed { terminated: false }
7126 );
7127 assert_eq!(
7128 isolated_balances(&buf),
7129 vec![("BTCUSDT", listed, free, Decimal::ZERO)],
7130 "only {listed} listed"
7131 );
7132 }
7133 }
7134
7135 #[test]
7136 fn isolated_outbound_position_skips_assets_outside_the_pair() {
7137 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7140 let base_quote = Arc::new(HashMap::from([(
7141 btcusdt(),
7142 (
7143 AssetNameExchange::new("BTC"),
7144 AssetNameExchange::new("USDT"),
7145 ),
7146 )]));
7147 let mut handler = isolated_handler(sub_map, base_quote);
7148
7149 let mixed = push_with_sub(
7150 7,
7151 serde_json::json!({
7152 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7153 "B": [{ "a": "BNB", "f": "3", "l": "0" }, { "a": "USDT", "f": "20", "l": "1" }],
7154 }),
7155 );
7156 let mut buf = Vec::new();
7157 assert_eq!(
7158 convert_margin_user_data_events_with(
7159 &mixed,
7160 &mut buf,
7161 &mut handler,
7162 &mut cross_notice_instrument,
7163 ),
7164 FrameOutcome::Pushed { terminated: false }
7165 );
7166 assert_eq!(
7167 isolated_balances(&buf),
7168 vec![("BTCUSDT", "USDT", Decimal::from(20), Decimal::ONE)]
7169 );
7170
7171 let stray_only = push_with_sub(
7172 7,
7173 serde_json::json!({
7174 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7175 "B": [{ "a": "BNB", "f": "3", "l": "0" }],
7176 }),
7177 );
7178 let mut buf = Vec::new();
7179 assert_eq!(
7180 convert_margin_user_data_events_with(
7181 &stray_only,
7182 &mut buf,
7183 &mut handler,
7184 &mut cross_notice_instrument,
7185 ),
7186 FrameOutcome::Pushed { terminated: false }
7187 );
7188 assert!(buf.is_empty(), "no asset of the pair → nothing emitted");
7189 }
7190
7191 #[test]
7192 fn isolated_outbound_position_unparseable_entry_keeps_the_other_side() {
7193 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7195 let base_quote = Arc::new(HashMap::from([(
7196 btcusdt(),
7197 (
7198 AssetNameExchange::new("BTC"),
7199 AssetNameExchange::new("USDT"),
7200 ),
7201 )]));
7202 let mut handler = isolated_handler(sub_map, base_quote);
7203
7204 let frame = push_with_sub(
7205 7,
7206 serde_json::json!({
7207 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7208 "B": [{ "a": "BTC", "f": "x", "l": "0" }, { "a": "USDT", "f": "20", "l": "1" }],
7209 }),
7210 );
7211 let mut buf = Vec::new();
7212 assert_eq!(
7213 convert_margin_user_data_events_with(
7214 &frame,
7215 &mut buf,
7216 &mut handler,
7217 &mut cross_notice_instrument,
7218 ),
7219 FrameOutcome::Pushed { terminated: false }
7220 );
7221 assert_eq!(
7222 isolated_balances(&buf),
7223 vec![("BTCUSDT", "USDT", Decimal::from(20), Decimal::ONE)]
7224 );
7225 }
7226
7227 #[test]
7228 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
7229 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
7233 let base_quote = Arc::new(HashMap::new());
7234 let mut handler = isolated_handler(sub_map, base_quote);
7235
7236 let frame = push_with_sub(
7237 42,
7238 serde_json::json!({
7239 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
7240 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
7241 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
7242 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
7243 }),
7244 );
7245 let mut buf = Vec::new();
7246 assert_eq!(
7247 convert_margin_user_data_events_with(
7248 &frame,
7249 &mut buf,
7250 &mut handler,
7251 &mut cross_notice_instrument,
7252 ),
7253 FrameOutcome::Pushed { terminated: false }
7254 );
7255 assert_eq!(buf.len(), 2, "the execution and its order snapshot");
7256 match &buf[0].kind {
7257 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
7258 other => panic!("expected Trade, got {other:?}"),
7259 }
7260 match &buf[1].kind {
7261 AccountEventKind::OrderSnapshot(snap) => {
7262 assert_eq!(snap.0.key.instrument.name().as_str(), "BTCUSDT");
7263 }
7264 other => panic!("expected OrderSnapshot, got {other:?}"),
7265 }
7266 }
7267
7268 struct MarginMyTradesVenue {
7279 trades: Vec<(i64, i64, i64)>,
7281 end_exclusive: bool,
7282 }
7283
7284 impl wiremock::Respond for MarginMyTradesVenue {
7285 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
7286 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
7287 let query: std::collections::HashMap<String, String> =
7288 request.url.query_pairs().into_owned().collect();
7289 let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
7290 let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
7291 let order = param("orderId");
7292
7293 let mut selected: Vec<&(i64, i64, i64)> = self
7294 .trades
7295 .iter()
7296 .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
7297 .collect();
7298 if let Some(from_id) = param("fromId") {
7299 selected.retain(|(id, _, _)| *id >= from_id);
7300 } else if order.is_none() {
7301 let start = param("startTime").expect("a query by time sends startTime");
7302 let end = match param("endTime") {
7303 Some(end) if end - start >= DAY_MS => {
7304 return wiremock::ResponseTemplate::new(400).set_body_json(
7305 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
7306 );
7307 }
7308 Some(end) if self.end_exclusive => end - 1,
7309 Some(end) => end,
7310 None => start + DAY_MS - 1,
7311 };
7312 selected.retain(|(_, time, _)| (start..=end).contains(time));
7313 }
7314
7315 let page: Vec<serde_json::Value> = selected
7316 .into_iter()
7317 .take(limit)
7318 .map(|(id, time, order_id)| {
7319 serde_json::json!({
7320 "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
7321 "price": "50000", "qty": "0.01", "commission": "0",
7322 "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
7323 "isBestMatch": true, "isIsolated": true,
7324 })
7325 })
7326 .collect();
7327 wiremock::ResponseTemplate::new(200).set_body_json(page)
7328 }
7329 }
7330
7331 fn since(start: i64) -> MyTradesFrom {
7333 MyTradesFrom::Span {
7334 start,
7335 end: i64::MAX,
7336 }
7337 }
7338
7339 async fn read_margin_trades(
7342 venue: MarginMyTradesVenue,
7343 from: MyTradesFrom,
7344 ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
7345 let server = wiremock::MockServer::start().await;
7346 wiremock::Mock::given(wiremock::matchers::method("GET"))
7347 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7348 .respond_with(venue)
7349 .mount(&server)
7350 .await;
7351 let rest = Arc::new(MarginTradingRestApi::from_config(
7352 ConfigurationRestApi::builder()
7353 .api_key("key")
7354 .api_secret("secret")
7355 .base_path(server.uri())
7356 .build()
7357 .unwrap(),
7358 ));
7359
7360 let read = paginate_margin_my_trades(
7361 &rest,
7362 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7363 &InstrumentNameExchange::new("BTCUSDT"),
7364 from,
7365 true,
7366 RequestKind::Query,
7367 )
7368 .await
7369 .unwrap();
7370
7371 let requests = server
7372 .received_requests()
7373 .await
7374 .unwrap()
7375 .iter()
7376 .map(|request| request.url.query_pairs().into_owned().collect())
7377 .collect();
7378 (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
7379 }
7380
7381 #[tokio::test]
7384 async fn recovered_margin_fills_carry_their_orders_client_order_ids() {
7385 recovered_margin_fills_carry_client_order_ids(true).await;
7386 recovered_margin_fills_carry_client_order_ids(false).await;
7387 }
7388
7389 async fn recovered_margin_fills_carry_client_order_ids(is_isolated: bool) {
7390 let disconnect = Utc::now() - chrono::Duration::minutes(10);
7391 let reconnect = Utc::now() - chrono::Duration::minutes(5);
7392 let fill_time = (disconnect + chrono::Duration::minutes(1)).timestamp_millis();
7393 let server = wiremock::MockServer::start().await;
7394 wiremock::Mock::given(wiremock::matchers::method("GET"))
7395 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7396 .respond_with(MarginMyTradesVenue {
7397 trades: vec![(1, fill_time, 1), (2, fill_time, 2)],
7398 end_exclusive: false,
7399 })
7400 .mount(&server)
7401 .await;
7402 wiremock::Mock::given(wiremock::matchers::method("GET"))
7403 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7404 .and(wiremock::matchers::query_param("orderId", "1"))
7405 .and(wiremock::matchers::query_param(
7406 "isIsolated",
7407 if is_isolated { "TRUE" } else { "FALSE" },
7408 ))
7409 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7410 serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "repay-1"}),
7411 ))
7412 .mount(&server)
7413 .await;
7414 wiremock::Mock::given(wiremock::matchers::method("GET"))
7415 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7416 .and(wiremock::matchers::query_param("orderId", "2"))
7417 .respond_with(
7418 wiremock::ResponseTemplate::new(400).set_body_json(
7419 serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
7420 ),
7421 )
7422 .mount(&server)
7423 .await;
7424 let rest = Arc::new(MarginTradingRestApi::from_config(
7425 ConfigurationRestApi::builder()
7426 .api_key("key")
7427 .api_secret("secret")
7428 .base_path(server.uri())
7429 .build()
7430 .unwrap(),
7431 ));
7432 let (tx, mut rx) = mpsc::unbounded_channel();
7433 let mut unrecovered = UnrecoveredFills::default();
7434 unrecovered.open(
7435 &[InstrumentNameExchange::new("BTCUSDT")],
7436 disconnect,
7437 reconnect,
7438 );
7439
7440 recover_margin_fills(
7441 &rest,
7442 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7443 &mut unrecovered,
7444 &tx,
7445 &new_dedup_cache(),
7446 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7447 is_isolated,
7448 )
7449 .await;
7450
7451 let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
7452 .filter_map(|event| match event.kind {
7453 AccountEventKind::Trade(trade) => Some((
7454 trade.id.0.to_string(),
7455 trade.client_order_id,
7456 trade.order_filled_quantity.is_some(),
7457 )),
7458 _ => None,
7459 })
7460 .collect();
7461 cids.sort();
7462 assert_eq!(
7464 cids,
7465 [
7466 ("1".to_string(), Some(ClientOrderId::new("repay-1")), true),
7467 ("2".to_string(), None, true),
7468 ],
7469 "is_isolated: {is_isolated}"
7470 );
7471 let order_reads = server
7472 .received_requests()
7473 .await
7474 .unwrap()
7475 .iter()
7476 .filter(|request| request.url.path() == "/sapi/v1/margin/order")
7477 .count();
7478 assert_eq!(order_reads, 2, "one read per order");
7479 }
7480
7481 fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
7483 MarginMyTradesVenue {
7484 trades: trades
7485 .into_iter()
7486 .map(|(id, time)| (id, time, id))
7487 .collect(),
7488 end_exclusive: false,
7489 }
7490 }
7491
7492 const HOUR_MS: i64 = 60 * 60 * 1000;
7493
7494 #[tokio::test]
7497 async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
7498 let now = Utc::now().timestamp_millis();
7499 let (ids, _) = read_margin_trades(
7500 venue(vec![(7, now - 72 * HOUR_MS)]),
7501 since(now - 120 * HOUR_MS),
7502 )
7503 .await;
7504
7505 assert_eq!(ids, vec![7]);
7506 }
7507
7508 #[tokio::test]
7511 async fn margin_trades_read_fills_across_several_windows_once_each() {
7512 let now = Utc::now().timestamp_millis();
7513 let trades = vec![
7514 (1, now - 119 * HOUR_MS),
7515 (2, now - 72 * HOUR_MS),
7516 (3, now - HOUR_MS),
7517 ];
7518 let (ids, _) = read_margin_trades(venue(trades), since(now - 144 * HOUR_MS)).await;
7519
7520 assert_eq!(ids, vec![1, 2, 3]);
7521 }
7522
7523 #[tokio::test]
7526 async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
7527 for end_exclusive in [false, true] {
7528 let now = Utc::now().timestamp_millis();
7529 let from = now - 48 * HOUR_MS;
7530 let venue = MarginMyTradesVenue {
7531 end_exclusive,
7532 ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
7533 };
7534 let (ids, _) = read_margin_trades(venue, since(from)).await;
7535
7536 assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
7537 }
7538 }
7539
7540 #[tokio::test]
7542 async fn margin_trades_page_on_by_id_past_a_full_window() {
7543 let now = Utc::now().timestamp_millis();
7544 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
7545 let trades = (1..=count)
7546 .map(|id| (id, now - 48 * HOUR_MS + id))
7547 .collect();
7548 let (ids, _) = read_margin_trades(venue(trades), since(now - 50 * HOUR_MS)).await;
7549
7550 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
7551 }
7552
7553 #[tokio::test]
7556 async fn margin_trades_page_on_past_a_full_final_window() {
7557 let now = Utc::now().timestamp_millis();
7558 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
7559 let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
7560 let (ids, requests) = read_margin_trades(venue(trades), since(now - 2 * HOUR_MS)).await;
7561
7562 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
7563 assert_eq!(requests.len(), 2);
7564 }
7565
7566 #[tokio::test]
7569 async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
7570 let now = Utc::now().timestamp_millis();
7571 let (ids, requests) =
7572 read_margin_trades(venue(Vec::new()), since(now - 72 * HOUR_MS)).await;
7573
7574 assert!(ids.is_empty());
7575 assert_eq!(requests.len(), 4);
7577 }
7578
7579 #[tokio::test]
7582 async fn margin_trades_read_a_short_gap_in_one_request() {
7583 let now = Utc::now().timestamp_millis();
7584 let (ids, requests) =
7585 read_margin_trades(venue(vec![(9, now - HOUR_MS)]), since(now - 2 * HOUR_MS)).await;
7586
7587 assert_eq!(ids, vec![9]);
7588 assert_eq!(requests.len(), 1);
7589 }
7590
7591 #[tokio::test]
7593 async fn margin_trades_from_the_future_send_no_request() {
7594 let now = Utc::now().timestamp_millis();
7595 let (ids, requests) = read_margin_trades(venue(Vec::new()), since(now + HOUR_MS)).await;
7596
7597 assert!(ids.is_empty());
7598 assert!(requests.is_empty());
7599 }
7600
7601 #[tokio::test]
7604 async fn margin_trades_by_order_page_by_id_without_time_bounds() {
7605 let now = Utc::now().timestamp_millis();
7606 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
7607 let trades = (1..=2 * count)
7609 .map(|id| {
7610 (
7611 id,
7612 now - 48 * HOUR_MS + id,
7613 if id % 2 == 0 { 42 } else { 7 },
7614 )
7615 })
7616 .collect();
7617 let venue = MarginMyTradesVenue {
7618 trades,
7619 end_exclusive: false,
7620 };
7621 let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
7622
7623 assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
7624 assert_eq!(requests.len(), 2);
7625 assert!(
7626 requests.iter().all(
7627 |query| query.get("orderId").map(String::as_str) == Some("42")
7628 && !query.contains_key("startTime")
7629 && !query.contains_key("endTime")
7630 )
7631 );
7632 }
7633
7634 async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
7637 let server = wiremock::MockServer::start().await;
7638 wiremock::Mock::given(wiremock::matchers::method("GET"))
7639 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7640 .respond_with(response)
7641 .mount(&server)
7642 .await;
7643 let rest = Arc::new(MarginTradingRestApi::from_config(
7644 ConfigurationRestApi::builder()
7645 .api_key("key")
7646 .api_secret("secret")
7647 .base_path(server.uri())
7648 .build()
7649 .unwrap(),
7650 ));
7651
7652 paginate_margin_my_trades(
7653 &rest,
7654 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7655 &InstrumentNameExchange::new("BTCUSDT"),
7656 since(Utc::now().timestamp_millis() - HOUR_MS),
7657 true,
7658 RequestKind::Query,
7659 )
7660 .await
7661 .unwrap_err()
7662 }
7663
7664 #[tokio::test]
7667 async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
7668 let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
7669 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
7670 ))
7671 .await;
7672
7673 let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
7674 panic!("expected RequestRejected, got {err:?}");
7675 };
7676 assert!(msg.contains("-1127"), "code missing from {msg:?}");
7677 assert!(!err.is_transient());
7678 }
7679
7680 #[tokio::test]
7682 async fn margin_trades_report_an_undecodable_response_as_internal() {
7683 let err = margin_trades_error(
7684 wiremock::ResponseTemplate::new(200)
7685 .set_body_json(serde_json::json!({"unexpected": 1})),
7686 )
7687 .await;
7688
7689 assert!(
7690 matches!(err, UnindexedClientError::Internal(_)),
7691 "got {err:?}"
7692 );
7693 assert!(!err.is_transient());
7694 }
7695
7696 #[tokio::test]
7699 async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
7700 for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
7701 let server = wiremock::MockServer::start().await;
7702 wiremock::Mock::given(wiremock::matchers::method("GET"))
7703 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
7704 .respond_with(
7705 wiremock::ResponseTemplate::new(200)
7706 .insert_header("x-sapi-used-ip-weight-1m", used)
7707 .set_body_json(serde_json::json!([])),
7708 )
7709 .mount(&server)
7710 .await;
7711 let rest = Arc::new(MarginTradingRestApi::from_config(
7712 ConfigurationRestApi::builder()
7713 .api_key("key")
7714 .api_secret("secret")
7715 .base_path(server.uri())
7716 .build()
7717 .unwrap(),
7718 ));
7719 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7720
7721 fetch_margin_all_open_orders(rest, Arc::clone(&tracker), false)
7722 .await
7723 .unwrap();
7724
7725 let query_waits = tokio::time::timeout(
7726 Duration::from_millis(50),
7727 tracker.wait_if_blocked(RequestKind::Query),
7728 )
7729 .await
7730 .is_err();
7731 assert_eq!(query_waits, pauses, "used IP weight {used}");
7732 tokio::time::timeout(
7733 Duration::from_millis(50),
7734 tracker.wait_if_blocked(RequestKind::Order),
7735 )
7736 .await
7737 .expect("orders never wait on the pause")
7738 .expect("no cooldown too long to sleep through");
7739 }
7740 }
7741
7742 #[tokio::test]
7744 async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
7745 let server = wiremock::MockServer::start().await;
7746 wiremock::Mock::given(wiremock::matchers::method("GET"))
7747 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7748 .respond_with(
7750 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
7751 "symbol": "BTCUSDT",
7752 "id": 1,
7753 "orderId": 7,
7754 "price": "100",
7755 "qty": "1",
7756 "commission": "0",
7757 "commissionAsset": "USDT",
7758 "time": Utc::now().timestamp_millis(),
7759 "isBuyer": true,
7760 "isMaker": false,
7761 }])),
7762 )
7763 .expect(2)
7764 .mount(&server)
7765 .await;
7766 let rest = Arc::new(MarginTradingRestApi::from_config(
7767 ConfigurationRestApi::builder()
7768 .api_key("key")
7769 .api_secret("secret")
7770 .base_path(server.uri())
7771 .build()
7772 .unwrap(),
7773 ));
7774 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7775 tracker.throttle(Duration::from_secs(60));
7776 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
7777 let mut unrecovered = UnrecoveredFills::default();
7778 unrecovered.open(
7779 &[InstrumentNameExchange::new("BTCUSDT")],
7780 Utc::now() - chrono::Duration::minutes(1),
7781 Utc::now(),
7782 );
7783
7784 tokio::time::timeout(
7786 Duration::from_millis(800),
7787 recover_margin_fills(
7788 &rest,
7789 &tracker,
7790 &mut unrecovered,
7791 &tx,
7792 &new_dedup_cache(),
7793 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7794 false,
7795 ),
7796 )
7797 .await
7798 .expect("recovery does not wait for the pause");
7799 }
7800
7801 #[tokio::test]
7804 async fn margin_trades_read_over_a_span_stop_at_its_end() {
7805 let now = Utc::now().timestamp_millis();
7806 let trades = vec![
7807 (1, now - 50 * HOUR_MS),
7808 (2, now - 49 * HOUR_MS),
7809 (3, now - 10 * HOUR_MS),
7810 ];
7811 let (ids, _) = read_margin_trades(
7812 venue(trades),
7813 MyTradesFrom::Span {
7814 start: now - 60 * HOUR_MS,
7815 end: now - 20 * HOUR_MS,
7816 },
7817 )
7818 .await;
7819
7820 assert_eq!(ids, vec![1, 2]);
7821 }
7822
7823 #[tokio::test]
7826 async fn a_margin_gap_whose_every_read_fails_is_reported_when_given_up() {
7827 let disconnect = Utc::now() - chrono::Duration::minutes(10);
7828 let reconnect = Utc::now() - chrono::Duration::minutes(5);
7829 let server = wiremock::MockServer::start().await;
7830 wiremock::Mock::given(wiremock::matchers::method("GET"))
7831 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7832 .respond_with(
7833 wiremock::ResponseTemplate::new(400)
7834 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
7835 )
7836 .mount(&server)
7837 .await;
7838 let rest = Arc::new(MarginTradingRestApi::from_config(
7839 ConfigurationRestApi::builder()
7840 .api_key("key")
7841 .api_secret("secret")
7842 .base_path(server.uri())
7843 .build()
7844 .expect("valid config"),
7845 ));
7846 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7847 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
7848 let dedup = new_dedup_cache();
7849 let btc = InstrumentNameExchange::new("BTCUSDT");
7850 let mut unrecovered = UnrecoveredFills::default();
7851 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
7852
7853 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
7854 recover_margin_fills(
7855 &rest,
7856 &tracker,
7857 &mut unrecovered,
7858 &tx,
7859 &dedup,
7860 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7861 false,
7862 )
7863 .await;
7864 unrecovered.make_due();
7865 }
7866
7867 assert!(unrecovered.is_empty(), "the gap is given up");
7868 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7869 let [event] = forwarded.as_slice() else {
7870 panic!("only the give-up is sent: {forwarded:?}");
7871 };
7872 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
7873 panic!("expected FillRecoveryGaveUp, got {event:?}");
7874 };
7875 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
7876 assert_eq!(
7877 gave_up.scope,
7878 crate::FillRecoveryScope::Instruments(vec![btc])
7879 );
7880 assert_eq!(
7881 gave_up.start.timestamp_millis(),
7882 disconnect.timestamp_millis()
7883 );
7884 assert_eq!(
7885 gave_up.attempts,
7886 crate::client::order_recovery::MAX_GAP_RETRIES + 1
7887 );
7888 assert!(
7889 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
7890 "the last read's error: {:?}",
7891 gave_up.reason
7892 );
7893 }
7894
7895 #[tokio::test]
7898 async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
7899 let disconnect = Utc::now() - chrono::Duration::minutes(10);
7900 let reconnect = Utc::now() - chrono::Duration::minutes(5);
7901 let server = wiremock::MockServer::start().await;
7902 wiremock::Mock::given(wiremock::matchers::method("GET"))
7903 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7904 .respond_with(
7905 wiremock::ResponseTemplate::new(400)
7906 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
7907 )
7908 .up_to_n_times(1)
7909 .with_priority(1)
7910 .mount(&server)
7911 .await;
7912 wiremock::Mock::given(wiremock::matchers::method("GET"))
7913 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7914 .respond_with(venue(vec![
7915 (
7916 1,
7917 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
7918 ),
7919 (
7920 2,
7921 (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
7922 ),
7923 ]))
7924 .mount(&server)
7925 .await;
7926 let rest = Arc::new(MarginTradingRestApi::from_config(
7927 ConfigurationRestApi::builder()
7928 .api_key("key")
7929 .api_secret("secret")
7930 .base_path(server.uri())
7931 .build()
7932 .unwrap(),
7933 ));
7934 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7935 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
7936 let dedup = new_dedup_cache();
7937 let mut unrecovered = UnrecoveredFills::default();
7938 unrecovered.open(
7939 &[InstrumentNameExchange::new("BTCUSDT")],
7940 disconnect,
7941 reconnect,
7942 );
7943
7944 recover_margin_fills(
7945 &rest,
7946 &tracker,
7947 &mut unrecovered,
7948 &tx,
7949 &dedup,
7950 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7951 false,
7952 )
7953 .await;
7954 assert!(!unrecovered.is_empty(), "the failed gap is kept");
7955 assert!(rx.try_recv().is_err(), "nothing forwarded");
7956
7957 unrecovered.make_due();
7958 recover_margin_fills(
7959 &rest,
7960 &tracker,
7961 &mut unrecovered,
7962 &tx,
7963 &dedup,
7964 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7965 false,
7966 )
7967 .await;
7968 assert!(unrecovered.is_empty(), "the retry recovered it");
7969 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
7970 assert_eq!(
7971 forwarded.len(),
7972 1,
7973 "only the fill inside the gap: {forwarded:?}"
7974 );
7975 }
7976
7977 #[tokio::test]
7980 async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
7981 let server = wiremock::MockServer::start().await;
7982 wiremock::Mock::given(wiremock::matchers::method("GET"))
7983 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7984 .respond_with(
7985 wiremock::ResponseTemplate::new(200)
7986 .set_body_json(serde_json::json!([]))
7987 .set_delay(Duration::from_secs(3_600)),
7988 )
7989 .mount(&server)
7990 .await;
7991 let rest = Arc::new(MarginTradingRestApi::from_config(
7992 ConfigurationRestApi::builder()
7993 .api_key("key")
7994 .api_secret("secret")
7995 .base_path(server.uri())
7996 .timeout(3_600_000_u64)
7997 .retries(0_u32)
7998 .build()
7999 .unwrap(),
8000 ));
8001 let instruments: Vec<_> = (0..9)
8002 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
8003 .collect();
8004 let mut unrecovered = UnrecoveredFills::default();
8005 unrecovered.open(
8006 &instruments,
8007 Utc::now() - chrono::Duration::minutes(10),
8008 Utc::now(),
8009 );
8010 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
8011 tokio::time::pause();
8013
8014 recover_margin_fills(
8015 &rest,
8016 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
8017 &mut unrecovered,
8018 &tx,
8019 &new_dedup_cache(),
8020 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
8021 false,
8022 )
8023 .await;
8024
8025 let now = tokio::time::Instant::now();
8026 assert_eq!(
8027 unrecovered.due(now).len(),
8028 1,
8029 "the gap never started stays due"
8030 );
8031 assert_eq!(
8032 unrecovered
8033 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
8034 .len(),
8035 9,
8036 "the eight started wait for their first retry"
8037 );
8038 }
8039
8040 fn margin_order_row(cid: &str, status: &str) -> serde_json::Value {
8046 serde_json::json!({
8047 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
8048 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
8049 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "isIsolated": false,
8050 "time": 1_700_000_000_000_i64, "updateTime": 1_700_000_060_000_i64,
8051 })
8052 }
8053
8054 fn margin_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
8055 OrderKey::new(
8056 ExchangeId::BinanceMargin,
8057 InstrumentNameExchange::new(instrument),
8058 StrategyId::new("strategy"),
8059 ClientOrderId::new(cid),
8060 )
8061 }
8062
8063 #[test]
8064 fn a_margin_order_row_says_how_the_order_ended() {
8065 use crate::order::state::InactiveOrderState;
8066 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOrderResponse;
8067 let key = margin_key("BTCUSDT", "a");
8068 let ended = |row: serde_json::Value| {
8069 let row: QueryMarginAccountsOrderResponse = serde_json::from_value(row).unwrap();
8070 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, None)
8071 .map(|order| order.state)
8072 };
8073
8074 let mut filled = margin_order_row("a", "FILLED");
8075 filled["executedQty"] = "2".into();
8076 filled["cummulativeQuoteQty"] = "210".into();
8077 let Some(InactiveOrderState::FullyFilled(filled)) = ended(filled) else {
8078 panic!("FILLED is fully filled");
8079 };
8080 assert_eq!(filled.avg_price, Some(Decimal::from(105)));
8081 assert!(matches!(
8082 ended(margin_order_row("a", "CANCELED")),
8083 Some(InactiveOrderState::Cancelled(cancelled)) if cancelled.filled_quantity == Some(Decimal::ONE)
8084 ));
8085 assert_eq!(ended(margin_order_row("a", "NEW")), None);
8086
8087 let mut unknown_quote = margin_order_row("a", "FILLED");
8089 unknown_quote["executedQty"] = "2".into();
8090 unknown_quote["cummulativeQuoteQty"] = "-1".into();
8091 let Some(InactiveOrderState::FullyFilled(filled)) = ended(unknown_quote) else {
8092 panic!("still fully filled");
8093 };
8094 assert_eq!(filled.avg_price, None, "no negative average price");
8095 }
8096
8097 fn margin_rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
8098 Arc::new(MarginTradingRestApi::from_config(
8099 ConfigurationRestApi::builder()
8100 .api_key("key")
8101 .api_secret("secret")
8102 .base_path(server.uri())
8103 .build()
8104 .unwrap(),
8105 ))
8106 }
8107
8108 pub(super) fn margin_client_at(
8110 server: &wiremock::MockServer,
8111 is_isolated: bool,
8112 ) -> BinanceMargin {
8113 let config = if is_isolated {
8114 BinanceMarginConfig::isolated(
8115 "key".into(),
8116 "secret".into(),
8117 vec![InstrumentNameExchange::new("BTCUSDT")],
8118 )
8119 } else {
8120 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
8121 };
8122 let mode = config.pre_send_rules;
8123 let mut client = <BinanceMargin as ExecutionClient>::new(config);
8124 client.rest = margin_rest_at(server);
8125 client.rules = rules_at(server, mode);
8126 client
8127 }
8128
8129 fn rules_at(server: &wiremock::MockServer, mode: PreSendRules) -> Arc<RulesLoader> {
8131 Arc::new(RulesLoader::new(
8132 mode,
8133 Arc::new(binance_sdk::spot::SpotRestApi::from_config(
8134 ConfigurationRestApi::builder()
8135 .api_key("key")
8136 .api_secret("secret")
8137 .base_path(server.uri())
8138 .build()
8139 .unwrap(),
8140 )),
8141 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8142 ))
8143 }
8144
8145 async fn mount_margin_order(
8147 server: &wiremock::MockServer,
8148 cid: &str,
8149 response: wiremock::ResponseTemplate,
8150 ) {
8151 wiremock::Mock::given(wiremock::matchers::method("GET"))
8152 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8153 .and(wiremock::matchers::query_param("origClientOrderId", cid))
8154 .respond_with(response)
8155 .mount(server)
8156 .await;
8157 }
8158
8159 fn margin_venue_error(code: i64) -> wiremock::ResponseTemplate {
8160 wiremock::ResponseTemplate::new(400)
8161 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
8162 }
8163
8164 fn margin_row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
8165 wiremock::ResponseTemplate::new(200).set_body_json(margin_order_row(cid, status))
8166 }
8167
8168 async fn margin_requests(server: &wiremock::MockServer) -> Vec<(String, Option<String>)> {
8170 server
8171 .received_requests()
8172 .await
8173 .unwrap()
8174 .iter()
8175 .map(|request| {
8176 let isolated = request
8177 .url
8178 .query_pairs()
8179 .find(|(key, _)| key == "isIsolated")
8180 .map(|(_, value)| value.into_owned());
8181 (request.url.path().to_owned(), isolated)
8182 })
8183 .collect()
8184 }
8185
8186 #[tokio::test]
8187 async fn margin_fetch_ended_orders_asks_the_configured_account_and_omits_the_rest() {
8188 use crate::order::state::InactiveOrderState;
8189 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
8190 let server = wiremock::MockServer::start().await;
8191 mount_margin_order(&server, "filled", margin_row_response("filled", "FILLED")).await;
8192 mount_margin_order(&server, "live", margin_row_response("live", "NEW")).await;
8193 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
8194 mount_margin_order(&server, "no-symbol", margin_venue_error(-1121)).await;
8195 let client = margin_client_at(&server, is_isolated);
8196 let keys = ["filled", "live", "unknown"].map(|cid| margin_key("BTCUSDT", cid));
8197
8198 let ended = client
8199 .fetch_ended_orders(
8200 &[keys.to_vec(), vec![margin_key("NOSUCH", "no-symbol")]].concat(),
8201 )
8202 .await
8203 .unwrap();
8204
8205 let [order] = ended.as_slice() else {
8206 panic!("one order ended: {ended:?}");
8207 };
8208 assert_eq!(order.key, keys[0], "the strategy asked survives");
8209 assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
8210 let requests = margin_requests(&server).await;
8211 assert_eq!(requests.len(), 4);
8212 for (path, isolated) in requests {
8213 assert_eq!(path, "/sapi/v1/margin/order");
8214 assert_eq!(
8215 isolated.as_deref(),
8216 Some(flag),
8217 "is_isolated = {is_isolated}"
8218 );
8219 }
8220 }
8221 }
8222
8223 #[tokio::test]
8224 async fn margin_fetch_ended_orders_fails_when_any_lookup_fails() {
8225 let server = wiremock::MockServer::start().await;
8226 mount_margin_order(
8227 &server,
8228 "cancelled",
8229 margin_row_response("cancelled", "CANCELED"),
8230 )
8231 .await;
8232 mount_margin_order(&server, "refused", margin_venue_error(-1100)).await;
8233 let client = margin_client_at(&server, false);
8234
8235 let result = client
8236 .fetch_ended_orders(&[
8237 margin_key("BTCUSDT", "cancelled"),
8238 margin_key("BTCUSDT", "refused"),
8239 ])
8240 .await;
8241
8242 assert!(
8243 matches!(
8244 result,
8245 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
8246 ),
8247 "{result:?}"
8248 );
8249 }
8250
8251 fn held_margin_orders() -> SharedKnownLiveOrders {
8253 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
8254 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
8255 known.lock().live(
8256 &margin_key("BTCUSDT", cid),
8257 Decimal::TWO,
8258 OrderKind::Limit,
8259 &Open::new(
8260 VenueOrderId::Assigned(OrderId::new(n.to_string())),
8261 Utc::now(),
8262 Decimal::ZERO,
8263 ),
8264 );
8265 }
8266 known
8267 }
8268
8269 #[tokio::test]
8274 async fn an_ended_margin_trailing_stop_is_reported_with_the_kind_it_was_held_with() {
8275 let server = wiremock::MockServer::start().await;
8276 wiremock::Mock::given(wiremock::matchers::method("GET"))
8277 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
8278 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8279 .mount(&server)
8280 .await;
8281 let mut row = margin_order_row("trailing", "CANCELED");
8282 row["type"] = "STOP_LOSS".into();
8283 row["price"] = "0.00000000".into();
8284 row["stopPrice"] = "0.00000000".into();
8285 mount_margin_order(
8286 &server,
8287 "trailing",
8288 wiremock::ResponseTemplate::new(200).set_body_json(row),
8289 )
8290 .await;
8291 let trailing_stop = OrderKind::TrailingStop {
8292 offset: Decimal::from(25),
8293 offset_type: TrailingOffsetType::BasisPoints,
8294 activation_price: None,
8295 };
8296 let hold = |known: &SharedKnownLiveOrders| {
8297 known.lock().live(
8298 &margin_key("BTCUSDT", "trailing"),
8299 Decimal::TWO,
8300 trailing_stop,
8301 &Open::new(
8302 VenueOrderId::Assigned(OrderId::new("7")),
8303 Utc::now(),
8304 Decimal::ZERO,
8305 ),
8306 );
8307 };
8308
8309 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
8310 hold(&known);
8311 let mut unchecked = UncheckedOrders::default();
8312 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
8313 let (tx, mut rx) = mpsc::unbounded_channel();
8314 recover_margin_ended_orders(
8315 &margin_rest_at(&server),
8316 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
8317 &known,
8318 &mut unchecked,
8319 &UnrecoveredFills::default(),
8320 &tx,
8321 false,
8322 )
8323 .await;
8324 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
8325 let [event] = sent.as_slice() else {
8326 panic!("the trailing stop ended: {sent:?}");
8327 };
8328 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
8329 panic!("an order snapshot: {event:?}");
8330 };
8331 assert_eq!(snapshot.0.kind, trailing_stop);
8332 assert!(matches!(
8333 snapshot.0.state,
8334 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
8335 ));
8336 assert!(!known.lock().contains(&ClientOrderId::new("trailing")));
8337
8338 let client = margin_client_at(&server, false);
8339 hold(&client.known_live);
8340 let ended = client
8341 .fetch_ended_orders(&[margin_key("BTCUSDT", "trailing")])
8342 .await
8343 .expect("lookup");
8344 let [order] = ended.as_slice() else {
8345 panic!("the trailing stop ended: {ended:?}");
8346 };
8347 assert_eq!(order.kind, trailing_stop);
8348 }
8349
8350 #[tokio::test]
8351 async fn a_margin_reconnect_reports_how_an_unlisted_order_ended_in_the_configured_account() {
8352 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
8353 let server = wiremock::MockServer::start().await;
8354 wiremock::Mock::given(wiremock::matchers::method("GET"))
8355 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
8356 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
8357 .respond_with(
8358 wiremock::ResponseTemplate::new(200)
8359 .set_body_json(serde_json::json!([margin_order_row("live", "NEW")])),
8360 )
8361 .mount(&server)
8362 .await;
8363 mount_margin_order(&server, "gone", margin_row_response("gone", "CANCELED")).await;
8364 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
8365 let known = held_margin_orders();
8366 let mut unchecked = UncheckedOrders::default();
8367 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
8368 let (tx, mut rx) = mpsc::unbounded_channel();
8369
8370 recover_margin_ended_orders(
8371 &margin_rest_at(&server),
8372 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
8373 &known,
8374 &mut unchecked,
8375 &UnrecoveredFills::default(),
8376 &tx,
8377 is_isolated,
8378 )
8379 .await;
8380
8381 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
8382 let [event] = sent.as_slice() else {
8383 panic!("one order ended: {sent:?}");
8384 };
8385 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
8386 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
8387 panic!("an order snapshot: {event:?}");
8388 };
8389 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
8390 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
8391 assert!(matches!(
8392 snapshot.0.state,
8393 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
8394 ));
8395 let mut requests = margin_requests(&server).await;
8396 requests.sort();
8397 let expected = |path: &str| (path.to_owned(), Some(flag.to_owned()));
8398 assert_eq!(
8399 requests,
8400 [
8401 expected("/sapi/v1/margin/openOrders"),
8402 expected("/sapi/v1/margin/order"),
8403 expected("/sapi/v1/margin/order"),
8404 ],
8405 "one listing, and a lookup of each unlisted order only"
8406 );
8407 let known = known.lock();
8408 assert!(known.contains(&ClientOrderId::new("live")));
8409 assert!(!known.contains(&ClientOrderId::new("gone")));
8410 assert!(
8411 !known.contains(&ClientOrderId::new("unknown")),
8412 "asking again cannot help"
8413 );
8414 assert!(unchecked.is_empty());
8415 }
8416 }
8417
8418 fn margin_open_request<'a>(
8419 instrument: &'a InstrumentNameExchange,
8420 cid: &str,
8421 ) -> OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange> {
8422 OrderRequestOpen {
8423 key: OrderKey::new(
8424 ExchangeId::BinanceMargin,
8425 instrument,
8426 StrategyId::new("strategy"),
8427 ClientOrderId::new(cid),
8428 ),
8429 state: crate::order::request::RequestOpen::new(
8430 Side::Buy,
8431 Some(Decimal::from(100)),
8432 Decimal::TWO,
8433 OrderKind::Limit,
8434 TimeInForce::GoodUntilCancelled { post_only: false },
8435 ),
8436 }
8437 }
8438
8439 #[tokio::test]
8442 async fn a_margin_trailing_stop_is_sent_with_trailing_delta() {
8443 let server = wiremock::MockServer::start().await;
8444 let sent_as_trailing_stop = |request: &wiremock::Request| {
8445 let mut body_as_query = request.url.clone();
8447 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
8448 let params: Vec<(String, String)> = request
8449 .url
8450 .query_pairs()
8451 .chain(body_as_query.query_pairs())
8452 .map(|(k, v)| (k.into_owned(), v.into_owned()))
8453 .collect();
8454 let has = |key: &str, value: &str| params.iter().any(|(k, v)| k == key && v == value);
8455 has("type", "STOP_LOSS")
8456 && has("trailingDelta", "25")
8457 && has("sideEffectType", "NO_SIDE_EFFECT")
8458 && !params.iter().any(|(k, _)| k == "stopPrice" || k == "price")
8459 };
8460 wiremock::Mock::given(wiremock::matchers::method("POST"))
8461 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8462 .and(sent_as_trailing_stop)
8463 .respond_with(
8464 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8465 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "trailing",
8466 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "2",
8467 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
8468 "timeInForce": "GTC", "type": "STOP_LOSS", "side": "SELL",
8469 })),
8470 )
8471 .expect(1)
8472 .mount(&server)
8473 .await;
8474 let client = margin_client_at(&server, false);
8475 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8476 let trailing_request = |cid, offset| {
8477 let mut request = margin_open_request(&btcusdt, cid);
8478 request.state.side = Side::Sell;
8479 request.state.price = None;
8480 request.state.kind = OrderKind::TrailingStop {
8481 offset,
8482 offset_type: TrailingOffsetType::BasisPoints,
8483 activation_price: None,
8484 };
8485 request
8486 };
8487
8488 let placed = client
8489 .open_order(trailing_request("trailing", Decimal::from(25)))
8490 .await;
8491 assert!(
8492 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8493 "{placed:?}"
8494 );
8495
8496 let refused = client
8497 .open_order(trailing_request("fractional", Decimal::new(25, 1)))
8498 .await;
8499 assert!(
8500 matches!(
8501 refused.state,
8502 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
8503 OrderError::InvalidPrecision(_)
8504 ))
8505 ),
8506 "{refused:?}"
8507 );
8508 }
8509
8510 #[tokio::test]
8514 async fn a_request_margin_effect_picks_the_side_effect_type_sent() {
8515 let server = wiremock::MockServer::start().await;
8516 let sends = |wire: &'static str, auto_repay_at_cancel: Option<&'static str>| {
8517 move |request: &wiremock::Request| {
8518 let mut body_as_query = request.url.clone();
8519 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
8520 let params: Vec<(String, String)> = request
8521 .url
8522 .query_pairs()
8523 .chain(body_as_query.query_pairs())
8524 .map(|(k, v)| (k.into_owned(), v.into_owned()))
8525 .collect();
8526 let value = |key: &str| {
8527 params
8528 .iter()
8529 .find(|(k, _)| k == key)
8530 .map(|(_, v)| v.as_str())
8531 };
8532 value("sideEffectType") == Some(wire)
8533 && value("autoRepayAtCancel") == auto_repay_at_cancel
8534 }
8535 };
8536 for (matcher, cid) in [
8537 (sends("MARGIN_BUY", Some("true")), "borrow"),
8538 (sends("AUTO_REPAY", None), "repay"),
8539 (sends("NO_SIDE_EFFECT", None), "default"),
8540 ] {
8541 wiremock::Mock::given(wiremock::matchers::method("POST"))
8542 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8543 .and(matcher)
8544 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
8545 serde_json::json!({
8546 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
8547 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
8548 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
8549 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
8550 }),
8551 ))
8552 .expect(1)
8553 .mount(&server)
8554 .await;
8555 }
8556 let client = margin_client_at(&server, false);
8557 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8558
8559 for (cid, effect) in [
8560 ("borrow", Some(MarginEffect::Borrow)),
8561 ("repay", Some(MarginEffect::Repay)),
8562 ("default", None),
8563 ] {
8564 let mut request = margin_open_request(&btcusdt, cid);
8565 request.state.margin_effect = effect;
8566 let placed = client.open_order(request).await;
8567 assert!(
8568 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8569 "{cid}: {placed:?}"
8570 );
8571 }
8572 }
8573
8574 use crate::error::{Bound, BoundsViolation, OrderField, PrecisionLimit, PrecisionViolation};
8575 use rust_decimal_macros::dec;
8576
8577 fn btcusdt_exchange_info() -> wiremock::ResponseTemplate {
8579 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8580 "timezone": "UTC", "serverTime": 1_700_000_000_000_i64,
8581 "symbols": [{
8582 "symbol": "BTCUSDT", "status": "TRADING",
8583 "filters": [
8584 {"filterType": "PRICE_FILTER", "minPrice": "0.01", "maxPrice": "0", "tickSize": "0.01"},
8585 {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
8586 {"filterType": "NOTIONAL", "minNotional": "5", "applyMinToMarket": true,
8587 "maxNotional": "9000000", "applyMaxToMarket": false, "avgPriceMins": 5},
8588 ],
8589 }],
8590 }))
8591 }
8592
8593 async fn mount_order_accepted(server: &wiremock::MockServer, expected: u64) {
8594 wiremock::Mock::given(wiremock::matchers::method("POST"))
8595 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8596 .respond_with(
8597 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8598 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "c",
8599 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
8600 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
8601 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
8602 })),
8603 )
8604 .expect(expected)
8605 .mount(server)
8606 .await;
8607 }
8608
8609 #[tokio::test]
8612 async fn an_order_breaking_the_symbols_rules_is_refused_unsent() {
8613 let server = wiremock::MockServer::start().await;
8614 wiremock::Mock::given(wiremock::matchers::method("GET"))
8615 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
8616 .and(wiremock::matchers::query_param("symbols", r#"["BTCUSDT"]"#))
8617 .respond_with(btcusdt_exchange_info())
8618 .expect(1)
8619 .mount(&server)
8620 .await;
8621 mount_order_accepted(&server, 1).await;
8622 let client = margin_client_at(&server, false);
8623 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8624 let order = |cid: &str, price: Decimal, quantity: Decimal| {
8625 let mut request = margin_open_request(&btcusdt, cid);
8626 request.state.price = Some(price);
8627 request.state.quantity = quantity;
8628 request
8629 };
8630
8631 let off_tick = client.open_order(order("a", dec!(100.001), dec!(1))).await;
8632 assert_eq!(
8633 off_tick.state,
8634 OrderState::inactive(OrderError::InvalidPrecision(PrecisionViolation::new(
8635 OrderField::Price,
8636 dec!(100.001),
8637 PrecisionLimit::NotOnGrid { step: dec!(0.01) }
8638 )))
8639 );
8640 let dust = client.open_order(order("b", dec!(100), dec!(0.001))).await;
8641 assert_eq!(
8642 dust.state,
8643 OrderState::inactive(OrderError::OutOfBounds(BoundsViolation::new(
8644 OrderField::Notional,
8645 Bound::Min,
8646 dec!(5),
8647 dec!(0.100)
8648 )))
8649 );
8650 let placed = client.open_order(order("c", dec!(100), dec!(1))).await;
8651 assert!(
8652 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8653 "{placed:?}"
8654 );
8655 }
8656
8657 #[tokio::test]
8659 async fn with_the_rules_off_every_order_is_sent_and_no_rules_are_read() {
8660 let server = wiremock::MockServer::start().await;
8661 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
8662 .respond_with(btcusdt_exchange_info())
8663 .expect(0)
8664 .mount(&server)
8665 .await;
8666 mount_order_accepted(&server, 1).await;
8667 let mut client = margin_client_at(&server, false);
8668 client.rules = rules_at(&server, PreSendRules::Off);
8669 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8670 let mut request = margin_open_request(&btcusdt, "x");
8671 request.state.price = Some(dec!(100.001));
8672 let placed = client.open_order(request).await;
8673 assert!(
8674 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8675 "{placed:?}"
8676 );
8677 }
8678
8679 #[tokio::test]
8681 async fn an_order_whose_rules_cannot_be_loaded_is_sent_unchecked() {
8682 let server = wiremock::MockServer::start().await;
8683 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
8684 .respond_with(wiremock::ResponseTemplate::new(500))
8685 .mount(&server)
8686 .await;
8687 mount_order_accepted(&server, 1).await;
8688 let client = margin_client_at(&server, false);
8689 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8690 let mut request = margin_open_request(&btcusdt, "x");
8691 request.state.price = Some(dec!(100.001));
8692 let placed = client.open_order(request).await;
8693 assert!(
8694 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8695 "{placed:?}"
8696 );
8697 }
8698
8699 #[tokio::test]
8701 async fn load_rules_returns_the_rules_and_the_first_order_reuses_them() {
8702 let server = wiremock::MockServer::start().await;
8703 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
8704 .respond_with(btcusdt_exchange_info())
8705 .expect(1)
8706 .mount(&server)
8707 .await;
8708 mount_order_accepted(&server, 0).await;
8709 let client = margin_client_at(&server, false);
8710 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8711
8712 let loaded = client.load_rules(std::slice::from_ref(&btcusdt)).await;
8713 let rules = loaded.ok().and_then(|mut loaded| loaded.remove(&btcusdt));
8714 assert_eq!(
8715 rules.map(|rules| (rules.price.step, rules.quantity.step, rules.notional.min)),
8716 Some((Some(dec!(0.01)), Some(dec!(0.001)), Some(dec!(5))))
8717 );
8718
8719 let mut request = margin_open_request(&btcusdt, "x");
8720 request.state.price = Some(dec!(100.001));
8721 let refused = client.open_order(request).await;
8722 assert!(
8723 matches!(refused.state, OrderState::Inactive(_)),
8724 "{refused:?}"
8725 );
8726 }
8727
8728 #[tokio::test]
8730 async fn load_rules_of_no_symbols_sends_no_request() {
8731 let server = wiremock::MockServer::start().await;
8732 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
8733 .respond_with(btcusdt_exchange_info())
8734 .expect(0)
8735 .mount(&server)
8736 .await;
8737 let client = margin_client_at(&server, false);
8738 assert_eq!(client.load_rules(&[]).await, Ok(HashMap::new()));
8739 }
8740
8741 #[tokio::test]
8745 async fn a_cross_refusal_of_a_trailing_stop_is_an_unsupported_order_type() {
8746 let server = wiremock::MockServer::start().await;
8747 wiremock::Mock::given(wiremock::matchers::method("POST"))
8748 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8749 .respond_with(
8750 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
8751 "code": -3111,
8752 "msg": "Trailing stop order only supports limit type.",
8753 })),
8754 )
8755 .expect(1)
8756 .mount(&server)
8757 .await;
8758 let client = margin_client_at(&server, false);
8759 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8760 let mut request = margin_open_request(&btcusdt, "trailing");
8761 request.state.side = Side::Sell;
8762 request.state.price = None;
8763 request.state.kind = OrderKind::TrailingStop {
8764 offset: Decimal::from(25),
8765 offset_type: TrailingOffsetType::BasisPoints,
8766 activation_price: None,
8767 };
8768
8769 let refused = client.open_order(request).await;
8770
8771 let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
8772 OrderError::UnsupportedOrderType(msg),
8773 )) = &refused.state
8774 else {
8775 panic!("expected UnsupportedOrderType: {refused:?}");
8776 };
8777 assert!(
8778 msg.contains("-3111") && msg.contains("only supports limit type"),
8779 "{msg}"
8780 );
8781 }
8782
8783 #[tokio::test]
8784 async fn placing_listing_and_cancelling_margin_orders_feed_the_known_live_set() {
8785 let server = wiremock::MockServer::start().await;
8786 wiremock::Mock::given(wiremock::matchers::method("POST"))
8787 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8788 .respond_with(
8789 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8790 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "placed",
8791 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
8792 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
8793 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
8794 })),
8795 )
8796 .mount(&server)
8797 .await;
8798 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
8799 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8800 .respond_with(
8801 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8802 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
8803 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0",
8804 "cummulativeQuoteQty": "0", "status": "CANCELED", "timeInForce": "GTC",
8805 "type": "LIMIT", "side": "BUY",
8806 })),
8807 )
8808 .mount(&server)
8809 .await;
8810 wiremock::Mock::given(wiremock::matchers::method("GET"))
8811 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
8812 .respond_with(
8813 wiremock::ResponseTemplate::new(200)
8814 .set_body_json(serde_json::json!([margin_order_row("listed", "NEW")])),
8815 )
8816 .mount(&server)
8817 .await;
8818 let client = margin_client_at(&server, false);
8819 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8820
8821 let placed = client
8822 .open_order(margin_open_request(&btcusdt, "placed"))
8823 .await;
8824 assert!(
8825 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8826 "{placed:?}"
8827 );
8828 assert!(
8829 client
8830 .known_live
8831 .lock()
8832 .contains(&ClientOrderId::new("placed"))
8833 );
8834
8835 client.fetch_open_orders(&[]).await.unwrap();
8836 assert!(
8837 client
8838 .known_live
8839 .lock()
8840 .contains(&ClientOrderId::new("listed"))
8841 );
8842
8843 let cancelled = client
8844 .cancel_order(OrderRequestCancel {
8845 key: OrderKey::new(
8846 ExchangeId::BinanceMargin,
8847 &btcusdt,
8848 StrategyId::new("strategy"),
8849 ClientOrderId::new("placed"),
8850 ),
8851 state: crate::order::request::RequestCancel {
8852 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
8853 },
8854 })
8855 .await;
8856 assert!(cancelled.state.is_ok(), "{cancelled:?}");
8857 assert!(
8858 !client
8859 .known_live
8860 .lock()
8861 .contains(&ClientOrderId::new("placed"))
8862 );
8863 }
8864
8865 #[tokio::test]
8867 async fn a_cancel_response_without_executed_qty_reports_the_fill_unknown() {
8868 let server = wiremock::MockServer::start().await;
8869 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
8870 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8871 .respond_with(
8872 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8873 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
8874 "clientOrderId": "cancel", "price": "100", "origQty": "2",
8875 "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
8876 })),
8877 )
8878 .mount(&server)
8879 .await;
8880 let client = margin_client_at(&server, false);
8881
8882 let cancelled = client
8883 .cancel_order(OrderRequestCancel {
8884 key: OrderKey::new(
8885 ExchangeId::BinanceMargin,
8886 &InstrumentNameExchange::new("BTCUSDT"),
8887 StrategyId::new("strategy"),
8888 ClientOrderId::new("placed"),
8889 ),
8890 state: crate::order::request::RequestCancel {
8891 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
8892 },
8893 })
8894 .await;
8895 let Ok(cancelled) = cancelled.state else {
8896 panic!("expected Ok, got {cancelled:?}");
8897 };
8898 assert_eq!(cancelled.filled_quantity, None);
8899 }
8900
8901 #[test]
8902 fn a_reconnect_opens_a_gap_on_each_stream_instrument_and_checks_those_with_orders_held() {
8903 let [btc, eth, xrp] = ["BTCUSDT", "ETHUSDT", "XRPUSDT"].map(InstrumentNameExchange::new);
8904 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
8905 for (instrument, cid) in [(&btc, "btc"), (&xrp, "xrp")] {
8906 known.lock().live(
8907 &margin_key(instrument.name(), cid),
8908 Decimal::TWO,
8909 OrderKind::Limit,
8910 &Open::new(
8911 VenueOrderId::Assigned(OrderId::new(cid)),
8912 Utc::now(),
8913 Decimal::ZERO,
8914 ),
8915 );
8916 }
8917 let mut unrecovered = UnrecoveredFills::default();
8918 let mut unchecked = UncheckedOrders::default();
8919
8920 open_reconnect_recovery(
8921 &mut unrecovered,
8922 &mut unchecked,
8923 &known,
8924 &[btc.clone(), eth.clone()],
8925 Utc::now() - chrono::Duration::minutes(1),
8926 );
8927
8928 assert!(unrecovered.covers(&btc) && unrecovered.covers(ð));
8929 assert!(!unrecovered.covers(&xrp), "not the stream's");
8930 assert!(unchecked.contains(&btc));
8931 assert!(!unchecked.contains(ð), "no order held");
8932 assert!(!unchecked.contains(&xrp), "its fills are not recovered");
8933 }
8934
8935 #[tokio::test]
8938 async fn a_recovered_margin_fill_that_completes_a_held_order_ends_it() {
8939 let disconnect = Utc::now() - chrono::Duration::minutes(10);
8940 let server = wiremock::MockServer::start().await;
8941 wiremock::Mock::given(wiremock::matchers::method("GET"))
8942 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
8943 .respond_with(venue(vec![
8944 (
8945 1,
8946 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
8947 ),
8948 (
8949 2,
8950 (disconnect + chrono::Duration::minutes(2)).timestamp_millis(),
8951 ),
8952 ]))
8953 .mount(&server)
8954 .await;
8955 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
8958 for (cid, order_id, quantity) in [("one", "1", "0.01"), ("two", "2", "0.02")] {
8959 known.lock().live(
8960 &margin_key("BTCUSDT", cid),
8961 Decimal::from_str(quantity).unwrap(),
8962 OrderKind::Limit,
8963 &Open::new(
8964 VenueOrderId::Assigned(OrderId::new(order_id)),
8965 Utc::now(),
8966 Decimal::ZERO,
8967 ),
8968 );
8969 }
8970 let mut unrecovered = UnrecoveredFills::default();
8971 unrecovered.open(
8972 &[InstrumentNameExchange::new("BTCUSDT")],
8973 disconnect,
8974 Utc::now(),
8975 );
8976 let (tx, mut rx) = mpsc::unbounded_channel();
8977
8978 recover_margin_fills(
8979 &margin_rest_at(&server),
8980 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
8981 &mut unrecovered,
8982 &tx,
8983 &new_dedup_cache(),
8984 &known,
8985 false,
8986 )
8987 .await;
8988
8989 assert_eq!(
8990 std::iter::from_fn(|| rx.try_recv().ok()).count(),
8991 2,
8992 "both fills"
8993 );
8994 let known = known.lock();
8995 assert!(!known.contains(&ClientOrderId::new("one")), "completed");
8996 assert!(known.contains(&ClientOrderId::new("two")), "part-filled");
8997 }
8998
8999 #[tokio::test]
9002 async fn a_margin_order_that_expired_on_placement_is_not_reported_or_held_open() {
9003 use rust_decimal_macros::dec;
9004
9005 for (cid, filled, quote) in [("unfilled", "0", "0"), ("partly", "1", "100")] {
9006 let server = wiremock::MockServer::start().await;
9007 wiremock::Mock::given(wiremock::matchers::method("POST"))
9008 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
9009 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
9010 serde_json::json!({
9011 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
9012 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
9013 "executedQty": filled, "cummulativeQuoteQty": quote,
9014 "status": "EXPIRED", "timeInForce": "IOC", "type": "LIMIT", "side": "BUY",
9015 }),
9016 ))
9017 .mount(&server)
9018 .await;
9019 let client = margin_client_at(&server, false);
9020 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
9021
9022 let placed = client.open_order(margin_open_request(&btcusdt, cid)).await;
9023
9024 let OrderState::Inactive(crate::order::state::InactiveOrderState::Expired(expired)) =
9025 &placed.state
9026 else {
9027 panic!("{cid}: expired, not open: {placed:?}");
9028 };
9029 assert_eq!(expired.filled_quantity, Decimal::from_str(filled).ok());
9030 let late = Open::new(
9031 VenueOrderId::Assigned(OrderId::new("7")),
9032 Utc::now(),
9033 Decimal::ZERO,
9034 );
9035 let mut known = client.known_live.lock();
9036 known.live(&placed.key, dec!(2), OrderKind::Limit, &late);
9037 assert!(
9038 !known.contains(&ClientOrderId::new(cid)),
9039 "{cid}: not held, even after a late live report"
9040 );
9041 }
9042 }
9043
9044 async fn mount_borrow_terms(
9048 server: &wiremock::MockServer,
9049 pair: serde_json::Value,
9050 asset: serde_json::Value,
9051 rate: serde_json::Value,
9052 max_borrow: serde_json::Value,
9053 ) {
9054 use wiremock::{
9055 Mock, ResponseTemplate,
9056 matchers::{method, path},
9057 };
9058 for (route, body) in [
9059 ("/sapi/v1/margin/allPairs", pair.clone()),
9060 ("/sapi/v1/margin/isolated/allPairs", pair),
9061 ("/sapi/v1/margin/allAssets", asset),
9062 ("/sapi/v1/margin/next-hourly-interest-rate", rate),
9063 ("/sapi/v1/margin/maxBorrowable", max_borrow),
9064 ("/sapi/v1/margin/delist-schedule", serde_json::json!([])),
9065 ] {
9066 Mock::given(method("GET"))
9067 .and(path(route))
9068 .respond_with(ResponseTemplate::new(200).set_body_json(body))
9069 .mount(server)
9070 .await;
9071 }
9072 }
9073
9074 async fn mount_delist_schedule(server: &wiremock::MockServer, schedule: serde_json::Value) {
9077 use wiremock::{
9078 Mock, ResponseTemplate,
9079 matchers::{method, path},
9080 };
9081 Mock::given(method("GET"))
9082 .and(path("/sapi/v1/margin/delist-schedule"))
9083 .respond_with(ResponseTemplate::new(200).set_body_json(schedule))
9084 .with_priority(1)
9085 .mount(server)
9086 .await;
9087 }
9088
9089 fn btcusdt_pair(is_margin_trade: bool, is_sell_allowed: bool) -> serde_json::Value {
9090 serde_json::json!([{
9091 "base": "BTC", "quote": "USDT", "symbol": "BTCUSDT",
9092 "isMarginTrade": is_margin_trade, "isSellAllowed": is_sell_allowed, "isBuyAllowed": true,
9093 }])
9094 }
9095
9096 fn btc_asset(is_borrowable: bool) -> serde_json::Value {
9097 serde_json::json!([{ "assetName": "BTC", "isBorrowable": is_borrowable }])
9098 }
9099
9100 fn btc_rate(rate: &str) -> serde_json::Value {
9101 serde_json::json!([{ "asset": "BTC", "nextHourlyInterestRate": rate }])
9102 }
9103
9104 async fn query_value(server: &wiremock::MockServer, route: &str, key: &str) -> Option<String> {
9106 server
9107 .received_requests()
9108 .await
9109 .unwrap()
9110 .iter()
9111 .find(|request| request.url.path() == route)
9112 .and_then(|request| {
9113 request
9114 .url
9115 .query_pairs()
9116 .find(|(name, _)| name == key)
9117 .map(|(_, value)| value.into_owned())
9118 })
9119 }
9120
9121 #[tokio::test]
9122 async fn margin_shortability_reads_the_pair_the_asset_and_the_annualised_rate() {
9123 use crate::client::ShortabilityClient;
9124 use rust_decimal_macros::dec;
9125 for (is_isolated, pairs_route, flag) in [
9126 (false, "/sapi/v1/margin/allPairs", "FALSE"),
9127 (true, "/sapi/v1/margin/isolated/allPairs", "TRUE"),
9128 ] {
9129 let server = wiremock::MockServer::start().await;
9130 mount_borrow_terms(
9131 &server,
9132 btcusdt_pair(true, true),
9133 btc_asset(true),
9134 btc_rate("0.00000571"),
9135 serde_json::json!({}),
9136 )
9137 .await;
9138
9139 let shortability = margin_client_at(&server, is_isolated)
9140 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9141 .await
9142 .unwrap();
9143
9144 assert_eq!(
9145 shortability,
9146 Shortability::new()
9147 .with_shortable(true)
9148 .with_fee_rate(dec!(0.00000571) * dec!(8760)),
9149 "isolated: {is_isolated}"
9150 );
9151 assert_eq!(
9152 query_value(&server, pairs_route, "symbol").await.as_deref(),
9153 Some("BTCUSDT"),
9154 "the pair list is asked for the one symbol"
9155 );
9156 assert_eq!(
9157 query_value(&server, "/sapi/v1/margin/allAssets", "asset")
9158 .await
9159 .as_deref(),
9160 Some("BTC"),
9161 "the base asset is the one borrowed"
9162 );
9163 let rate = "/sapi/v1/margin/next-hourly-interest-rate";
9164 assert_eq!(
9165 query_value(&server, rate, "assets").await.as_deref(),
9166 Some("BTC")
9167 );
9168 assert_eq!(
9169 query_value(&server, rate, "isIsolated").await.as_deref(),
9170 Some(flag)
9171 );
9172 }
9173 }
9174
9175 #[tokio::test]
9176 async fn margin_shortability_is_refused_by_any_flag_and_unknown_without_them() {
9177 use crate::client::ShortabilityClient;
9178 let cases = [
9179 (btcusdt_pair(false, true), btc_asset(true), Some(false)),
9180 (btcusdt_pair(true, false), btc_asset(true), Some(false)),
9181 (btcusdt_pair(true, true), btc_asset(false), Some(false)),
9182 (btcusdt_pair(true, true), serde_json::json!([]), None),
9184 ];
9185 for (pair, asset, shortable) in cases {
9186 let server = wiremock::MockServer::start().await;
9187 mount_borrow_terms(
9188 &server,
9189 pair.clone(),
9190 asset.clone(),
9191 serde_json::json!([]),
9192 serde_json::json!({}),
9193 )
9194 .await;
9195
9196 let shortability = margin_client_at(&server, false)
9197 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9198 .await
9199 .unwrap();
9200
9201 assert_eq!(shortability.shortable, shortable, "{pair} {asset}");
9202 assert_eq!(
9203 shortability.fee_rate, None,
9204 "no rate returned is an unknown fee"
9205 );
9206 assert_eq!(shortability.easy_to_borrow, None);
9207 assert_eq!(shortability.available, None);
9208 }
9209 }
9210
9211 #[tokio::test]
9215 async fn margin_shortability_reads_the_delisting_schedule_for_this_margin() {
9216 use crate::client::ShortabilityClient;
9217 let past = Utc::now() - chrono::Duration::hours(1);
9218 let soon = Utc::now() + chrono::Duration::days(3);
9219 let later = soon + chrono::Duration::days(7);
9220 let entry = |time: DateTime<Utc>, assets: &[&str], symbols: &[&str]| {
9221 serde_json::json!({
9222 "delistTime": time.timestamp_millis(),
9223 "crossMarginAssets": assets,
9224 "isolatedMarginSymbols": symbols,
9225 })
9226 };
9227 let millis = |time: DateTime<Utc>| DateTime::from_timestamp_millis(time.timestamp_millis());
9228 let cases = [
9229 (
9231 false,
9232 vec![entry(soon, &["BTC"], &[])],
9233 millis(soon),
9234 Some(true),
9235 ),
9236 (
9237 false,
9238 vec![entry(soon, &["USDT"], &[])],
9239 millis(soon),
9240 Some(true),
9241 ),
9242 (
9243 false,
9244 vec![
9245 entry(later, &["BTC"], &[]),
9246 entry(soon, &["ETH", "USDT"], &[]),
9247 ],
9248 millis(soon),
9249 Some(true),
9250 ),
9251 (
9252 false,
9253 vec![entry(soon, &["ETH"], &["BTCUSDT"])],
9254 None,
9255 Some(true),
9256 ),
9257 (
9258 false,
9259 vec![entry(past, &["BTC"], &[])],
9260 millis(past),
9261 Some(false),
9262 ),
9263 (
9265 true,
9266 vec![entry(soon, &[], &["BTCUSDT"])],
9267 millis(soon),
9268 Some(true),
9269 ),
9270 (
9271 true,
9272 vec![entry(soon, &["BTC", "USDT"], &["ETHUSDT"])],
9273 None,
9274 Some(true),
9275 ),
9276 (
9277 true,
9278 vec![entry(past, &[], &["BTCUSDT"])],
9279 millis(past),
9280 Some(false),
9281 ),
9282 ];
9283 for (is_isolated, schedule, delisting_at, shortable) in cases {
9284 let server = wiremock::MockServer::start().await;
9285 mount_borrow_terms(
9286 &server,
9287 btcusdt_pair(true, true),
9288 btc_asset(true),
9289 serde_json::json!([]),
9290 serde_json::json!({}),
9291 )
9292 .await;
9293 let schedule = serde_json::Value::from(schedule);
9294 mount_delist_schedule(&server, schedule.clone()).await;
9295
9296 let shortability = margin_client_at(&server, is_isolated)
9297 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9298 .await
9299 .unwrap();
9300
9301 let case = format!("isolated: {is_isolated}, {schedule}");
9302 assert_eq!(shortability.delisting_at, delisting_at, "{case}");
9303 assert_eq!(shortability.shortable, shortable, "{case}");
9304 }
9305 }
9306
9307 #[tokio::test]
9309 async fn margin_shortability_of_a_scheduled_pair_without_a_time_is_an_error() {
9310 use crate::client::ShortabilityClient;
9311 let server = wiremock::MockServer::start().await;
9312 mount_borrow_terms(
9313 &server,
9314 btcusdt_pair(true, true),
9315 btc_asset(true),
9316 serde_json::json!([]),
9317 serde_json::json!({}),
9318 )
9319 .await;
9320 mount_delist_schedule(
9321 &server,
9322 serde_json::json!([{ "crossMarginAssets": ["BTC"] }]),
9323 )
9324 .await;
9325
9326 let result = margin_client_at(&server, false)
9327 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9328 .await;
9329
9330 assert!(
9331 matches!(result, Err(UnindexedClientError::Internal(ref message))
9332 if message.contains("delistTime")),
9333 "{result:?}"
9334 );
9335 }
9336
9337 #[tokio::test]
9338 async fn margin_shortability_of_an_unlisted_pair_is_an_invalid_instrument() {
9339 use crate::client::ShortabilityClient;
9340 let server = wiremock::MockServer::start().await;
9341 mount_borrow_terms(
9342 &server,
9343 serde_json::json!([]),
9344 btc_asset(true),
9345 btc_rate("0.00000571"),
9346 serde_json::json!({}),
9347 )
9348 .await;
9349
9350 let result = margin_client_at(&server, false)
9351 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9352 .await;
9353
9354 assert!(
9355 matches!(
9356 result,
9357 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
9358 if instrument.name().as_str() == "BTCUSDT"
9359 ),
9360 "{result:?}"
9361 );
9362 }
9363
9364 #[tokio::test]
9365 async fn margin_borrow_capacity_asks_for_the_asset_the_side_borrows() {
9366 use crate::client::BorrowCapacityClient;
9367 use rust_decimal_macros::dec;
9368 for (is_isolated, side, asset, isolated_symbol) in [
9369 (false, Side::Sell, "BTC", None),
9370 (false, Side::Buy, "USDT", None),
9371 (true, Side::Sell, "BTC", Some("BTCUSDT")),
9372 ] {
9373 let server = wiremock::MockServer::start().await;
9374 mount_borrow_terms(
9375 &server,
9376 btcusdt_pair(true, true),
9377 btc_asset(true),
9378 btc_rate("0.00000571"),
9379 serde_json::json!({ "amount": "1.69248805", "borrowLimit": "60" }),
9380 )
9381 .await;
9382
9383 let capacity = margin_client_at(&server, is_isolated)
9384 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), side)
9385 .await
9386 .unwrap();
9387
9388 assert_eq!(
9389 capacity,
9390 BorrowCapacity::new(AssetNameExchange::new(asset))
9391 .with_borrowable_now(dec!(1.69248805))
9392 .with_account_limit(dec!(60)),
9393 "{side:?} isolated: {is_isolated}"
9394 );
9395 let max_borrow = "/sapi/v1/margin/maxBorrowable";
9396 assert_eq!(
9397 query_value(&server, max_borrow, "asset").await.as_deref(),
9398 Some(asset)
9399 );
9400 assert_eq!(
9401 query_value(&server, max_borrow, "isolatedSymbol")
9402 .await
9403 .as_deref(),
9404 isolated_symbol,
9405 "only isolated margin names the pair's account"
9406 );
9407 }
9408 }
9409
9410 #[tokio::test]
9411 async fn margin_borrow_capacity_keeps_an_omitted_amount_unknown_and_fails_a_malformed_one() {
9412 use crate::client::BorrowCapacityClient;
9413 use rust_decimal_macros::dec;
9414 let instrument = InstrumentNameExchange::new("BTCUSDT");
9415
9416 let server = wiremock::MockServer::start().await;
9417 mount_borrow_terms(
9418 &server,
9419 btcusdt_pair(true, true),
9420 btc_asset(true),
9421 btc_rate("0"),
9422 serde_json::json!({ "borrowLimit": "60" }),
9423 )
9424 .await;
9425 let capacity = margin_client_at(&server, false)
9426 .fetch_borrow_capacity(&instrument, Side::Sell)
9427 .await
9428 .unwrap();
9429 assert_eq!(capacity.borrowable_now, None);
9430 assert_eq!(capacity.account_limit, Some(dec!(60)));
9431
9432 let server = wiremock::MockServer::start().await;
9433 mount_borrow_terms(
9434 &server,
9435 btcusdt_pair(true, true),
9436 btc_asset(true),
9437 btc_rate("0"),
9438 serde_json::json!({ "amount": "lots", "borrowLimit": "60" }),
9439 )
9440 .await;
9441 let result = margin_client_at(&server, false)
9442 .fetch_borrow_capacity(&instrument, Side::Sell)
9443 .await;
9444 assert!(
9445 matches!(result, Err(UnindexedClientError::Internal(ref message)) if message.contains("amount")),
9446 "{result:?}"
9447 );
9448 }
9449
9450 #[tokio::test]
9451 async fn margin_borrow_capacity_without_an_isolated_account_names_the_pair() {
9452 use crate::client::BorrowCapacityClient;
9453 use wiremock::{
9454 Mock,
9455 matchers::{method, path},
9456 };
9457 let server = wiremock::MockServer::start().await;
9458 Mock::given(method("GET"))
9459 .and(path("/sapi/v1/margin/maxBorrowable"))
9460 .respond_with(
9461 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
9462 "code": -11001, "msg": "Isolated margin account does not exist."
9463 })),
9464 )
9465 .mount(&server)
9466 .await;
9467 mount_borrow_terms(
9468 &server,
9469 btcusdt_pair(true, true),
9470 btc_asset(true),
9471 btc_rate("0"),
9472 serde_json::json!({}),
9473 )
9474 .await;
9475
9476 let err = margin_client_at(&server, true)
9477 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), Side::Sell)
9478 .await
9479 .unwrap_err();
9480
9481 assert_eq!(
9482 err.to_string(),
9483 "API: request rejected: BTCUSDT: -11001 Isolated margin account does not exist."
9484 );
9485 assert!(!err.is_transient());
9486 }
9487}