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, unreadable_answer,
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::{MarginBorrow, MarginEffect, MarginPlacement},
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)]
487pub struct BinanceMargin {
488 config: Arc<BinanceMarginConfig>,
489 rest: Arc<RestApi>,
491 rest_config: Arc<ConfigurationRestApi>,
496 ws_config: ConfigurationWebsocketApi,
500 rate_limiter: Arc<RateLimitTracker>,
502 known_live: SharedKnownLiveOrders,
505 rules: Arc<RulesLoader>,
508}
509
510impl std::fmt::Debug for BinanceMargin {
511 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
512 f.debug_struct("BinanceMargin")
513 .field("testnet", &self.config.testnet)
514 .field("is_isolated", &self.config.is_isolated)
515 .field("margin_effect", &self.config.margin_effect)
516 .finish_non_exhaustive()
517 }
518}
519
520impl BinanceMargin {
521 pub async fn load_rules(
529 &self,
530 instruments: &[InstrumentNameExchange],
531 ) -> Result<HashMap<InstrumentNameExchange, OrderRules>, UnindexedClientError> {
532 self.rules.load(instruments).await
533 }
534
535 pub async fn open_margin_order(
575 &self,
576 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
577 ) -> MarginPlacement {
578 let instrument = request.key.instrument.clone();
579 let side = request.state.side;
580 let price = request.state.price;
581 let quantity = request.state.quantity;
582 let kind = request.state.kind;
583 let time_in_force = request.state.time_in_force;
584 let cid = request.key.cid.clone();
585
586 let order_key = OrderKey::new(
587 ExchangeId::BinanceMargin,
588 instrument.clone(),
589 request.key.strategy.clone(),
590 cid.clone(),
591 );
592
593 let inactive = |state: UnindexedOrderState| MarginPlacement {
596 order: Order {
597 key: order_key.clone(),
598 side,
599 price,
600 quantity,
601 kind,
602 time_in_force,
603 state,
604 },
605 borrowed: None,
606 };
607
608 let params = match build_new_order_params(
609 instrument.name().to_string(),
610 side,
611 price,
612 quantity,
613 kind,
614 time_in_force,
615 cid.0.to_string(),
616 request
617 .state
618 .margin_effect
619 .unwrap_or(self.config.margin_effect),
620 self.config.auto_repay_at_cancel,
621 self.config.is_isolated,
622 ) {
623 Ok(params) => params,
624 Err(BuildOrderError::Unsupported) => {
625 return inactive(OrderState::inactive(OrderError::UnsupportedOrderType(
626 format!(
627 "BinanceMargin does not support OrderKind::{kind:?} with {time_in_force:?}"
628 ),
629 )));
630 }
631 Err(BuildOrderError::Trailing(refused)) => {
632 return inactive(OrderState::inactive(refused.into_order_error()));
633 }
634 Err(BuildOrderError::Build(msg)) => {
635 error!(%msg, "BinanceMargin failed to build new order params");
636 return inactive(OrderState::inactive(OrderError::Rejected(
637 ApiError::OrderRejected(msg),
638 )));
639 }
640 };
641
642 if let Err(refused) = self
643 .rules
644 .check(instrument.name(), kind, price, quantity)
645 .await
646 {
647 return inactive(OrderState::inactive(refused));
648 }
649
650 let query = new_order_query(params);
653 let response = match rest_call_with_retry(
654 &self.rest_config,
655 &self.rate_limiter,
656 RequestKind::Order,
657 |cfg| {
658 let query = query.clone();
659 Box::pin(async move {
660 binance_sdk::common::utils::send_request::<NewOrderAnswer>(
661 &cfg,
662 NEW_ORDER_PATH,
663 reqwest::Method::POST,
664 query,
665 BTreeMap::new(),
666 None,
667 true,
668 )
669 .await
670 })
671 },
672 )
673 .await
674 {
675 Ok(response) => response,
676 Err(e) => {
677 return inactive(OrderState::inactive(classify_rest_order_error(
678 &e,
679 &instrument,
680 )));
681 }
682 };
683
684 let data = match response.data().await {
685 Ok(data) => data,
686 Err(e) => {
687 return inactive(OrderState::inactive(unreadable_answer(
688 ExchangeId::BinanceMargin,
689 &e.to_string(),
690 )));
691 }
692 };
693
694 let time_exchange = data
695 .transact_time
696 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
697 .unwrap_or_else(Utc::now);
698
699 let exchange_order_id = match data.order_id {
700 Some(id) => OrderId(format_smolstr!("{id}")),
701 None => {
702 return inactive(OrderState::inactive(unreadable_answer(
703 ExchangeId::BinanceMargin,
704 "open_order answer has no orderId",
705 )));
706 }
707 };
708
709 let borrowed = match (data.margin_buy_borrow_amount, data.margin_buy_borrow_asset) {
710 (Some(amount), Some(asset)) => Some(MarginBorrow::new(asset, amount)),
711 (None, None) => None,
712 (Some(_), None) | (None, Some(_)) => {
713 return inactive(OrderState::inactive(unreadable_answer(
714 ExchangeId::BinanceMargin,
715 "open_order answer has a borrowed amount or asset without the other",
716 )));
717 }
718 };
719
720 let state = placed_order_state(
723 ExchangeId::BinanceMargin,
724 &instrument,
725 quantity,
726 PlacementResponse {
727 status: data.status.as_deref(),
728 order_id: exchange_order_id,
729 time_exchange,
730 executed_qty: data.executed_qty.as_deref(),
731 cumulative_quote_qty: data.cummulative_quote_qty.as_deref(),
732 },
733 );
734 self.known_live
735 .lock()
736 .placed(&order_key, quantity, kind, &state);
737
738 MarginPlacement {
739 order: Order {
740 key: order_key,
741 side,
742 price,
743 quantity,
744 kind,
745 time_in_force,
746 state,
747 },
748 borrowed,
749 }
750 }
751
752 fn remember_live<'a>(
754 &self,
755 orders: impl IntoIterator<Item = &'a Order<ExchangeId, InstrumentNameExchange, Open>>,
756 ) {
757 let mut known = self.known_live.lock();
758 for order in orders {
759 known.live(&order.key, order.quantity, order.kind, &order.state);
760 }
761 }
762
763 #[allow(clippy::expect_used)] fn build_rest_config(config: &BinanceMarginConfig) -> ConfigurationRestApi {
774 ConfigurationRestApi::builder()
775 .api_key(config.api_key.clone())
776 .api_secret(config.secret_key.clone())
777 .base_path(MARGIN_TRADING_REST_API_PROD_URL)
778 .build()
779 .expect("failed to build Binance margin REST configuration")
780 }
781
782 #[allow(clippy::expect_used)] fn build_ws_config(config: &BinanceMarginConfig) -> ConfigurationWebsocketApi {
793 ConfigurationWebsocketApi::builder()
794 .api_key(config.api_key.clone())
795 .api_secret(config.secret_key.clone())
796 .ws_url(SPOT_WS_API_PROD_URL)
800 .build()
801 .expect("failed to build Binance margin WebSocket configuration")
802 }
803
804 fn effective_isolated_set(
817 &self,
818 instruments: &[InstrumentNameExchange],
819 ) -> Vec<InstrumentNameExchange> {
820 if instruments.is_empty() {
821 return self.config.isolated_symbols.clone();
822 }
823 instruments
824 .iter()
825 .filter(|inst| {
826 let in_set = self.config.isolated_symbols.contains(*inst);
827 if !in_set {
828 warn!(
829 instrument = %inst.name(),
830 "BinanceMargin isolated: requested instrument not in configured isolated_symbols — skipping"
831 );
832 }
833 in_set
834 })
835 .cloned()
836 .collect()
837 }
838
839 async fn isolated_account_snapshot(
843 &self,
844 instruments: &[InstrumentNameExchange],
845 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
846 use futures::{StreamExt as _, TryStreamExt as _};
847
848 let effective = self.effective_isolated_set(instruments);
849 if effective.is_empty() {
850 return Ok(AccountSnapshot::new(
853 ExchangeId::BinanceMargin,
854 Vec::new(),
855 Vec::new(),
856 ));
857 }
858
859 let mut balances_by_symbol = convert_isolated_margin_assets(
861 fetch_isolated_margin_account_info(
862 self.rest.clone(),
863 self.rate_limiter.clone(),
864 effective.clone(),
865 )
866 .await?,
867 );
868
869 let instrument_snapshots: Vec<_> =
872 futures::stream::iter(effective.into_iter().map(|instrument| {
873 fetch_margin_open_orders_for_instrument(
874 self.rest.clone(),
875 self.rate_limiter.clone(),
876 instrument,
877 true,
878 RequestKind::Query,
879 )
880 }))
881 .buffer_unordered(8)
882 .map(|result| {
883 let (inst, listing) = result?;
884 self.remember_live(&listing.orders);
885 let wrapped = listing
886 .orders
887 .into_iter()
888 .map(active_order_snapshot)
889 .collect();
890 let isolated = balances_by_symbol.remove(&inst);
891 if isolated.is_none() {
892 warn!(
896 instrument = %inst.name(),
897 "BinanceMargin isolated: no isolated balance entry returned for instrument — snapshot will carry isolated: None"
898 );
899 }
900 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
901 inst,
902 wrapped,
903 listing.complete,
904 PositionReport::Unreported,
905 isolated,
906 ))
907 })
908 .try_collect()
909 .await?;
910
911 Ok(AccountSnapshot::new(
914 ExchangeId::BinanceMargin,
915 Vec::new(),
916 instrument_snapshots,
917 ))
918 }
919}
920
921impl ExecutionClient for BinanceMargin {
922 const EXCHANGE: ExchangeId = ExchangeId::BinanceMargin;
923
924 const SUPPORTED_KINDS: &'static [InstrumentKindDiscriminant] =
927 &[InstrumentKindDiscriminant::Spot];
928 type Config = BinanceMarginConfig;
929 type AccountStream = BoxStream<'static, UnindexedAccountEvent>;
930
931 fn new(config: Self::Config) -> Self {
944 if config.testnet {
945 warn!(
946 "BinanceMarginConfig.testnet = true is ignored: Binance margin has no testnet; \
947 using production endpoints"
948 );
949 }
950 assert!(
951 !(config.is_isolated && config.isolated_symbols.is_empty()),
952 "BinanceMarginConfig: is_isolated = true requires a non-empty isolated_symbols"
953 );
954 let rest_config = Self::build_rest_config(&config);
959 let rest = Arc::new(MarginTradingRestApi::production(rest_config.clone()));
960 let rules = Arc::new(RulesLoader::new(
964 config.pre_send_rules,
965 Arc::new(binance_sdk::spot::SpotRestApi::production(
966 rest_config.clone(),
967 )),
968 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
969 ));
970 let rest_config = Arc::new(rest_config);
971 let ws_config = Self::build_ws_config(&config);
972 Self {
973 config: Arc::new(config),
974 rest,
975 rest_config,
976 ws_config,
977 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
978 known_live: KnownLiveOrders::shared(ExchangeId::BinanceMargin),
979 rules,
980 }
981 }
982
983 async fn open_order(
988 &self,
989 request: OrderRequestOpen<ExchangeId, &InstrumentNameExchange>,
990 ) -> Order<ExchangeId, InstrumentNameExchange, UnindexedOrderState> {
991 self.open_margin_order(request).await.order
992 }
993
994 async fn cancel_order(
1008 &self,
1009 request: OrderRequestCancel<ExchangeId, &InstrumentNameExchange>,
1010 ) -> UnindexedOrderResponseCancel {
1011 let instrument = request.key.instrument.clone();
1012 let key = OrderKey {
1013 exchange: request.key.exchange,
1014 instrument: instrument.clone(),
1015 strategy: request.key.strategy.clone(),
1016 cid: request.key.cid.clone(),
1017 };
1018
1019 let params = match build_cancel_order_params(
1020 instrument.name().to_string(),
1021 request.state.id.as_ref().and_then(VenueOrderId::assigned),
1022 &request.key.cid,
1023 self.config.is_isolated,
1024 ) {
1025 Ok(p) => p,
1026 Err(e) => {
1027 error!(%e, "BinanceMargin failed to build cancel order params");
1028 return UnindexedOrderResponseCancel {
1029 key,
1030 state: Err(OrderError::Rejected(ApiError::OrderRejected(e))),
1031 };
1032 }
1033 };
1034
1035 let response =
1036 match rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Order, |rest| {
1037 let params = params.clone();
1038 Box::pin(async move { rest.margin_account_cancel_order(params).await })
1039 })
1040 .await
1041 {
1042 Ok(response) => response,
1043 Err(e) => {
1044 return UnindexedOrderResponseCancel {
1045 key,
1046 state: Err(classify_rest_order_error(&e, &instrument)),
1047 };
1048 }
1049 };
1050
1051 let data = match response.data().await {
1052 Ok(data) => data,
1053 Err(e) => {
1054 return UnindexedOrderResponseCancel {
1055 key,
1056 state: Err(unreadable_answer(ExchangeId::BinanceMargin, &e.to_string())),
1057 };
1058 }
1059 };
1060
1061 let time_exchange = Utc::now();
1063
1064 let exchange_order_id = match data.order_id {
1065 Some(id) => OrderId(SmolStr::new(id)),
1068 None => {
1069 return UnindexedOrderResponseCancel {
1070 key,
1071 state: Err(unreadable_answer(
1072 ExchangeId::BinanceMargin,
1073 "cancel answer has no orderId",
1074 )),
1075 };
1076 }
1077 };
1078
1079 let filled_qty = binance_filled_qty(
1080 ExchangeId::BinanceMargin,
1081 &exchange_order_id,
1082 data.executed_qty.as_deref(),
1083 );
1084
1085 self.known_live.lock().ended(&key.cid);
1086 UnindexedOrderResponseCancel {
1087 key,
1088 state: Ok(Cancelled::new(exchange_order_id, time_exchange, filled_qty)),
1089 }
1090 }
1091
1092 async fn account_snapshot(
1115 &self,
1116 assets: &[AssetNameExchange],
1117 instruments: &[InstrumentNameExchange],
1118 ) -> Result<UnindexedAccountSnapshot, UnindexedClientError> {
1119 if self.config.is_isolated {
1120 return self.isolated_account_snapshot(instruments).await;
1121 }
1122 let response =
1123 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1124 Box::pin(async move {
1125 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1126 rest.query_cross_margin_account_details(params).await
1127 })
1128 })
1129 .await
1130 .map_err(|e| classify_rest_query_error(&e, None))?;
1131
1132 let account = response.data().await.map_err(response_decode_error)?;
1133
1134 let balances =
1135 filter_and_convert_margin_balances(account.user_assets.unwrap_or_default(), assets);
1136
1137 use futures::{StreamExt as _, TryStreamExt as _};
1142 let instrument_snapshots: Vec<_> =
1143 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1144 fetch_margin_open_orders_for_instrument(
1145 self.rest.clone(),
1146 self.rate_limiter.clone(),
1147 instrument,
1148 false,
1150 RequestKind::Query,
1151 )
1152 }))
1153 .buffer_unordered(8)
1154 .map(|result| {
1155 let (inst, listing) = result?;
1156 self.remember_live(&listing.orders);
1157 let wrapped = listing
1158 .orders
1159 .into_iter()
1160 .map(active_order_snapshot)
1161 .collect();
1162 Ok::<_, UnindexedClientError>(InstrumentAccountSnapshot::new(
1163 inst,
1164 wrapped,
1165 listing.complete,
1166 PositionReport::Unreported,
1167 None,
1168 ))
1169 })
1170 .try_collect()
1171 .await?;
1172
1173 Ok(AccountSnapshot::new(
1174 ExchangeId::BinanceMargin,
1175 balances,
1176 instrument_snapshots,
1177 ))
1178 }
1179
1180 async fn fetch_balances(
1194 &self,
1195 assets: &[AssetNameExchange],
1196 ) -> Result<Vec<AssetBalance<AssetNameExchange>>, UnindexedClientError> {
1197 if self.config.is_isolated {
1198 return Ok(Vec::new());
1199 }
1200 let response =
1201 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1202 Box::pin(async move {
1203 let params = QueryCrossMarginAccountDetailsParams::builder().build()?;
1204 rest.query_cross_margin_account_details(params).await
1205 })
1206 })
1207 .await
1208 .map_err(|e| classify_rest_query_error(&e, None))?;
1209
1210 let account = response.data().await.map_err(response_decode_error)?;
1211
1212 Ok(filter_and_convert_margin_balances(
1213 account.user_assets.unwrap_or_default(),
1214 assets,
1215 ))
1216 }
1217
1218 async fn fetch_open_orders(
1237 &self,
1238 instruments: &[InstrumentNameExchange],
1239 ) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
1240 use futures::{StreamExt as _, TryStreamExt as _};
1241
1242 if self.config.is_isolated {
1246 let effective = self.effective_isolated_set(instruments);
1247 let capacity = effective.len();
1248 return futures::stream::iter(effective.into_iter().map(|instrument| {
1249 fetch_margin_open_orders_for_instrument(
1250 self.rest.clone(),
1251 self.rate_limiter.clone(),
1252 instrument,
1253 true,
1254 RequestKind::Query,
1255 )
1256 }))
1257 .buffer_unordered(8)
1258 .try_fold(
1259 Vec::with_capacity(capacity),
1260 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>,
1261 (_, listing)| async move {
1262 acc.extend(listing.orders);
1263 Ok(acc)
1264 },
1265 )
1266 .await
1267 .inspect(|orders| self.remember_live(orders));
1268 }
1269
1270 if instruments.is_empty() {
1273 let orders =
1274 fetch_margin_all_open_orders(self.rest.clone(), self.rate_limiter.clone(), false)
1275 .await?;
1276 self.remember_live(&orders);
1277 return Ok(orders);
1278 }
1279 futures::stream::iter(instruments.iter().cloned().map(|instrument| {
1280 fetch_margin_open_orders_for_instrument(
1281 self.rest.clone(),
1282 self.rate_limiter.clone(),
1283 instrument,
1284 false,
1285 RequestKind::Query,
1286 )
1287 }))
1288 .buffer_unordered(8)
1289 .try_fold(
1290 Vec::with_capacity(instruments.len()),
1291 |mut acc: Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, (_, listing)| async move {
1292 acc.extend(listing.orders);
1293 Ok(acc)
1294 },
1295 )
1296 .await
1297 .inspect(|orders| self.remember_live(orders))
1298 }
1299
1300 async fn fetch_trades(
1329 &self,
1330 start: DateTime<Utc>,
1331 end: DateTime<Utc>,
1332 instruments: &[InstrumentNameExchange],
1333 ) -> Result<TradesRead<AssetNameExchange, InstrumentNameExchange>, UnindexedClientError> {
1334 use futures::StreamExt as _;
1335
1336 if start > end {
1337 return Ok(TradesRead::complete(Vec::new()));
1338 }
1339
1340 let effective: Vec<InstrumentNameExchange> = if self.config.is_isolated {
1343 self.effective_isolated_set(instruments)
1344 } else {
1345 instruments.to_vec()
1346 };
1347 if effective.is_empty() {
1348 debug!(
1351 is_isolated = self.config.is_isolated,
1352 "BinanceMargin fetch_trades: empty effective instrument set — returning empty result"
1353 );
1354 return Ok(TradesRead::complete(Vec::new()));
1355 }
1356 let span = MyTradesFrom::Span {
1358 start: start.timestamp_millis(),
1359 end: end.timestamp_millis(),
1360 };
1361 let is_isolated = self.config.is_isolated;
1362 let mut all_trades = Vec::new();
1363
1364 let mut stream = futures::stream::iter(effective.into_iter().map(|inst| {
1367 let rest = self.rest.clone();
1368 let rate_limiter = self.rate_limiter.clone();
1369 async move {
1370 let pages = paginate_margin_my_trades(
1371 &rest,
1372 &rate_limiter,
1373 &inst,
1374 span,
1375 is_isolated,
1376 RequestKind::Query,
1377 )
1378 .await?;
1379 Ok::<_, UnindexedClientError>((inst, pages))
1380 }
1381 }))
1382 .buffer_unordered(8);
1383 while let Some(result) = stream.next().await {
1384 let (instrument, trades_data) = result?;
1385 for t in trades_data {
1386 if let Some(trade) = convert_margin_trade(&t, &instrument)
1387 && (start..=end).contains(&trade.time_exchange)
1388 {
1389 all_trades.push(trade);
1390 }
1391 }
1392 }
1393
1394 Ok(TradesRead::complete(all_trades))
1395 }
1396
1397 async fn account_stream(
1534 &self,
1535 _assets: &[AssetNameExchange],
1538 instruments: &[InstrumentNameExchange],
1539 ) -> Result<Self::AccountStream, UnindexedClientError> {
1540 let (tx, rx) = mpsc::unbounded_channel::<UnindexedAccountEvent>();
1541 let dedup = new_dedup_cache();
1542 let rest = self.rest.clone();
1543 let rest_config = self.rest_config.clone();
1544 let ws_config = self.ws_config.clone();
1545 let rate_limiter = self.rate_limiter.clone();
1546 let known_live = self.known_live.clone();
1547
1548 let cm_handle = if self.config.is_isolated {
1549 let symbols = self.config.isolated_symbols.clone();
1553 debug_assert!(
1556 symbols.iter().all(|i| i.name().len() <= 23),
1557 "instrument name exceeds SmolStr inline capacity: {:?}",
1558 symbols.iter().find(|i| i.name().len() > 23)
1559 );
1560
1561 let assets = fetch_isolated_margin_account_info(
1567 rest.clone(),
1568 rate_limiter.clone(),
1569 symbols.clone(),
1570 )
1571 .await?;
1572 let base_quote = Arc::new(build_base_quote_map(&assets));
1573 for sym in &symbols {
1574 if !base_quote.contains_key(sym) {
1575 warn!(
1576 symbol = %sym.name(),
1577 "BinanceMargin isolated: account info returned no base/quote for configured \
1578 symbol — its live per-pair balance frames will be dropped (fills/orders \
1579 unaffected; balances available via account_snapshot)"
1580 );
1581 }
1582 }
1583
1584 let tokens = acquire_all_isolated_tokens(&rest_config, &rate_limiter, &symbols).await?;
1588 rate_limiter.before_stream_open().await?;
1591 let live = isolated_connect_and_subscribe(
1592 &ws_config,
1593 &tx,
1594 &dedup,
1595 &known_live,
1596 &base_quote,
1597 &tokens,
1598 )
1599 .await
1600 .map_err(|e| {
1601 rate_limiter.stream_open_error(&e, |e| {
1602 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1603 })
1604 })?;
1605
1606 tokio::spawn(isolated_connection_manager(
1607 tx,
1608 dedup,
1609 ws_config,
1610 rest_config,
1611 rest,
1612 rate_limiter,
1613 known_live,
1614 symbols,
1615 base_quote,
1616 Some(live),
1617 ))
1618 } else {
1619 let instruments = instruments.to_vec();
1621 debug_assert!(
1625 instruments.iter().all(|i| i.name().len() <= 23),
1626 "instrument name exceeds SmolStr inline capacity: {:?}",
1627 instruments.iter().find(|i| i.name().len() > 23)
1628 );
1629
1630 let initial_token =
1636 acquire_user_listen_token(&rest_config, &rate_limiter, None).await?;
1637 rate_limiter.before_stream_open().await?;
1639 let initial_ws = connect_margin_ws(&ws_config).await.map_err(|e| {
1640 rate_limiter.stream_open_error(&e, |e| {
1641 UnindexedClientError::Connectivity(ConnectivityError::Socket(e.to_string()))
1642 })
1643 })?;
1644
1645 let risk = RiskStreamTask::spawn(
1649 SapiRiskListenKeys {
1650 rest: rest.clone(),
1651 rate_limiter: rate_limiter.clone(),
1652 },
1653 RISK_STREAM_URL.to_owned(),
1654 tx.clone(),
1655 dedup.clone(),
1656 );
1657 let manager = margin_connection_manager(
1658 tx,
1659 dedup,
1660 ws_config,
1661 rest_config,
1662 rest,
1663 rate_limiter,
1664 known_live,
1665 instruments,
1666 Some((initial_ws, initial_token)),
1667 );
1668 tokio::spawn(async move {
1669 let _risk = risk;
1670 manager.await;
1671 })
1672 };
1673
1674 let rx_stream = tokio_stream::wrappers::UnboundedReceiverStream::new(rx);
1675 let guarded_stream = AbortOnDropStream::new(rx_stream, cm_handle);
1676 Ok(futures::StreamExt::boxed(guarded_stream))
1677 }
1678}
1679
1680impl OrderStatusClient for BinanceMargin {
1697 async fn fetch_ended_orders(
1698 &self,
1699 orders: &[UnindexedOrderKey],
1700 ) -> Result<Vec<UnindexedInactiveOrder>, UnindexedClientError> {
1701 fetch_ended_by_key(orders, |key| {
1702 let recorded_kind = self.known_live.lock().kind_of(&key.cid);
1703 fetch_margin_order_lookup(
1704 self.rest.clone(),
1705 self.rate_limiter.clone(),
1706 key,
1707 self.config.is_isolated,
1708 RequestKind::Query,
1709 recorded_kind,
1710 )
1711 })
1712 .await
1713 }
1714}
1715
1716const HOURS_PER_YEAR: u32 = 24 * 365;
1722
1723impl ShortabilityClient for BinanceMargin {
1724 async fn fetch_shortability(
1751 &self,
1752 instrument: &InstrumentNameExchange,
1753 ) -> Result<Shortability, UnindexedClientError> {
1754 let pair = self.fetch_margin_pair(instrument).await?;
1755 let (borrowable, hourly_rate, delisting_at) = tokio::try_join!(
1756 self.fetch_asset_borrowable(&pair.base),
1757 self.fetch_next_hourly_interest_rate(&pair.base),
1758 self.fetch_delisting_at(instrument, &pair),
1759 )?;
1760
1761 let mut shortability = Shortability::new();
1762 if let Some(delisting_at) = delisting_at {
1763 shortability = shortability.with_delisting_at(delisting_at);
1764 }
1765 let not_delisted = Some(delisting_at.is_none_or(|delisting_at| delisting_at > Utc::now()));
1769 let flags = [
1770 pair.is_margin_trade,
1771 pair.is_sell_allowed,
1772 borrowable,
1773 not_delisted,
1774 ];
1775 if flags.contains(&Some(false)) {
1776 shortability = shortability.with_shortable(false);
1777 } else if flags.iter().all(|flag| *flag == Some(true)) {
1778 shortability = shortability.with_shortable(true);
1779 }
1780 if let Some(hourly_rate) = hourly_rate {
1781 shortability = shortability.with_fee_rate(hourly_rate * Decimal::from(HOURS_PER_YEAR));
1782 }
1783 Ok(shortability)
1784 }
1785}
1786
1787impl BorrowCapacityClient for BinanceMargin {
1788 async fn fetch_borrow_capacity(
1805 &self,
1806 instrument: &InstrumentNameExchange,
1807 side: Side,
1808 ) -> Result<BorrowCapacity, UnindexedClientError> {
1809 let pair = self.fetch_margin_pair(instrument).await?;
1810 let asset = match side {
1811 Side::Buy => pair.quote,
1812 Side::Sell => pair.base,
1813 };
1814 let isolated_symbol = self
1815 .config
1816 .is_isolated
1817 .then(|| instrument.name().to_string());
1818 let response =
1819 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1820 let asset = asset.name().to_string();
1821 let isolated_symbol = isolated_symbol.clone();
1822 Box::pin(async move {
1823 let params = QueryMaxBorrowParams::builder(asset)
1824 .isolated_symbol(isolated_symbol)
1825 .build()?;
1826 rest.query_max_borrow(params).await
1827 })
1828 })
1829 .await
1830 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1831 let max_borrow = response.data().await.map_err(response_decode_error)?;
1832
1833 let mut capacity = BorrowCapacity::new(asset);
1834 if let Some(amount) = optional_decimal("amount", max_borrow.amount.as_deref())? {
1835 capacity = capacity.with_borrowable_now(amount);
1836 }
1837 if let Some(limit) = optional_decimal("borrowLimit", max_borrow.borrow_limit.as_deref())? {
1838 capacity = capacity.with_account_limit(limit);
1839 }
1840 Ok(capacity)
1841 }
1842}
1843
1844#[derive(Debug)]
1846struct MarginPair {
1847 base: AssetNameExchange,
1848 quote: AssetNameExchange,
1849 is_margin_trade: Option<bool>,
1850 is_sell_allowed: Option<bool>,
1851}
1852
1853impl BinanceMargin {
1854 async fn fetch_margin_pair(
1858 &self,
1859 instrument: &InstrumentNameExchange,
1860 ) -> Result<MarginPair, UnindexedClientError> {
1861 let symbol = instrument.name().to_string();
1862 let listed = if self.config.is_isolated {
1863 let response =
1864 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1865 let symbol = symbol.clone();
1866 Box::pin(async move {
1867 let params = GetAllIsolatedMarginSymbolParams::builder()
1868 .symbol(symbol)
1869 .build()?;
1870 rest.get_all_isolated_margin_symbol(params).await
1871 })
1872 })
1873 .await
1874 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1875 response
1876 .data()
1877 .await
1878 .map_err(response_decode_error)?
1879 .into_iter()
1880 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
1881 .map(|pair| {
1882 (
1883 pair.base,
1884 pair.quote,
1885 pair.is_margin_trade,
1886 pair.is_sell_allowed,
1887 )
1888 })
1889 } else {
1890 let response =
1891 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1892 let symbol = symbol.clone();
1893 Box::pin(async move {
1894 let params = GetAllCrossMarginPairsParams::builder()
1895 .symbol(symbol)
1896 .build()?;
1897 rest.get_all_cross_margin_pairs(params).await
1898 })
1899 })
1900 .await
1901 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
1902 response
1903 .data()
1904 .await
1905 .map_err(response_decode_error)?
1906 .into_iter()
1907 .find(|pair| pair.symbol.as_deref() == Some(symbol.as_str()))
1908 .map(|pair| {
1909 (
1910 pair.base,
1911 pair.quote,
1912 pair.is_margin_trade,
1913 pair.is_sell_allowed,
1914 )
1915 })
1916 };
1917 let margin = if self.config.is_isolated {
1918 "isolated"
1919 } else {
1920 "cross"
1921 };
1922 let Some((base, quote, is_margin_trade, is_sell_allowed)) = listed else {
1923 return Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(
1924 instrument.clone(),
1925 format!("Binance lists no {margin} margin pair {symbol}"),
1926 )));
1927 };
1928 let (Some(base), Some(quote)) = (base, quote) else {
1929 return Err(UnindexedClientError::Internal(format!(
1930 "Binance lists {margin} margin pair {symbol} without its base or quote asset"
1931 )));
1932 };
1933 Ok(MarginPair {
1934 base: AssetNameExchange::new(base),
1935 quote: AssetNameExchange::new(quote),
1936 is_margin_trade,
1937 is_sell_allowed,
1938 })
1939 }
1940
1941 async fn fetch_asset_borrowable(
1944 &self,
1945 asset: &AssetNameExchange,
1946 ) -> Result<Option<bool>, UnindexedClientError> {
1947 let name = asset.name().to_string();
1948 let response =
1949 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1950 let name = name.clone();
1951 Box::pin(async move {
1952 let params = GetAllMarginAssetsParams::builder().asset(name).build()?;
1953 rest.get_all_margin_assets(params).await
1954 })
1955 })
1956 .await
1957 .map_err(|e| classify_rest_query_error(&e, None))?;
1958 Ok(response
1959 .data()
1960 .await
1961 .map_err(response_decode_error)?
1962 .into_iter()
1963 .find(|listed| listed.asset_name.as_deref() == Some(name.as_str()))
1964 .and_then(|listed| listed.is_borrowable))
1965 }
1966
1967 async fn fetch_delisting_at(
1972 &self,
1973 instrument: &InstrumentNameExchange,
1974 pair: &MarginPair,
1975 ) -> Result<Option<DateTime<Utc>>, UnindexedClientError> {
1976 let response =
1977 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
1978 Box::pin(async move {
1979 let params = GetDelistScheduleParams::builder().build()?;
1980 rest.get_delist_schedule(params).await
1981 })
1982 })
1983 .await
1984 .map_err(|e| classify_rest_query_error(&e, None))?;
1985 let schedule = response.data().await.map_err(response_decode_error)?;
1986
1987 let symbol = instrument.name().as_str();
1988 let (base, quote) = (pair.base.name().as_str(), pair.quote.name().as_str());
1989 let mut earliest: Option<DateTime<Utc>> = None;
1990 for entry in schedule {
1991 let scheduled = if self.config.is_isolated {
1992 entry
1993 .isolated_margin_symbols
1994 .iter()
1995 .flatten()
1996 .any(|listed| listed == symbol)
1997 } else {
1998 entry
1999 .cross_margin_assets
2000 .iter()
2001 .flatten()
2002 .any(|listed| listed == base || listed == quote)
2003 };
2004 if !scheduled {
2005 continue;
2006 }
2007 let delisting_at = entry
2009 .delist_time
2010 .and_then(DateTime::from_timestamp_millis)
2011 .ok_or_else(|| {
2012 UnindexedClientError::Internal(format!(
2013 "decoding Binance response: delist-schedule lists {symbol} with delistTime \
2014 {:?}",
2015 entry.delist_time
2016 ))
2017 })?;
2018 earliest = Some(earliest.map_or(delisting_at, |earliest| earliest.min(delisting_at)));
2019 }
2020 Ok(earliest)
2021 }
2022
2023 async fn fetch_next_hourly_interest_rate(
2027 &self,
2028 asset: &AssetNameExchange,
2029 ) -> Result<Option<Decimal>, UnindexedClientError> {
2030 let name = asset.name().to_string();
2031 let is_isolated = self.config.is_isolated;
2032 let response =
2033 rest_call_with_retry(&self.rest, &self.rate_limiter, RequestKind::Query, |rest| {
2034 let name = name.clone();
2035 Box::pin(async move {
2036 let params = GetFutureHourlyInterestRateParams::builder(
2037 name,
2038 IsolatedFlag::from_flag(is_isolated),
2039 )
2040 .build()?;
2041 rest.get_future_hourly_interest_rate(params).await
2042 })
2043 })
2044 .await
2045 .map_err(|e| classify_rest_query_error(&e, None))?;
2046 let rate = response
2047 .data()
2048 .await
2049 .map_err(response_decode_error)?
2050 .into_iter()
2051 .find(|listed| listed.asset.as_deref() == Some(name.as_str()))
2052 .and_then(|listed| listed.next_hourly_interest_rate);
2053 optional_decimal("nextHourlyInterestRate", rate.as_deref())
2054 }
2055}
2056
2057fn optional_decimal(
2060 field: &str,
2061 value: Option<&str>,
2062) -> Result<Option<Decimal>, UnindexedClientError> {
2063 value
2064 .map(|value| {
2065 Decimal::from_str(value).map_err(|error| {
2066 UnindexedClientError::Internal(format!(
2067 "decoding Binance response: {field} {value:?}: {error}"
2068 ))
2069 })
2070 })
2071 .transpose()
2072}
2073
2074const TOKEN_RENEW_MARGIN_SECS: i64 = 300; const TOKEN_MIN_LIFETIME_SECS: u64 = 60;
2082
2083struct UserListenToken {
2085 token: String,
2086 expiration_time_ms: i64,
2087}
2088
2089fn build_listen_token_query(
2093 symbol: Option<&InstrumentNameExchange>,
2094) -> BTreeMap<String, serde_json::Value> {
2095 let mut query = BTreeMap::new();
2096 if let Some(sym) = symbol {
2097 query.insert(
2098 "isIsolated".to_string(),
2099 serde_json::Value::String("TRUE".to_string()),
2100 );
2101 query.insert(
2102 "symbol".to_string(),
2103 serde_json::Value::String(sym.name().to_string()),
2104 );
2105 }
2106 query
2107}
2108
2109#[derive(Deserialize)]
2111struct UserListenTokenResponse {
2112 #[serde(alias = "listenToken")]
2114 token: String,
2115 #[serde(rename = "expirationTime")]
2116 expiration_time: i64,
2117}
2118
2119async fn acquire_user_listen_token(
2134 rest_config: &Arc<ConfigurationRestApi>,
2135 rate_limiter: &RateLimitTracker,
2136 symbol: Option<&InstrumentNameExchange>,
2137) -> Result<UserListenToken, UnindexedClientError> {
2138 let query = build_listen_token_query(symbol);
2141 let response = rest_call_with_retry(rest_config, rate_limiter, RequestKind::Essential, |cfg| {
2142 let query = query.clone();
2143 Box::pin(async move {
2144 binance_sdk::common::utils::send_request::<UserListenTokenResponse>(
2145 &cfg,
2146 "/sapi/v1/userListenToken",
2147 reqwest::Method::POST,
2148 query,
2151 BTreeMap::new(),
2152 None,
2153 true, )
2155 .await
2156 })
2157 })
2158 .await
2159 .map_err(|e| classify_rest_query_error(&e, symbol))?;
2160
2161 let data = response.data().await.map_err(response_decode_error)?;
2162 Ok(UserListenToken {
2163 token: data.token,
2164 expiration_time_ms: data.expiration_time,
2165 })
2166}
2167
2168fn token_renew_after(expiration_time_ms: i64) -> Duration {
2171 let now_ms = Utc::now().timestamp_millis();
2172 let remaining_secs = expiration_time_ms
2176 .saturating_sub(now_ms)
2177 .saturating_div(1_000)
2178 .saturating_sub(TOKEN_RENEW_MARGIN_SECS);
2179 let secs = u64::try_from(remaining_secs).unwrap_or(0);
2180 if secs < TOKEN_MIN_LIFETIME_SECS {
2183 warn!(
2184 computed_secs = secs,
2185 floor_secs = TOKEN_MIN_LIFETIME_SECS,
2186 "BinanceMargin token renewal wait clamped to floor (near-expiry or odd expirationTime)"
2187 );
2188 }
2189 Duration::from_secs(secs.max(TOKEN_MIN_LIFETIME_SECS))
2190}
2191
2192async fn connect_margin_ws(
2199 ws_config: &ConfigurationWebsocketApi,
2200) -> anyhow::Result<DisconnectOnDrop<Arc<WsApiBase>>> {
2201 let ws = WsApiBase::new(ws_config.clone(), vec![]);
2202 tokio::time::timeout(
2205 Duration::from_secs(CONNECT_TIMEOUT_SECS),
2206 ws.clone().connect(),
2207 )
2208 .await
2209 .map_err(|_| {
2210 anyhow::anyhow!("BinanceMargin WS connect timed out after {CONNECT_TIMEOUT_SECS}s")
2211 })??;
2212 Ok(DisconnectOnDrop::new(ws, "BinanceMargin account stream"))
2214}
2215
2216async fn subscribe_listen_token(ws: &Arc<WsApiBase>, token: &str) -> anyhow::Result<()> {
2223 let mut payload = BTreeMap::new();
2226 payload.insert(
2227 "listenToken".to_string(),
2228 serde_json::Value::String(token.to_string()),
2229 );
2230 ws.send_message::<serde_json::Value>(
2231 "userDataStream.subscribe.listenToken",
2232 payload,
2233 WebsocketMessageSendOptions::new(),
2234 )
2235 .await
2236 .map_err(subscribe_error)?;
2237 Ok(())
2238}
2239
2240fn subscribe_error(e: WebsocketError) -> anyhow::Error {
2243 let message = format!("userDataStream.subscribe.listenToken failed: {e}");
2244 anyhow::Error::new(e).context(message)
2245}
2246
2247async fn subscribe_listen_token_capture(
2260 ws: &Arc<WsApiBase>,
2261 token: &str,
2262) -> anyhow::Result<Option<i64>> {
2263 #[derive(Deserialize)]
2266 struct SubscribeAck {
2267 #[serde(rename = "subscriptionId", default)]
2268 subscription_id: Option<i64>,
2269 }
2270
2271 let mut payload = BTreeMap::new();
2272 payload.insert(
2273 "listenToken".to_string(),
2274 serde_json::Value::String(token.to_string()),
2275 );
2276 let result = ws
2277 .send_message::<SubscribeAck>(
2278 "userDataStream.subscribe.listenToken",
2279 payload,
2280 WebsocketMessageSendOptions::new(),
2281 )
2282 .await
2283 .map_err(subscribe_error)?;
2284 let ack = match result {
2285 SendWebsocketMessageResult::Single(resp) => resp.data()?,
2286 SendWebsocketMessageResult::Multiple(responses) => responses
2288 .into_iter()
2289 .next()
2290 .ok_or_else(|| anyhow::anyhow!("empty subscribe ack"))?
2291 .data()?,
2292 };
2293 Ok(ack.subscription_id)
2294}
2295
2296fn cross_account_position_handler(
2300 position: OutboundAccountPosition,
2301 _subscription_id: Option<i64>,
2302 buf: &mut Vec<UnindexedAccountEvent>,
2303) {
2304 convert_margin_account_position(position, buf);
2305}
2306
2307fn cross_notice_instrument(_subscription_id: Option<i64>) -> Option<InstrumentNameExchange> {
2311 None
2312}
2313
2314fn isolated_notice_instrument(
2320 subscription_id: Option<i64>,
2321 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
2322) -> Option<InstrumentNameExchange> {
2323 let Some(sub_id) = subscription_id else {
2324 warn!(
2325 "BinanceMargin isolated: MARGIN_LEVEL_STATUS_CHANGE without subscriptionId — \
2326 delivering the notice without an instrument"
2327 );
2328 return None;
2329 };
2330 let instrument = sub_map
2331 .lock()
2332 .unwrap_or_else(|p| p.into_inner())
2333 .get(&sub_id)
2334 .cloned();
2335 if instrument.is_none() {
2336 warn!(
2337 subscription_id = sub_id,
2338 "BinanceMargin isolated: MARGIN_LEVEL_STATUS_CHANGE for unmapped subscriptionId — \
2339 delivering the notice without an instrument"
2340 );
2341 }
2342 instrument
2343}
2344
2345#[cfg(test)]
2351fn convert_margin_user_data_events(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> bool {
2352 matches!(
2353 convert_margin_user_data_events_with(
2354 frame,
2355 buf,
2356 &mut cross_account_position_handler,
2357 &mut cross_notice_instrument,
2358 ),
2359 FrameOutcome::Pushed { terminated: true }
2360 )
2361}
2362
2363#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2365enum FrameOutcome {
2366 Response,
2368 Pushed { terminated: bool },
2370}
2371
2372fn convert_margin_user_data_events_with(
2390 frame: &str,
2391 buf: &mut Vec<UnindexedAccountEvent>,
2392 handle_position: &mut impl FnMut(
2393 OutboundAccountPosition,
2394 Option<i64>,
2395 &mut Vec<UnindexedAccountEvent>,
2396 ),
2397 notice_instrument: &mut impl FnMut(Option<i64>) -> Option<InstrumentNameExchange>,
2398) -> FrameOutcome {
2399 let (subscription_id, event_type, event_raw) = match parse_user_data_frame(frame) {
2400 UserDataFrame::Response => return FrameOutcome::Response,
2402 UserDataFrame::Event {
2403 subscription_id,
2404 event_type,
2405 event,
2406 } => (subscription_id, event_type, event),
2407 UserDataFrame::Unrecognised => {
2408 static SEEN: AtomicU64 = AtomicU64::new(0);
2409 log_unrecognised_frame("BinanceMargin", &SEEN, frame);
2410 return FrameOutcome::Pushed { terminated: false };
2411 }
2412 };
2413 let terminated = match event_type {
2414 "executionReport" => {
2415 match serde_json::from_str::<ExecutionReport>(event_raw) {
2418 Ok(report) => {
2419 debug!(
2423 symbol = report.symbol.as_deref().unwrap_or("?"),
2424 order_id = ?report.order_id,
2425 client_order_id = report.client_order_id.as_deref().unwrap_or("?"),
2426 order_type = report.order_type.as_deref().unwrap_or("?"),
2427 execution_type = report.execution_type.as_deref().unwrap_or("?"),
2428 order_status = report.order_status.as_deref().unwrap_or("?"),
2429 "BinanceMargin executionReport"
2430 );
2431 convert_execution_report(&report, ExchangeId::BinanceMargin, buf);
2432 }
2433 Err(e) => {
2434 warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable executionReport, dropping")
2435 }
2436 }
2437 false
2438 }
2439 "outboundAccountPosition" => {
2440 match serde_json::from_str::<OutboundAccountPosition>(event_raw) {
2441 Ok(position) => handle_position(position, subscription_id, buf),
2443 Err(e) => {
2444 warn!(error = %e, frame = frame_excerpt(event_raw), "BinanceMargin: undeserializable outboundAccountPosition, dropping")
2445 }
2446 }
2447 false
2448 }
2449 "balanceUpdate" => {
2450 false
2454 }
2455 "USER_LIABILITY_CHANGE" | "userLiabilityChange" => {
2459 match serde_json::from_str::<UserLiabilityChange>(event_raw) {
2466 Ok(c) => log_liability_change(&c, "user data"),
2467 Err(e) => warn!(
2468 error = %e,
2469 frame = frame_excerpt(event_raw),
2470 "BinanceMargin: undeserializable USER_LIABILITY_CHANGE, dropping"
2471 ),
2472 }
2473 false
2474 }
2475 "MARGIN_LEVEL_STATUS_CHANGE" | "marginLevelStatusChange" => {
2476 match serde_json::from_str::<MarginLevelStatusChange>(event_raw) {
2479 Ok(change) => {
2480 log_margin_level_change(&change, "user data");
2481 buf.extend(margin_level_notice(
2482 change,
2483 notice_instrument(subscription_id),
2484 ));
2485 }
2486 Err(e) => warn!(
2487 error = %e,
2488 frame = frame_excerpt(event_raw),
2489 "BinanceMargin: undeserializable MARGIN_LEVEL_STATUS_CHANGE, dropping"
2490 ),
2491 }
2492 false
2493 }
2494 "eventStreamTerminated" => {
2495 warn!("BinanceMargin user data stream terminated by exchange, signalling reconnect");
2496 true
2497 }
2498 "listStatus" => {
2499 trace!("BinanceMargin ignoring listStatus user data event");
2502 false
2503 }
2504 other => {
2505 static SEEN: UnhandledEvents = UnhandledEvents::new();
2506 log_unhandled_event("BinanceMargin", &SEEN, other, event_raw);
2507 false
2508 }
2509 };
2510 FrameOutcome::Pushed { terminated }
2511}
2512
2513fn convert_margin_account_position(
2518 position: OutboundAccountPosition,
2519 buf: &mut Vec<UnindexedAccountEvent>,
2520) {
2521 let time_exchange = position
2522 .u
2523 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2524 .unwrap_or_else(Utc::now);
2525
2526 for b in position.b_uppercase.unwrap_or_default() {
2527 let asset = match b.a {
2528 Some(a) => AssetNameExchange::new(a),
2529 None => {
2530 warn!("BinanceMargin account position entry missing asset name");
2531 continue;
2532 }
2533 };
2534 let free = match b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2535 Some(v) => v,
2536 None => {
2537 warn!(%asset, "BinanceMargin account position missing/unparseable 'free' field");
2538 continue;
2539 }
2540 };
2541 let locked = match b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
2542 Some(v) => v,
2543 None => {
2544 warn!(%asset, "BinanceMargin account position missing/unparseable 'locked' field");
2545 continue;
2546 }
2547 };
2548 let update =
2549 AssetBalanceUpdate::new(asset, BalanceUpdate::new(free, locked), time_exchange);
2550 buf.push(UnindexedAccountEvent::new(
2551 ExchangeId::BinanceMargin,
2552 AccountEventKind::BalanceStreamUpdate(
2553 rustrade_integration::collection::snapshot::Snapshot::new(update),
2554 ),
2555 ));
2556 }
2557}
2558
2559fn route_isolated_account_position(
2575 position: OutboundAccountPosition,
2576 subscription_id: Option<i64>,
2577 sub_map: &Mutex<HashMap<i64, InstrumentNameExchange>>,
2578 base_quote: &HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>,
2579 buf: &mut Vec<UnindexedAccountEvent>,
2580) {
2581 let Some(sub_id) = subscription_id else {
2582 warn!(
2583 "BinanceMargin isolated: outboundAccountPosition without subscriptionId — dropping \
2584 (per-instrument balance routing requires it)"
2585 );
2586 return;
2587 };
2588 let instrument = {
2589 let map = sub_map.lock().unwrap_or_else(|p| p.into_inner());
2592 match map.get(&sub_id) {
2593 Some(inst) => inst.clone(),
2594 None => {
2595 warn!(
2596 subscription_id = sub_id,
2597 "BinanceMargin isolated: outboundAccountPosition for unmapped subscriptionId — dropping"
2598 );
2599 return;
2600 }
2601 }
2602 };
2603 let Some((base_asset, quote_asset)) = base_quote.get(&instrument) else {
2604 warn!(
2605 instrument = %instrument.name(),
2606 "BinanceMargin isolated: no base/quote known for instrument — dropping balance frame"
2607 );
2608 return;
2609 };
2610
2611 let time_exchange = position
2612 .u
2613 .and_then(|ms| Utc.timestamp_millis_opt(ms).single())
2614 .unwrap_or_else(Utc::now);
2615
2616 for b in position.b_uppercase.unwrap_or_default() {
2617 let Some(asset) = b.a.as_deref() else {
2618 warn!(instrument = %instrument.name(), "BinanceMargin isolated account position entry missing asset name");
2619 continue;
2620 };
2621 if asset != base_asset.name().as_str() && asset != quote_asset.name().as_str() {
2623 debug!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position: skipping asset outside the pair");
2624 continue;
2625 }
2626 let (Some(free), Some(locked)) = (
2627 b.f.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2628 b.l.as_deref().and_then(|s| Decimal::from_str(s).ok()),
2629 ) else {
2630 warn!(%asset, instrument = %instrument.name(), "BinanceMargin isolated account position missing/unparseable free/locked");
2631 continue;
2632 };
2633 buf.push(UnindexedAccountEvent::new(
2634 ExchangeId::BinanceMargin,
2635 AccountEventKind::InstrumentBalanceUpdate(InstrumentBalanceUpdate::new(
2636 instrument.clone(),
2637 AssetBalanceUpdate::new(
2638 AssetNameExchange::new(asset),
2639 BalanceUpdate::new(free, locked),
2640 time_exchange,
2641 ),
2642 )),
2643 ));
2644 }
2645}
2646
2647fn register_user_data_listener(
2660 ws: &Arc<WsApiBase>,
2661 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2662 dedup: SharedDedupCache,
2663 known: SharedKnownLiveOrders,
2664 heartbeat_flag: Arc<AtomicBool>,
2665 signal_tx: oneshot::Sender<()>,
2666 mut handle_position: impl FnMut(
2667 OutboundAccountPosition,
2668 Option<i64>,
2669 &mut Vec<UnindexedAccountEvent>,
2670 ) + Send
2671 + 'static,
2672 mut notice_instrument: impl FnMut(Option<i64>) -> Option<InstrumentNameExchange> + Send + 'static,
2673) -> UnsubscribeOnDrop {
2674 let mut signal_tx_opt = Some(signal_tx);
2675 let mut event_tx = Some(tx);
2676 let mut event_buf = Vec::with_capacity(32);
2680
2681 UnsubscribeOnDrop::new(ws.common.events.subscribe(move |event| {
2685 let Some(ref sender) = event_tx else { return };
2686 match event {
2687 WebsocketEvent::Message(json_str) => {
2688 let terminated = match convert_margin_user_data_events_with(
2691 &json_str,
2692 &mut event_buf,
2693 &mut handle_position,
2694 &mut notice_instrument,
2695 ) {
2696 FrameOutcome::Response => return,
2699 FrameOutcome::Pushed { terminated } => {
2700 heartbeat_flag.store(true, Ordering::Release);
2701 terminated
2702 }
2703 };
2704 for ev in event_buf.drain(..) {
2705 if let Some(key) = dedup_key_from_event(&ev)
2706 && is_duplicate(&dedup, key)
2707 {
2708 trace!("BinanceMargin dedup: skipping duplicate event");
2709 continue;
2710 }
2711 let held = KnownLiveOrders::observes(&ev.kind).then(|| {
2715 let mut held = known.lock();
2716 held.observe(&ev);
2717 held
2718 });
2719 if sender.send(ev).is_err() {
2720 drop(held);
2721 warn!("BinanceMargin account_stream receiver dropped, suppressing sends");
2722 event_tx.take();
2723 if let Some(s) = signal_tx_opt.take() {
2724 let _ = s.send(());
2725 }
2726 return;
2727 }
2728 }
2729 if terminated {
2730 event_tx.take();
2731 if let Some(s) = signal_tx_opt.take() {
2732 let _ = s.send(());
2733 }
2734 }
2735 }
2736 WebsocketEvent::Ping | WebsocketEvent::Pong => {
2737 heartbeat_flag.store(true, Ordering::Release);
2738 }
2739 WebsocketEvent::Error(e) => {
2740 warn!(%e, "BinanceMargin WebSocket error, will attempt reconnect");
2741 event_tx.take();
2742 if let Some(s) = signal_tx_opt.take() {
2743 let _ = s.send(());
2744 }
2745 }
2746 WebsocketEvent::Close(code, reason) => {
2747 warn!(code, %reason, "BinanceMargin WebSocket closed");
2748 event_tx.take();
2749 if let Some(s) = signal_tx_opt.take() {
2750 let _ = s.send(());
2751 }
2752 }
2753 _ => {
2754 trace!("BinanceMargin ignoring unhandled WebsocketEvent variant");
2755 }
2756 }
2757 }))
2758}
2759
2760async fn recover_margin_fills(
2782 rest: &Arc<RestApi>,
2783 rate_limiter: &Arc<RateLimitTracker>,
2784 unrecovered: &mut UnrecoveredFills,
2785 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
2786 dedup: &SharedDedupCache,
2787 known: &SharedKnownLiveOrders,
2788 is_isolated: bool,
2789) {
2790 use futures::StreamExt as _;
2791
2792 let due = unrecovered.due(tokio::time::Instant::now());
2793 let Some(oldest) = due.iter().map(|(_, gap)| gap.start_ms).min() else {
2794 return;
2795 };
2796 info!(
2797 gaps = due.len(),
2798 oldest = %gap_time(oldest),
2799 is_isolated,
2800 "BinanceMargin recovering fills missed while disconnected"
2801 );
2802
2803 let mut settled = vec![false; due.len()];
2806 let started = AtomicUsize::new(0);
2807 let recovery = async {
2808 let order_executions_deadline = tokio::time::Instant::now() + ORDER_EXECUTIONS_BUDGET;
2809 let mut recovered = 0u32;
2810 let mut duplicates = 0u32;
2811
2812 let mut stream =
2813 futures::stream::iter(due.iter().cloned().enumerate().map(|(index, (inst, gap))| {
2814 started.store(index + 1, Ordering::Relaxed);
2815 let rest = rest.clone();
2816 let rl = rate_limiter.clone();
2817 async move {
2818 let span = MyTradesFrom::Span {
2819 start: gap.start_ms,
2820 end: gap.end_ms,
2821 };
2822 let raw = match paginate_margin_my_trades(
2823 &rest,
2824 &rl,
2825 &inst,
2826 span,
2827 is_isolated,
2828 RequestKind::Essential,
2829 )
2830 .await
2831 {
2832 Ok(pages) => pages,
2833 Err(e) => return (index, Err(e)),
2834 };
2835 let totals = recovered_order_totals(
2839 ExchangeId::BinanceMargin,
2840 &inst,
2841 &raw,
2842 order_executions_deadline,
2843 |order_id| {
2844 paginate_margin_my_trades(
2845 &rest,
2846 &rl,
2847 &inst,
2848 MyTradesFrom::Order(order_id),
2849 is_isolated,
2850 RequestKind::Essential,
2851 )
2852 },
2853 )
2854 .await;
2855 let cids = recovered_order_cids(
2858 ExchangeId::BinanceMargin,
2859 &inst,
2860 &raw,
2861 order_executions_deadline,
2862 |order_id| fetch_margin_order_cid(&rest, &rl, &inst, order_id, is_isolated),
2863 )
2864 .await;
2865 let trades = raw
2866 .iter()
2867 .filter_map(|t| {
2868 let mut trade = convert_margin_trade(t, &inst)?;
2869 trade.order_filled_quantity =
2870 t.id.and_then(|id| totals.get(&id).copied());
2871 trade.client_order_id =
2872 t.order_id.and_then(|id| cids.get(&id).cloned());
2873 Some(trade)
2874 })
2875 .collect::<Vec<_>>();
2876 (index, Ok(trades))
2877 }
2878 }))
2879 .buffer_unordered(8);
2880 while let Some((index, result)) = stream.next().await {
2881 let (inst, gap) = &due[index];
2882 settled[index] = true;
2883 let trades = match result {
2884 Ok(trades) => trades,
2885 Err(e) => {
2886 gap_failed(
2887 unrecovered,
2888 tx,
2889 ExchangeId::BinanceMargin,
2890 inst,
2891 gap,
2892 FillRecoveryFailure::Request(e.to_string()),
2893 );
2894 continue;
2895 }
2896 };
2897 for trade in trades {
2898 let event = UnindexedAccountEvent::new(
2899 ExchangeId::BinanceMargin,
2900 AccountEventKind::Trade(trade),
2901 );
2902 if let Some(key) = dedup_key_from_event(&event)
2903 && is_duplicate(dedup, key)
2904 {
2905 duplicates += 1;
2906 continue;
2907 }
2908 let sent = {
2911 let mut known = known.lock();
2912 known.observe(&event);
2913 tx.send(event)
2914 };
2915 if sent.is_err() {
2916 debug!("BinanceMargin fill recovery: consumer dropped during recovery");
2917 return;
2918 }
2919 recovered += 1;
2920 }
2921 unrecovered.recovered(inst, gap);
2922 }
2923 info!(
2924 recovered,
2925 duplicates, is_isolated, "BinanceMargin fill recovery complete"
2926 );
2927 };
2928 if tokio::time::timeout(Duration::from_secs(FILL_RECOVERY_TIMEOUT_SECS), recovery)
2932 .await
2933 .is_err()
2934 {
2935 let started = started.load(Ordering::Relaxed);
2936 for ((inst, gap), _) in due[..started]
2937 .iter()
2938 .zip(&settled)
2939 .filter(|(_, settled)| !**settled)
2940 {
2941 gap_failed(
2942 unrecovered,
2943 tx,
2944 ExchangeId::BinanceMargin,
2945 inst,
2946 gap,
2947 FillRecoveryFailure::TimedOut {
2948 timeout_secs: FILL_RECOVERY_TIMEOUT_SECS,
2949 },
2950 );
2951 }
2952 }
2953}
2954
2955fn open_reconnect_recovery(
2960 unrecovered: &mut UnrecoveredFills,
2961 unchecked: &mut UncheckedOrders,
2962 known: &SharedKnownLiveOrders,
2963 instruments: &[InstrumentNameExchange],
2964 disconnected: DateTime<Utc>,
2965) {
2966 unrecovered.open(instruments, disconnected, Utc::now());
2967 let held = known.lock().instruments_among(instruments);
2968 unchecked.open(held);
2969}
2970
2971#[allow(
2979 clippy::cognitive_complexity,
2980 reason = "inherent reconnect-loop complexity (token + connect + subscribe + callback + recovery \
2981 + monitor + cleanup + backoff); mirrors spot's `connection_manager`, not worth splitting further"
2982)]
2983async fn margin_connection_manager(
2984 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
2985 dedup: SharedDedupCache,
2986 ws_config: ConfigurationWebsocketApi,
2987 rest_config: Arc<ConfigurationRestApi>,
2988 rest: Arc<RestApi>,
2989 rate_limiter: Arc<RateLimitTracker>,
2990 known: SharedKnownLiveOrders,
2991 instruments: Vec<InstrumentNameExchange>,
2992 initial: Option<(DisconnectOnDrop<Arc<WsApiBase>>, UserListenToken)>,
2993) {
2994 enum DisconnectReason {
2995 Signal,
2996 HeartbeatTimeout,
2997 TokenRefresh,
2998 ConsumerDropped,
2999 }
3000
3001 let mut budget = ReconnectBudget::new("Binance", BackoffLimits::ACCOUNT_STREAM);
3002 let mut disconnect_time: Option<DateTime<Utc>> = None;
3003 let mut unrecovered = UnrecoveredFills::default();
3005 let mut unchecked = UncheckedOrders::default();
3007 let (mut current_ws, mut current_token) = match initial {
3008 Some((ws, token)) => (Some(ws), Some(token)),
3009 None => (None, None),
3010 };
3011
3012 loop {
3013 let token = match current_token.take() {
3015 Some(t) => t,
3016 None => {
3017 if let Err(reason) = rate_limiter.before_stream_connect().await {
3020 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3021 break;
3022 }
3023 match acquire_user_listen_token(&rest_config, &rate_limiter, None).await {
3025 Ok(t) => t,
3026 Err(e) => {
3027 error!(%e, "BinanceMargin userListenToken acquisition failed");
3028 if let Err(reason) =
3029 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
3030 {
3031 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3032 break;
3033 }
3034 continue;
3035 }
3036 }
3037 }
3038 };
3039
3040 let ws = match current_ws.take() {
3042 Some(ws) => ws,
3043 None => match connect_margin_ws(&ws_config).await {
3044 Ok(ws) => ws,
3045 Err(e) => {
3046 error!(%e, "BinanceMargin WS connect failed");
3047 rate_limiter.on_stream_refused(&e);
3048 if let Err(reason) =
3049 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
3050 {
3051 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3052 break;
3053 }
3054 continue;
3055 }
3056 },
3057 };
3058
3059 let (signal_tx, signal_rx) = oneshot::channel::<()>();
3061 let heartbeat_flag = Arc::new(AtomicBool::new(false));
3064 let subscription = register_user_data_listener(
3067 &ws,
3068 tx.clone(),
3069 dedup.clone(),
3070 known.clone(),
3071 heartbeat_flag.clone(),
3072 signal_tx,
3073 cross_account_position_handler,
3074 cross_notice_instrument,
3075 );
3076
3077 if let Err(e) = subscribe_listen_token(&ws, &token.token).await {
3079 warn!(%e, "BinanceMargin user-data subscribe failed, cleaning up and retrying");
3080 subscription.unsubscribe();
3081 if let Err(de) = ws.disconnect().await {
3082 warn!(%de, "BinanceMargin failed to disconnect after subscribe failure");
3083 }
3084 rate_limiter.on_stream_refused(&e);
3085 if let Err(reason) = retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await {
3087 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3088 break;
3089 }
3090 continue;
3091 }
3092 info!("BinanceMargin account_stream connected and subscribed");
3093 let token_deadline =
3100 tokio::time::Instant::now() + token_renew_after(token.expiration_time_ms);
3101
3102 if let Some(dt) = disconnect_time.take() {
3107 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &instruments, dt);
3108 }
3109 recover_margin_fills(
3112 &rest,
3113 &rate_limiter,
3114 &mut unrecovered,
3115 &tx,
3116 &dedup,
3117 &known,
3118 false,
3119 )
3120 .await;
3121 recover_margin_ended_orders(
3122 &rest,
3123 &rate_limiter,
3124 &known,
3125 &mut unchecked,
3126 &unrecovered,
3127 &tx,
3128 false,
3129 )
3130 .await;
3131
3132 let reason = {
3134 let mut signal_rx = signal_rx;
3135 let retry_gaps = async {
3141 loop {
3142 let next_check = unchecked.next_due(&unrecovered);
3143 match unrecovered.next_due().into_iter().chain(next_check).min() {
3144 Some(due) => tokio::time::sleep_until(due).await,
3145 None => std::future::pending::<()>().await,
3146 }
3147 recover_margin_fills(
3148 &rest,
3149 &rate_limiter,
3150 &mut unrecovered,
3151 &tx,
3152 &dedup,
3153 &known,
3154 false,
3155 )
3156 .await;
3157 recover_margin_ended_orders(
3158 &rest,
3159 &rate_limiter,
3160 &known,
3161 &mut unchecked,
3162 &unrecovered,
3163 &tx,
3164 false,
3165 )
3166 .await;
3167 }
3168 };
3169 tokio::pin!(retry_gaps);
3170 let mut grace = true;
3172 loop {
3173 tokio::select! {
3174 biased;
3177 _ = tx.closed() => {
3178 debug!("BinanceMargin account_stream consumer dropped, terminating");
3179 break DisconnectReason::ConsumerDropped;
3180 }
3181 _ = &mut signal_rx => {
3182 warn!("BinanceMargin WS disconnected, will attempt reconnect");
3183 break DisconnectReason::Signal;
3184 }
3185 () = tokio::time::sleep_until(token_deadline) => {
3186 info!("BinanceMargin userListenToken nearing expiry, renewing");
3187 break DisconnectReason::TokenRefresh;
3188 }
3189 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
3190 let in_grace = std::mem::take(&mut grace);
3191 if heartbeat_flag.swap(false, Ordering::AcqRel) {
3192 budget.reset();
3195 continue;
3196 }
3197 if in_grace {
3198 continue;
3201 }
3202 warn!("BinanceMargin heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
3203 break DisconnectReason::HeartbeatTimeout;
3204 }
3205 () = &mut retry_gaps => {}
3207 }
3208 }
3209 };
3210 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
3211
3212 if should_reconnect {
3213 disconnect_time = Some(match reason {
3214 DisconnectReason::HeartbeatTimeout => {
3215 Utc::now()
3216 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
3217 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3218 }
3219 DisconnectReason::TokenRefresh => {
3220 Utc::now()
3225 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
3226 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3227 }
3228 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
3229 });
3230 }
3231
3232 subscription.unsubscribe();
3234 if let Err(e) = ws.disconnect().await {
3235 warn!(%e, "BinanceMargin failed to disconnect WebSocket");
3236 }
3237
3238 if !should_reconnect || tx.is_closed() {
3239 debug!("BinanceMargin connection manager exiting");
3241 break;
3242 }
3243
3244 if !matches!(reason, DisconnectReason::TokenRefresh) {
3247 let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
3250 let last_error = || disconnect_error(heartbeat_timed_out);
3251 if let Err(reason) = retry_or_end(&mut budget, &rate_limiter, last_error).await {
3252 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3253 break;
3254 }
3255 }
3256 }
3257}
3258
3259fn build_base_quote_map(
3271 assets: &[QueryIsolatedMarginAccountInfoResponseAssetsInner],
3272) -> HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)> {
3273 let mut map = HashMap::with_capacity(assets.len());
3274 for entry in assets {
3275 let (Some(symbol), Some(base), Some(quote)) = (
3276 entry.symbol.as_deref(),
3277 entry.base_asset.as_deref().and_then(|b| b.asset.as_deref()),
3278 entry
3279 .quote_asset
3280 .as_deref()
3281 .and_then(|q| q.asset.as_deref()),
3282 ) else {
3283 warn!(
3284 "BinanceMargin isolated: account-info entry missing symbol/base/quote asset — \
3285 skipping base/quote map entry"
3286 );
3287 continue;
3288 };
3289 map.insert(
3290 InstrumentNameExchange::new(symbol),
3291 (AssetNameExchange::new(base), AssetNameExchange::new(quote)),
3292 );
3293 }
3294 map
3295}
3296
3297async fn acquire_all_isolated_tokens(
3310 rest_config: &Arc<ConfigurationRestApi>,
3311 rate_limiter: &Arc<RateLimitTracker>,
3312 symbols: &[InstrumentNameExchange],
3313) -> Result<Vec<(InstrumentNameExchange, UserListenToken)>, UnindexedClientError> {
3314 use futures::{StreamExt as _, TryStreamExt as _};
3315
3316 futures::stream::iter(symbols.iter().cloned().map(|sym| {
3317 let rest_config = rest_config.clone();
3318 let rate_limiter = rate_limiter.clone();
3319 async move {
3320 let token = acquire_user_listen_token(&rest_config, &rate_limiter, Some(&sym)).await?;
3321 Ok::<_, UnindexedClientError>((sym, token))
3322 }
3323 }))
3324 .buffer_unordered(8)
3325 .try_collect()
3326 .await
3327}
3328
3329fn earliest_token_expiry_ms(tokens: &[(InstrumentNameExchange, UserListenToken)]) -> i64 {
3334 tokens
3335 .iter()
3336 .map(|(_, t)| t.expiration_time_ms)
3337 .min()
3338 .unwrap_or(i64::MAX)
3339}
3340
3341struct IsolatedLiveConn {
3344 ws: DisconnectOnDrop<Arc<WsApiBase>>,
3345 subscription: UnsubscribeOnDrop,
3346 signal_rx: oneshot::Receiver<()>,
3347 heartbeat_flag: Arc<AtomicBool>,
3348 earliest_expiry_ms: i64,
3351}
3352
3353async fn isolated_connect_and_subscribe(
3363 ws_config: &ConfigurationWebsocketApi,
3364 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3365 dedup: &SharedDedupCache,
3366 known: &SharedKnownLiveOrders,
3367 base_quote: &Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
3368 tokens: &[(InstrumentNameExchange, UserListenToken)],
3369) -> anyhow::Result<IsolatedLiveConn> {
3370 let ws = connect_margin_ws(ws_config).await?;
3371 let sub_map = Arc::new(Mutex::new(
3377 HashMap::<i64, InstrumentNameExchange>::with_capacity(tokens.len()),
3378 ));
3379 let (signal_tx, signal_rx) = oneshot::channel::<()>();
3380 let heartbeat_flag = Arc::new(AtomicBool::new(false));
3382
3383 let handle_position = {
3384 let sub_map = sub_map.clone();
3385 let base_quote = base_quote.clone();
3386 move |position, subscription_id, buf: &mut Vec<UnindexedAccountEvent>| {
3387 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
3388 }
3389 };
3390 let notice_instrument = {
3391 let sub_map = sub_map.clone();
3392 move |subscription_id| isolated_notice_instrument(subscription_id, &sub_map)
3393 };
3394 let subscription = register_user_data_listener(
3396 &ws,
3397 tx.clone(),
3398 dedup.clone(),
3399 known.clone(),
3400 heartbeat_flag.clone(),
3401 signal_tx,
3402 handle_position,
3403 notice_instrument,
3404 );
3405
3406 let earliest_expiry_ms = earliest_token_expiry_ms(tokens);
3407 for (sym, token) in tokens {
3408 match subscribe_listen_token_capture(&ws, &token.token).await {
3409 Ok(Some(id)) => {
3410 sub_map
3411 .lock()
3412 .unwrap_or_else(|p| p.into_inner())
3413 .insert(id, sym.clone());
3414 }
3415 Ok(None) => warn!(
3416 symbol = %sym.name(),
3417 "BinanceMargin isolated: subscribe ack carried no subscriptionId — live per-pair \
3418 balances unavailable for this symbol (fills/orders unaffected; balances via snapshot)"
3419 ),
3420 Err(e) => {
3421 warn!(%e, symbol = %sym.name(), "BinanceMargin isolated subscribe failed");
3424 subscription.unsubscribe();
3425 if let Err(de) = ws.disconnect().await {
3426 warn!(%de, "BinanceMargin isolated: disconnect after subscribe failure failed");
3427 }
3428 return Err(e);
3429 }
3430 }
3431 }
3432
3433 Ok(IsolatedLiveConn {
3434 ws,
3435 subscription,
3436 signal_rx,
3437 heartbeat_flag,
3438 earliest_expiry_ms,
3439 })
3440}
3441
3442#[allow(
3451 clippy::cognitive_complexity,
3452 reason = "inherent reconnect-loop complexity (tokens + connect + subscribe + monitor + cleanup \
3453 + backoff); mirrors the cross manager, not worth splitting further"
3454)]
3455async fn isolated_connection_manager(
3456 tx: mpsc::UnboundedSender<UnindexedAccountEvent>,
3457 dedup: SharedDedupCache,
3458 ws_config: ConfigurationWebsocketApi,
3459 rest_config: Arc<ConfigurationRestApi>,
3460 rest: Arc<RestApi>,
3461 rate_limiter: Arc<RateLimitTracker>,
3462 known: SharedKnownLiveOrders,
3463 symbols: Vec<InstrumentNameExchange>,
3464 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
3465 initial: Option<IsolatedLiveConn>,
3466) {
3467 enum DisconnectReason {
3468 Signal,
3469 HeartbeatTimeout,
3470 TokenRefresh,
3471 ConsumerDropped,
3472 }
3473
3474 let mut budget = ReconnectBudget::new("Binance", BackoffLimits::ACCOUNT_STREAM);
3475 let mut disconnect_time: Option<DateTime<Utc>> = None;
3476 let mut unrecovered = UnrecoveredFills::default();
3478 let mut unchecked = UncheckedOrders::default();
3480 let mut current = initial;
3481
3482 loop {
3483 let IsolatedLiveConn {
3485 ws,
3486 subscription,
3487 signal_rx,
3488 heartbeat_flag,
3489 earliest_expiry_ms,
3490 } = match current.take() {
3491 Some(live) => live,
3493 None => {
3494 if let Err(reason) = rate_limiter.before_stream_connect().await {
3496 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3497 break;
3498 }
3499 let tokens = match acquire_all_isolated_tokens(
3502 &rest_config,
3503 &rate_limiter,
3504 &symbols,
3505 )
3506 .await
3507 {
3508 Ok(t) => t,
3509 Err(e) => {
3510 error!(%e, "BinanceMargin isolated userListenToken acquisition failed");
3511 if let Err(reason) =
3512 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
3513 {
3514 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3515 break;
3516 }
3517 continue;
3518 }
3519 };
3520 match isolated_connect_and_subscribe(
3521 &ws_config,
3522 &tx,
3523 &dedup,
3524 &known,
3525 &base_quote,
3526 &tokens,
3527 )
3528 .await
3529 {
3530 Ok(live) => live,
3531 Err(e) => {
3532 error!(%e, "BinanceMargin isolated connect/subscribe failed");
3533 rate_limiter.on_stream_refused(&e);
3534 if let Err(reason) =
3535 retry_or_end(&mut budget, &rate_limiter, || e.to_string()).await
3536 {
3537 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3538 break;
3539 }
3540 continue;
3541 }
3542 }
3543 }
3544 };
3545
3546 info!(
3547 symbols = symbols.len(),
3548 "BinanceMargin isolated account_stream connected and subscribed"
3549 );
3550 let token_deadline = tokio::time::Instant::now() + token_renew_after(earliest_expiry_ms);
3556
3557 if let Some(dt) = disconnect_time.take() {
3560 open_reconnect_recovery(&mut unrecovered, &mut unchecked, &known, &symbols, dt);
3561 }
3562 recover_margin_fills(
3564 &rest,
3565 &rate_limiter,
3566 &mut unrecovered,
3567 &tx,
3568 &dedup,
3569 &known,
3570 true,
3571 )
3572 .await;
3573 recover_margin_ended_orders(
3574 &rest,
3575 &rate_limiter,
3576 &known,
3577 &mut unchecked,
3578 &unrecovered,
3579 &tx,
3580 true,
3581 )
3582 .await;
3583
3584 let reason = {
3587 let mut signal_rx = signal_rx;
3588 let retry_gaps = async {
3591 loop {
3592 let next_check = unchecked.next_due(&unrecovered);
3593 match unrecovered.next_due().into_iter().chain(next_check).min() {
3594 Some(due) => tokio::time::sleep_until(due).await,
3595 None => std::future::pending::<()>().await,
3596 }
3597 recover_margin_fills(
3598 &rest,
3599 &rate_limiter,
3600 &mut unrecovered,
3601 &tx,
3602 &dedup,
3603 &known,
3604 true,
3605 )
3606 .await;
3607 recover_margin_ended_orders(
3608 &rest,
3609 &rate_limiter,
3610 &known,
3611 &mut unchecked,
3612 &unrecovered,
3613 &tx,
3614 true,
3615 )
3616 .await;
3617 }
3618 };
3619 tokio::pin!(retry_gaps);
3620 let mut grace = true;
3622 loop {
3623 tokio::select! {
3624 biased;
3625 _ = tx.closed() => {
3626 debug!("BinanceMargin isolated consumer dropped, terminating");
3627 break DisconnectReason::ConsumerDropped;
3628 }
3629 _ = &mut signal_rx => {
3630 warn!("BinanceMargin isolated WS disconnected, will attempt reconnect");
3631 break DisconnectReason::Signal;
3632 }
3633 () = tokio::time::sleep_until(token_deadline) => {
3634 info!("BinanceMargin isolated userListenToken nearing expiry, renewing all");
3635 break DisconnectReason::TokenRefresh;
3636 }
3637 () = tokio::time::sleep(Duration::from_secs(HEARTBEAT_TIMEOUT_SECS)) => {
3638 let in_grace = std::mem::take(&mut grace);
3639 if heartbeat_flag.swap(false, Ordering::AcqRel) {
3640 budget.reset();
3642 continue;
3643 }
3644 if in_grace {
3645 continue;
3647 }
3648 warn!("BinanceMargin isolated heartbeat timeout ({}s), will attempt reconnect", HEARTBEAT_TIMEOUT_SECS);
3649 break DisconnectReason::HeartbeatTimeout;
3650 }
3651 () = &mut retry_gaps => {}
3653 }
3654 }
3655 };
3656 let should_reconnect = !matches!(reason, DisconnectReason::ConsumerDropped);
3657
3658 if should_reconnect {
3659 disconnect_time = Some(match reason {
3660 DisconnectReason::HeartbeatTimeout => {
3661 Utc::now()
3662 - chrono::Duration::seconds(HEARTBEAT_TIMEOUT_SECS as i64)
3663 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3664 }
3665 DisconnectReason::TokenRefresh => {
3666 Utc::now()
3670 - chrono::Duration::seconds(CONNECT_TIMEOUT_SECS as i64)
3671 - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS)
3672 }
3673 _ => Utc::now() - chrono::Duration::milliseconds(SIGNAL_RECOVERY_LOOKBACK_MS),
3674 });
3675 }
3676
3677 subscription.unsubscribe();
3679 if let Err(e) = ws.disconnect().await {
3680 warn!(%e, "BinanceMargin isolated failed to disconnect WebSocket");
3681 }
3682
3683 if !should_reconnect || tx.is_closed() {
3684 debug!("BinanceMargin isolated connection manager exiting");
3686 break;
3687 }
3688
3689 if !matches!(reason, DisconnectReason::TokenRefresh) {
3692 let heartbeat_timed_out = matches!(reason, DisconnectReason::HeartbeatTimeout);
3695 let last_error = || disconnect_error(heartbeat_timed_out);
3696 if let Err(reason) = retry_or_end(&mut budget, &rate_limiter, last_error).await {
3697 emit_stream_terminated(&tx, ExchangeId::BinanceMargin, reason);
3698 break;
3699 }
3700 }
3701 }
3702}
3703
3704async fn fetch_margin_open_orders_for_instrument(
3711 rest: Arc<RestApi>,
3712 rate_limiter: Arc<RateLimitTracker>,
3713 instrument: InstrumentNameExchange,
3714 is_isolated: bool,
3715 kind: RequestKind,
3716) -> Result<(InstrumentNameExchange, OpenOrderListing), UnindexedClientError> {
3717 let symbol_str = instrument.name().to_string();
3719 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3720 let response = rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3721 let sym = symbol_str.clone();
3722 let isolated = isolated.clone();
3723 Box::pin(async move {
3724 let params = QueryMarginAccountsOpenOrdersParams::builder()
3725 .symbol(sym)
3726 .is_isolated(isolated)
3727 .build()?;
3728 rest.query_margin_accounts_open_orders(params).await
3729 })
3730 })
3731 .await
3732 .map_err(|e| classify_rest_query_error(&e, Some(&instrument)))?;
3733
3734 let orders_data = response.data().await.map_err(response_decode_error)?;
3735
3736 let listing = convert_open_order_listing(&orders_data, ExchangeId::BinanceMargin, &instrument);
3737
3738 Ok((instrument, listing))
3739}
3740
3741async fn fetch_margin_all_open_orders(
3748 rest: Arc<RestApi>,
3749 rate_limiter: Arc<RateLimitTracker>,
3750 is_isolated: bool,
3751) -> Result<Vec<Order<ExchangeId, InstrumentNameExchange, Open>>, UnindexedClientError> {
3752 let isolated = QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(is_isolated);
3753 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
3754 let isolated = isolated.clone();
3755 Box::pin(async move {
3756 let params = QueryMarginAccountsOpenOrdersParams::builder()
3757 .is_isolated(isolated)
3758 .build()?;
3759 rest.query_margin_accounts_open_orders(params).await
3760 })
3761 })
3762 .await
3763 .map_err(|e| classify_rest_query_error(&e, None))?;
3764
3765 let orders_data = response.data().await.map_err(response_decode_error)?;
3766
3767 let orders = orders_data
3768 .into_iter()
3769 .filter_map(|o| convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin))
3770 .collect();
3771
3772 Ok(orders)
3773}
3774
3775async fn fetch_margin_order_lookup(
3782 rest: Arc<RestApi>,
3783 rate_limiter: Arc<RateLimitTracker>,
3784 key: UnindexedOrderKey,
3785 is_isolated: bool,
3786 kind: RequestKind,
3787 recorded_kind: Option<OrderKind>,
3788) -> Result<OrderLookup, UnindexedClientError> {
3789 let symbol = key.instrument.name().to_string();
3791 let cid = key.cid.0.to_string();
3792 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
3793 let response = match rest_call_with_retry(&rest, &rate_limiter, kind, |rest| {
3794 let (symbol, cid, isolated) = (symbol.clone(), cid.clone(), isolated.clone());
3795 Box::pin(async move {
3796 let params = QueryMarginAccountsOrderParams::builder(symbol)
3797 .orig_client_order_id(cid)
3798 .is_isolated(isolated)
3799 .build()?;
3800 rest.query_margin_accounts_order(params).await
3801 })
3802 })
3803 .await
3804 {
3805 Ok(response) => response,
3806 Err(e) if is_unknown_order(&e) => {
3807 debug!(instrument = %key.instrument, cid = %key.cid, error = %e, "BinanceMargin does not know this order");
3808 return Ok(OrderLookup::Unknown);
3809 }
3810 Err(e) => return Err(classify_rest_query_error(&e, Some(&key.instrument))),
3811 };
3812
3813 let row = response.data().await.map_err(response_decode_error)?;
3814
3815 Ok(
3816 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, recorded_kind)
3817 .map_or(OrderLookup::NotEnded, |order| {
3818 OrderLookup::Ended(Box::new(order))
3819 }),
3820 )
3821}
3822
3823async fn fetch_margin_order_cid(
3828 rest: &Arc<RestApi>,
3829 rate_limiter: &RateLimitTracker,
3830 instrument: &InstrumentNameExchange,
3831 order_id: i64,
3832 is_isolated: bool,
3833) -> Result<Option<ClientOrderId>, UnindexedClientError> {
3834 let symbol = instrument.name().to_string();
3835 let isolated = QueryMarginAccountsOrderIsIsolatedEnum::from_flag(is_isolated);
3836 let response = match rest_call_with_retry(rest, rate_limiter, RequestKind::Essential, |rest| {
3837 let (symbol, isolated) = (symbol.clone(), isolated.clone());
3838 Box::pin(async move {
3839 let params = QueryMarginAccountsOrderParams::builder(symbol)
3840 .order_id(order_id)
3841 .is_isolated(isolated)
3842 .build()?;
3843 rest.query_margin_accounts_order(params).await
3844 })
3845 })
3846 .await
3847 {
3848 Ok(response) => response,
3849 Err(e) if is_unknown_order(&e) => {
3850 debug!(%instrument, order_id, error = %e, "BinanceMargin does not know this order");
3851 return Ok(None);
3852 }
3853 Err(e) => return Err(classify_rest_query_error(&e, Some(instrument))),
3854 };
3855 let row = response.data().await.map_err(response_decode_error)?;
3856 Ok(row
3857 .client_order_id
3858 .filter(|cid| !cid.is_empty())
3859 .map(ClientOrderId::new))
3860}
3861
3862async fn listed_margin_open_cids(
3865 rest: Arc<RestApi>,
3866 rate_limiter: Arc<RateLimitTracker>,
3867 instruments: Vec<InstrumentNameExchange>,
3868 is_isolated: bool,
3869) -> Result<FnvHashSet<ClientOrderId>, UnindexedClientError> {
3870 let mut listed = FnvHashSet::default();
3871 for instrument in instruments {
3872 let (_, listing) = fetch_margin_open_orders_for_instrument(
3873 rest.clone(),
3874 rate_limiter.clone(),
3875 instrument,
3876 is_isolated,
3877 RequestKind::Essential,
3878 )
3879 .await?;
3880 listed.extend(listing.orders.into_iter().map(|order| order.key.cid));
3881 }
3882 Ok(listed)
3883}
3884
3885async fn recover_margin_ended_orders(
3888 rest: &Arc<RestApi>,
3889 rate_limiter: &Arc<RateLimitTracker>,
3890 known: &SharedKnownLiveOrders,
3891 unchecked: &mut UncheckedOrders,
3892 unrecovered: &UnrecoveredFills,
3893 tx: &mpsc::UnboundedSender<UnindexedAccountEvent>,
3894 is_isolated: bool,
3895) {
3896 recover_ended_orders(
3897 ExchangeId::BinanceMargin,
3898 known,
3899 unchecked,
3900 unrecovered,
3901 tx,
3902 OpenListing::PerInstrument,
3903 |instruments| {
3904 listed_margin_open_cids(rest.clone(), rate_limiter.clone(), instruments, is_isolated)
3905 },
3906 |key| {
3907 let recorded_kind = known.lock().kind_of(&key.cid);
3909 fetch_margin_order_lookup(
3910 rest.clone(),
3911 rate_limiter.clone(),
3912 key,
3913 is_isolated,
3914 RequestKind::Essential,
3915 recorded_kind,
3916 )
3917 },
3918 )
3919 .await;
3920}
3921
3922const MARGIN_MY_TRADES_WINDOW_MS: i64 = 23 * 60 * 60 * 1000;
3928
3929#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3932enum MarginTradesQuery {
3933 Window { start: i64, end: i64 },
3935 FromId(i64),
3937 Order(i64),
3939 OrderFromId { order_id: i64, from_id: i64 },
3941}
3942
3943impl MarginTradesQuery {
3944 fn first(from: MyTradesFrom, now_ms: i64) -> Option<Self> {
3948 match from {
3949 MyTradesFrom::Span { start, .. } => {
3950 (start <= now_ms).then(|| Self::window(start, now_ms))
3951 }
3952 MyTradesFrom::Order(order_id) => Some(Self::Order(order_id)),
3953 }
3954 }
3955
3956 fn window(start: i64, now_ms: i64) -> Self {
3958 Self::Window {
3959 start,
3960 end: start.saturating_add(MARGIN_MY_TRADES_WINDOW_MS).min(now_ms),
3961 }
3962 }
3963
3964 fn next(self, page_len: usize, last_id: Option<i64>, now_ms: i64) -> Option<Self> {
3967 let full = page_len >= BINANCE_MAX_TRADES;
3968 let after_last = || last_id?.checked_add(1);
3969 match self {
3970 Self::Window { end, .. } if page_len == 0 => {
3975 (end < now_ms).then(|| Self::window(end, now_ms))
3976 }
3977 Self::Window { end, .. } if end >= now_ms && !full => None,
3979 Self::Window { .. } => after_last().map(Self::FromId),
3982 Self::FromId(_) if full => after_last().map(Self::FromId),
3983 Self::Order(order_id) | Self::OrderFromId { order_id, .. } if full => {
3984 after_last().map(|from_id| Self::OrderFromId { order_id, from_id })
3985 }
3986 Self::FromId(_) | Self::Order(_) | Self::OrderFromId { .. } => None,
3987 }
3988 }
3989}
3990
3991async fn paginate_margin_my_trades(
4013 rest: &Arc<RestApi>,
4014 rate_limiter: &Arc<RateLimitTracker>,
4015 instrument: &InstrumentNameExchange,
4016 from: MyTradesFrom,
4017 is_isolated: bool,
4018 kind: RequestKind,
4019) -> Result<Vec<QueryMarginAccountsTradeListResponseInner>, UnindexedClientError> {
4020 let symbol_str = instrument.name().to_string();
4022 let isolated = QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(is_isolated);
4023 #[allow(clippy::cast_possible_wrap)]
4028 let limit = BINANCE_MAX_TRADES as i64;
4029 let now_ms = match from {
4032 MyTradesFrom::Span { end, .. } => end.min(Utc::now().timestamp_millis()),
4033 MyTradesFrom::Order(_) => Utc::now().timestamp_millis(),
4034 };
4035 let mut all_pages = Vec::new();
4036 let mut requests = 0u32;
4037 let mut next = MarginTradesQuery::first(from, now_ms);
4038 while let Some(query) = next {
4039 requests += 1;
4040 let response = rest_call_with_retry(rest, rate_limiter, kind, |rest| {
4041 let sym = symbol_str.clone();
4042 let isolated = isolated.clone();
4043 Box::pin(async move {
4044 let builder = QueryMarginAccountsTradeListParams::builder(sym)
4045 .is_isolated(isolated)
4046 .limit(limit);
4047 let params = match query {
4048 MarginTradesQuery::Window { start, end } => {
4049 builder.start_time(start).end_time(end)
4050 }
4051 MarginTradesQuery::FromId(id) => builder.from_id(id),
4052 MarginTradesQuery::Order(order_id) => builder.order_id(order_id),
4053 MarginTradesQuery::OrderFromId { order_id, from_id } => {
4054 builder.order_id(order_id).from_id(from_id)
4055 }
4056 }
4057 .build()?;
4058 rest.query_margin_accounts_trade_list(params).await
4059 })
4060 })
4061 .await
4062 .map_err(|e| classify_rest_query_error(&e, Some(instrument)))?;
4063
4064 let mut page = response.data().await.map_err(response_decode_error)?;
4065
4066 let page_len = page.len();
4067 let last_id = page.last().and_then(|t| t.id);
4068 let past_end = match from {
4070 MyTradesFrom::Span { end, .. } => drop_after(&mut page, end),
4071 MyTradesFrom::Order(_) => false,
4072 };
4073 all_pages.extend(page);
4074 if past_end {
4075 break;
4076 }
4077
4078 if page_len > 0 && last_id.is_none() {
4079 warn!(%instrument, "BinanceMargin paginate_my_trades: trade missing ID, cannot page past it");
4080 break;
4081 }
4082 next = query.next(page_len, last_id, now_ms);
4083 }
4084 debug!(
4085 %instrument,
4086 requests,
4087 trades = all_pages.len(),
4088 "BinanceMargin paginate_my_trades: walk complete"
4089 );
4090 Ok(all_pages)
4091}
4092
4093fn convert_margin_balance_entry(
4103 b: QueryCrossMarginAccountDetailsResponseUserAssetsInner,
4104 now: DateTime<Utc>,
4105) -> Option<AssetBalance<AssetNameExchange>> {
4106 let asset_name = AssetNameExchange::new(b.asset.as_deref()?);
4107 let free = match b.free.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4108 Some(v) => v,
4109 None => {
4110 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'free' field");
4111 return None;
4112 }
4113 };
4114 let locked = match b.locked.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4115 Some(v) => v,
4116 None => {
4117 warn!(%asset_name, "BinanceMargin balance missing/unparseable 'locked' field");
4118 return None;
4119 }
4120 };
4121 let borrowed = b
4126 .borrowed
4127 .as_deref()
4128 .and_then(|s| Decimal::from_str(s).ok())
4129 .unwrap_or(Decimal::ZERO);
4130 let interest = b
4131 .interest
4132 .as_deref()
4133 .and_then(|s| Decimal::from_str(s).ok())
4134 .unwrap_or(Decimal::ZERO);
4135 Some(AssetBalance::new(
4136 asset_name,
4137 Balance::new_margin(free + locked, free, borrowed, interest),
4138 now,
4139 ))
4140}
4141
4142fn filter_and_convert_margin_balances(
4145 user_assets: Vec<QueryCrossMarginAccountDetailsResponseUserAssetsInner>,
4146 assets: &[AssetNameExchange],
4147) -> Vec<AssetBalance<AssetNameExchange>> {
4148 let now = Utc::now();
4149 if assets.is_empty() {
4151 return user_assets
4152 .into_iter()
4153 .filter_map(|b| convert_margin_balance_entry(b, now))
4154 .collect();
4155 }
4156 if assets.len() <= 16 {
4158 return user_assets
4159 .into_iter()
4160 .filter_map(|b| {
4161 let asset_name_str = b.asset.as_deref()?;
4162 if !assets.iter().any(|a| a.name().as_str() == asset_name_str) {
4163 return None;
4164 }
4165 convert_margin_balance_entry(b, now)
4166 })
4167 .collect();
4168 }
4169 use std::collections::HashSet;
4170 let asset_set: HashSet<&str> = assets.iter().map(|a| a.name().as_str()).collect();
4171 user_assets
4172 .into_iter()
4173 .filter_map(|b| {
4174 let asset_name_str = b.asset.as_deref()?;
4175 if !asset_set.contains(asset_name_str) {
4176 return None;
4177 }
4178 convert_margin_balance_entry(b, now)
4179 })
4180 .collect()
4181}
4182
4183fn active_order_snapshot(
4190 o: Order<ExchangeId, InstrumentNameExchange, Open>,
4191) -> Order<ExchangeId, InstrumentNameExchange, OrderState<AssetNameExchange, InstrumentNameExchange>>
4192{
4193 Order {
4194 key: o.key,
4195 side: o.side,
4196 price: o.price,
4197 quantity: o.quantity,
4198 kind: o.kind,
4199 time_in_force: o.time_in_force,
4200 state: OrderState::active(o.state),
4201 }
4202}
4203
4204fn convert_isolated_asset_balance(
4214 asset: Option<&str>,
4215 free: Option<&str>,
4216 locked: Option<&str>,
4217 borrowed: Option<&str>,
4218 interest: Option<&str>,
4219 now: DateTime<Utc>,
4220) -> Option<AssetBalance<AssetNameExchange>> {
4221 let asset_name = AssetNameExchange::new(asset?);
4222 let free = match free.and_then(|s| Decimal::from_str(s).ok()) {
4223 Some(v) => v,
4224 None => {
4225 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'free' field");
4226 return None;
4227 }
4228 };
4229 let locked = match locked.and_then(|s| Decimal::from_str(s).ok()) {
4230 Some(v) => v,
4231 None => {
4232 warn!(%asset_name, "BinanceMargin isolated balance missing/unparseable 'locked' field");
4233 return None;
4234 }
4235 };
4236 let borrowed = borrowed
4237 .and_then(|s| Decimal::from_str(s).ok())
4238 .unwrap_or(Decimal::ZERO);
4239 let interest = interest
4240 .and_then(|s| Decimal::from_str(s).ok())
4241 .unwrap_or(Decimal::ZERO);
4242 Some(AssetBalance::new(
4243 asset_name,
4244 Balance::new_margin(free + locked, free, borrowed, interest),
4245 now,
4246 ))
4247}
4248
4249fn convert_isolated_margin_assets(
4257 assets: Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>,
4258) -> HashMap<InstrumentNameExchange, IsolatedInstrumentState<AssetNameExchange>> {
4259 let now = Utc::now();
4262 let mut map = HashMap::with_capacity(assets.len());
4263 for entry in assets {
4264 let Some(symbol) = entry.symbol.as_deref() else {
4265 warn!("BinanceMargin isolated asset entry missing 'symbol' — skipping");
4266 continue;
4267 };
4268 let instrument = InstrumentNameExchange::new(symbol);
4269
4270 let Some(base_raw) = entry.base_asset.as_deref() else {
4271 warn!(%instrument, "BinanceMargin isolated entry missing baseAsset — skipping");
4272 continue;
4273 };
4274 let Some(quote_raw) = entry.quote_asset.as_deref() else {
4275 warn!(%instrument, "BinanceMargin isolated entry missing quoteAsset — skipping");
4276 continue;
4277 };
4278
4279 let Some(base) = convert_isolated_asset_balance(
4280 base_raw.asset.as_deref(),
4281 base_raw.free.as_deref(),
4282 base_raw.locked.as_deref(),
4283 base_raw.borrowed.as_deref(),
4284 base_raw.interest.as_deref(),
4285 now,
4286 ) else {
4287 warn!(%instrument, "BinanceMargin isolated baseAsset missing required field — skipping");
4288 continue;
4289 };
4290 let Some(quote) = convert_isolated_asset_balance(
4291 quote_raw.asset.as_deref(),
4292 quote_raw.free.as_deref(),
4293 quote_raw.locked.as_deref(),
4294 quote_raw.borrowed.as_deref(),
4295 quote_raw.interest.as_deref(),
4296 now,
4297 ) else {
4298 warn!(%instrument, "BinanceMargin isolated quoteAsset missing required field — skipping");
4299 continue;
4300 };
4301
4302 let risk = IsolatedMarginRisk {
4303 margin_level: entry
4304 .margin_level
4305 .as_deref()
4306 .and_then(|s| Decimal::from_str(s).ok()),
4307 margin_ratio: entry
4308 .margin_ratio
4309 .as_deref()
4310 .and_then(|s| Decimal::from_str(s).ok()),
4311 liquidation_price: entry
4312 .liquidate_price
4313 .as_deref()
4314 .and_then(|s| Decimal::from_str(s).ok()),
4315 };
4316
4317 map.insert(instrument, IsolatedInstrumentState { base, quote, risk });
4318 }
4319 map
4320}
4321
4322fn chunk_symbols(symbols: &[InstrumentNameExchange]) -> Vec<String> {
4325 symbols
4326 .chunks(5)
4327 .map(|chunk| {
4328 chunk
4329 .iter()
4330 .map(|s| s.name().as_str())
4331 .collect::<Vec<_>>()
4332 .join(",")
4333 })
4334 .collect()
4335}
4336
4337async fn fetch_isolated_margin_account_info(
4343 rest: Arc<RestApi>,
4344 rate_limiter: Arc<RateLimitTracker>,
4345 symbols: Vec<InstrumentNameExchange>,
4346) -> Result<Vec<QueryIsolatedMarginAccountInfoResponseAssetsInner>, UnindexedClientError> {
4347 use futures::{StreamExt as _, TryStreamExt as _};
4348
4349 let chunks = chunk_symbols(&symbols);
4350
4351 futures::stream::iter(chunks.into_iter().map(|symbols_param| {
4355 let rest = rest.clone();
4356 let rate_limiter = rate_limiter.clone();
4357 async move {
4358 let response = rest_call_with_retry(&rest, &rate_limiter, RequestKind::Query, |rest| {
4359 let symbols_param = symbols_param.clone();
4360 Box::pin(async move {
4361 let params = QueryIsolatedMarginAccountInfoParams::builder()
4362 .symbols(symbols_param)
4363 .build()?;
4364 rest.query_isolated_margin_account_info(params).await
4365 })
4366 })
4367 .await
4368 .map_err(|e| classify_rest_query_error(&e, None))?;
4369
4370 let info = response.data().await.map_err(response_decode_error)?;
4371 Ok::<_, UnindexedClientError>(info.assets.unwrap_or_default())
4372 }
4373 }))
4374 .buffer_unordered(8)
4375 .try_fold(
4376 Vec::with_capacity(symbols.len()),
4377 |mut acc, assets| async move {
4378 acc.extend(assets);
4379 Ok(acc)
4380 },
4381 )
4382 .await
4383}
4384
4385fn convert_margin_trade(
4392 t: &QueryMarginAccountsTradeListResponseInner,
4393 instrument: &InstrumentNameExchange,
4394) -> Option<Trade<AssetNameExchange, InstrumentNameExchange>> {
4395 let trade_id_raw = match t.id {
4396 Some(id) => id,
4397 None => {
4398 warn!(%instrument, "BinanceMargin trade missing id");
4399 return None;
4400 }
4401 };
4402 let trade_id = TradeId(format_smolstr!("{trade_id_raw}"));
4403 let order_id = match t.order_id {
4404 Some(id) => OrderId(format_smolstr!("{id}")),
4405 None => {
4406 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing orderId");
4407 return None;
4408 }
4409 };
4410 let side = match t.is_buyer {
4411 Some(true) => Side::Buy,
4412 Some(false) => Side::Sell,
4413 None => {
4414 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing isBuyer");
4415 return None;
4416 }
4417 };
4418 let price = match t.price.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4419 Some(v) => v,
4420 None => {
4421 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable price");
4422 return None;
4423 }
4424 };
4425 let quantity = match t.qty.as_deref().and_then(|s| Decimal::from_str(s).ok()) {
4426 Some(v) => v,
4427 None => {
4428 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable qty");
4429 return None;
4430 }
4431 };
4432 let commission = t
4433 .commission
4434 .as_deref()
4435 .and_then(|s| Decimal::from_str(s).ok())
4436 .unwrap_or(Decimal::ZERO);
4437 let time_exchange = match t.time.and_then(|ms| Utc.timestamp_millis_opt(ms).single()) {
4438 Some(ts) => ts,
4439 None => {
4440 warn!(%instrument, trade_id = %trade_id_raw, "BinanceMargin trade missing/unparseable time, using now");
4441 Utc::now()
4442 }
4443 };
4444
4445 let fee_asset = t
4450 .commission_asset
4451 .as_deref()
4452 .map(AssetNameExchange::from)
4453 .unwrap_or_else(|| AssetNameExchange::from("UNKNOWN"));
4454
4455 Some(Trade::new(
4456 trade_id,
4457 order_id,
4458 instrument.clone(),
4459 StrategyId::unknown(), time_exchange,
4461 side,
4462 price,
4463 quantity,
4464 None,
4466 AssetFees::new(fee_asset, commission, None),
4467 ))
4468}
4469
4470#[derive(Debug)]
4476enum BuildOrderError {
4477 Unsupported,
4479 Build(String),
4481 Trailing(TrailingOrderError),
4483}
4484
4485trait IsolatedFlag {
4496 fn from_flag(is_isolated: bool) -> Self;
4498}
4499
4500macro_rules! impl_isolated_flag {
4505 ($($t:ty),* $(,)?) => {
4506 $(
4507 impl IsolatedFlag for $t {
4508 fn from_flag(is_isolated: bool) -> Self {
4509 if is_isolated { Self::True } else { Self::False }
4510 }
4511 }
4512 )*
4513 };
4514}
4515
4516impl_isolated_flag!(
4517 MarginAccountNewOrderIsIsolatedEnum,
4518 MarginAccountCancelOrderIsIsolatedEnum,
4519 QueryMarginAccountsOpenOrdersIsIsolatedEnum,
4520 QueryMarginAccountsOrderIsIsolatedEnum,
4521 QueryMarginAccountsTradeListIsIsolatedEnum,
4522 GetFutureHourlyInterestRateIsIsolatedEnum,
4523);
4524
4525fn build_cancel_order_params(
4533 symbol: String,
4534 id: Option<&OrderId>,
4535 cid: &ClientOrderId,
4536 is_isolated: bool,
4537) -> Result<MarginAccountCancelOrderParams, String> {
4538 let mut builder = MarginAccountCancelOrderParams::builder(symbol).is_isolated(
4539 MarginAccountCancelOrderIsIsolatedEnum::from_flag(is_isolated),
4540 );
4541
4542 match id {
4543 Some(order_id) => match order_id.0.parse::<i64>() {
4544 Ok(id) => builder = builder.order_id(id),
4545 Err(_) => {
4546 error!(
4548 order_id = %order_id.0,
4549 "BinanceMargin cancel: exchange orderId not parseable as i64, falling back to clientOrderId"
4550 );
4551 builder = builder.orig_client_order_id(cid.0.to_string());
4552 }
4553 },
4554 None => builder = builder.orig_client_order_id(cid.0.to_string()),
4555 }
4556
4557 builder.build().map_err(|e| e.to_string())
4558}
4559
4560#[allow(clippy::too_many_arguments)] fn build_new_order_params(
4569 symbol: String,
4570 side: Side,
4571 price: Option<Decimal>,
4572 quantity: Decimal,
4573 kind: OrderKind,
4574 time_in_force: TimeInForce,
4575 new_client_order_id: String,
4576 margin_effect: MarginEffect,
4577 auto_repay_at_cancel: bool,
4578 is_isolated: bool,
4579) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
4580 let binance_side = match side {
4581 Side::Buy => MarginAccountNewOrderSideEnum::Buy,
4582 Side::Sell => MarginAccountNewOrderSideEnum::Sell,
4583 };
4584
4585 let (binance_type, binance_tif) =
4586 convert_order_kind_tif_margin(kind, time_in_force).ok_or(BuildOrderError::Unsupported)?;
4587
4588 let mut builder = MarginAccountNewOrderParams::builder(symbol, binance_side, binance_type)
4590 .quantity(quantity)
4591 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::from_flag(is_isolated))
4592 .side_effect_type(binance_side_effect(margin_effect))
4593 .new_client_order_id(new_client_order_id)
4594 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full);
4595
4596 if margin_effect == MarginEffect::Borrow {
4599 builder = builder.auto_repay_at_cancel(auto_repay_at_cancel);
4600 }
4601
4602 if let Some(tif) = binance_tif {
4603 builder = builder.time_in_force(tif);
4604 }
4605
4606 if let Some(trailing) = trailing_params(&kind, price).map_err(BuildOrderError::Trailing)? {
4609 builder = builder.trailing_delta(i64::from(trailing.trailing_delta));
4610 if let Some(limit) = trailing.price {
4611 builder = builder.price(limit);
4612 }
4613 if let Some(activation_price) = trailing.stop_price {
4614 builder = builder.stop_price(activation_price);
4615 }
4616 }
4617
4618 match kind {
4622 OrderKind::Limit => {
4623 builder = builder.price(price);
4624 }
4625 OrderKind::Stop { trigger_price } | OrderKind::TakeProfit { trigger_price } => {
4626 builder = builder.stop_price(trigger_price);
4627 }
4628 OrderKind::StopLimit { trigger_price } | OrderKind::TakeProfitLimit { trigger_price } => {
4629 builder = builder.price(price).stop_price(trigger_price);
4630 }
4631 OrderKind::TrailingStop { .. }
4634 | OrderKind::TrailingStopFixedLimit { .. }
4635 | OrderKind::Market
4636 | OrderKind::TrailingStopLimit { .. } => {}
4637 }
4638
4639 builder
4640 .build()
4641 .map_err(|e| BuildOrderError::Build(e.to_string()))
4642}
4643
4644const NEW_ORDER_PATH: &str = "/sapi/v1/margin/order";
4646
4647fn new_order_query(params: MarginAccountNewOrderParams) -> BTreeMap<String, serde_json::Value> {
4656 use serde_json::json;
4657
4658 let MarginAccountNewOrderParams {
4659 symbol,
4660 side,
4661 r#type,
4662 is_isolated,
4663 quantity,
4664 quote_order_qty,
4665 price,
4666 stop_price,
4667 new_client_order_id,
4668 iceberg_qty,
4669 new_order_resp_type,
4670 side_effect_type,
4671 time_in_force,
4672 self_trade_prevention_mode,
4673 trailing_delta,
4674 auto_repay_at_cancel,
4675 recv_window,
4676 } = params;
4677
4678 let mut query = BTreeMap::new();
4679 let mut insert = |key: &str, value: Option<serde_json::Value>| {
4680 if let Some(value) = value {
4681 query.insert(key.to_string(), value);
4682 }
4683 };
4684 insert("symbol", Some(json!(symbol)));
4685 insert("isIsolated", is_isolated.map(|v| json!(v)));
4686 insert("side", Some(json!(side)));
4687 insert("type", Some(json!(r#type)));
4688 insert("quantity", quantity.map(|v| json!(v)));
4689 insert("quoteOrderQty", quote_order_qty.map(|v| json!(v)));
4690 insert("price", price.map(|v| json!(v)));
4691 insert("stopPrice", stop_price.map(|v| json!(v)));
4692 insert("newClientOrderId", new_client_order_id.map(|v| json!(v)));
4693 insert("icebergQty", iceberg_qty.map(|v| json!(v)));
4694 insert("newOrderRespType", new_order_resp_type.map(|v| json!(v)));
4695 insert("sideEffectType", side_effect_type.map(|v| json!(v)));
4696 insert("timeInForce", time_in_force.map(|v| json!(v)));
4697 insert(
4698 "selfTradePreventionMode",
4699 self_trade_prevention_mode.map(|v| json!(v)),
4700 );
4701 insert("trailingDelta", trailing_delta.map(|v| json!(v)));
4702 insert("autoRepayAtCancel", auto_repay_at_cancel.map(|v| json!(v)));
4703 insert("recvWindow", recv_window.map(|v| json!(v)));
4704 query
4705}
4706
4707#[derive(Debug, Deserialize)]
4712#[serde(rename_all = "camelCase")]
4713struct NewOrderAnswer {
4714 order_id: Option<i64>,
4715 transact_time: Option<i64>,
4716 status: Option<String>,
4718 executed_qty: Option<String>,
4720 cummulative_quote_qty: Option<String>,
4721 margin_buy_borrow_amount: Option<Decimal>,
4723 margin_buy_borrow_asset: Option<AssetNameExchange>,
4725}
4726
4727fn convert_order_kind_tif_margin(
4738 kind: OrderKind,
4739 tif: TimeInForce,
4740) -> Option<(
4741 MarginAccountNewOrderTypeEnum,
4742 Option<MarginAccountNewOrderTimeInForceEnum>,
4743)> {
4744 let (binance_type, binance_tif) = classify_order_kind_tif(kind, tif)?;
4745 let margin_type = match binance_type {
4746 BinanceOrderType::Market => MarginAccountNewOrderTypeEnum::Market,
4747 BinanceOrderType::Limit => MarginAccountNewOrderTypeEnum::Limit,
4748 BinanceOrderType::LimitMaker => MarginAccountNewOrderTypeEnum::LimitMaker,
4749 BinanceOrderType::StopLoss => MarginAccountNewOrderTypeEnum::StopLoss,
4750 BinanceOrderType::StopLossLimit => MarginAccountNewOrderTypeEnum::StopLossLimit,
4751 BinanceOrderType::TakeProfit => MarginAccountNewOrderTypeEnum::TakeProfit,
4752 BinanceOrderType::TakeProfitLimit => MarginAccountNewOrderTypeEnum::TakeProfitLimit,
4753 };
4754 let margin_tif = binance_tif.map(|t| match t {
4755 BinanceTimeInForce::Gtc => MarginAccountNewOrderTimeInForceEnum::Gtc,
4756 BinanceTimeInForce::Ioc => MarginAccountNewOrderTimeInForceEnum::Ioc,
4757 BinanceTimeInForce::Fok => MarginAccountNewOrderTimeInForceEnum::Fok,
4758 });
4759 Some((margin_type, margin_tif))
4760}
4761
4762#[cfg(test)]
4763#[allow(clippy::unwrap_used, clippy::expect_used)] mod tests {
4765 use super::*;
4766 use crate::client::binance::shared::{
4767 convert_open_order,
4768 test_ws_api::{self, Lifetime},
4769 unix_ms,
4770 };
4771 use crate::error::StreamTerminationReason;
4772 use crate::notice::{AccountNotice, NoticeKind};
4773 use crate::order::state::ActiveOrderState;
4774 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOpenOrdersResponseInner;
4775
4776 #[tokio::test]
4779 async fn a_banned_listen_token_subscribe_starts_the_cooldown() {
4780 let until = i64::try_from(unix_ms()).unwrap() + 120_000;
4781 let error = subscribe_error(WebsocketError::ResponseError {
4782 code: -1003,
4783 message: "Way too much request weight used".to_string(),
4784 retry_after: Some(until),
4785 });
4786 assert!(
4787 error
4788 .to_string()
4789 .starts_with("userDataStream.subscribe.listenToken failed: "),
4790 "{error}"
4791 );
4792 let tracker = RateLimitTracker::new(WeightPool::Sapi);
4793
4794 assert!(tracker.on_stream_refused(&error));
4795 assert!(
4796 tracker
4797 .cooldown_remaining()
4798 .is_some_and(|left| left > Duration::from_secs(115)),
4799 "{:?}",
4800 tracker.cooldown_remaining()
4801 );
4802 }
4803
4804 struct ManagerRig {
4808 ws_config: ConfigurationWebsocketApi,
4809 rest_config: Arc<ConfigurationRestApi>,
4810 rest: Arc<RestApi>,
4811 rate_limiter: Arc<RateLimitTracker>,
4812 requests: Arc<parking_lot::Mutex<Vec<tokio::time::Instant>>>,
4814 }
4815
4816 impl ManagerRig {
4817 async fn new(
4818 answer: fn(&serde_json::Value) -> serde_json::Value,
4819 lifetime: fn(usize) -> Lifetime,
4820 ) -> Self {
4821 let (ws_url, requests) = test_ws_api::serve(answer, lifetime).await;
4822
4823 let rest_url = crate::client::test_http::serve(|method, path| {
4825 if (method, path) != ("POST", "/sapi/v1/userListenToken") {
4826 return (404, String::new());
4827 }
4828 let token = serde_json::json!({
4829 "token": "token",
4830 "expirationTime": Utc::now().timestamp_millis() + 86_400_000,
4831 });
4832 (200, token.to_string())
4833 })
4834 .await;
4835 let rest_config = Arc::new(
4836 ConfigurationRestApi::builder()
4837 .api_key("key")
4838 .api_secret("secret")
4839 .base_path(rest_url)
4840 .build()
4841 .unwrap(),
4842 );
4843 Self {
4844 ws_config: ConfigurationWebsocketApi::builder()
4845 .api_key("key")
4846 .api_secret("secret")
4847 .ws_url(ws_url)
4848 .build()
4849 .unwrap(),
4850 rest: Arc::new(MarginTradingRestApi::from_config((*rest_config).clone())),
4851 rest_config,
4852 rate_limiter: Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
4853 requests,
4854 }
4855 }
4856
4857 fn token() -> UserListenToken {
4859 UserListenToken {
4860 token: "token".to_string(),
4861 expiration_time_ms: Utc::now().timestamp_millis() + 86_400_000,
4862 }
4863 }
4864
4865 async fn cross(&self) -> mpsc::UnboundedReceiver<UnindexedAccountEvent> {
4867 let (tx, rx) = mpsc::unbounded_channel();
4868 let ws = connect_margin_ws(&self.ws_config).await.unwrap();
4869 tokio::spawn(margin_connection_manager(
4870 tx,
4871 new_dedup_cache(),
4872 self.ws_config.clone(),
4873 Arc::clone(&self.rest_config),
4874 Arc::clone(&self.rest),
4875 Arc::clone(&self.rate_limiter),
4876 KnownLiveOrders::shared(ExchangeId::BinanceMargin),
4877 Vec::new(),
4878 Some((ws, Self::token())),
4879 ));
4880 rx
4881 }
4882
4883 async fn isolated(&self) -> mpsc::UnboundedReceiver<UnindexedAccountEvent> {
4886 let (tx, rx) = mpsc::unbounded_channel();
4887 let (dedup, known) = (
4888 new_dedup_cache(),
4889 KnownLiveOrders::shared(ExchangeId::BinanceMargin),
4890 );
4891 let symbol = InstrumentNameExchange::new("BTCUSDT");
4892 let base_quote = Arc::new(HashMap::from([(
4893 symbol.clone(),
4894 (
4895 AssetNameExchange::new("BTC"),
4896 AssetNameExchange::new("USDT"),
4897 ),
4898 )]));
4899 let tokens = [(symbol.clone(), Self::token())];
4900 let live = isolated_connect_and_subscribe(
4901 &self.ws_config,
4902 &tx,
4903 &dedup,
4904 &known,
4905 &base_quote,
4906 &tokens,
4907 )
4908 .await
4909 .unwrap();
4910 tokio::spawn(isolated_connection_manager(
4911 tx,
4912 dedup,
4913 self.ws_config.clone(),
4914 Arc::clone(&self.rest_config),
4915 Arc::clone(&self.rest),
4916 Arc::clone(&self.rate_limiter),
4917 known,
4918 vec![symbol],
4919 base_quote,
4920 Some(live),
4921 ));
4922 rx
4923 }
4924 }
4925
4926 fn subscribed(request: &serde_json::Value) -> serde_json::Value {
4928 serde_json::json!({
4929 "id": request["id"],
4930 "status": 200,
4931 "result": { "subscriptionId": 0 },
4932 })
4933 }
4934
4935 fn banned_for_two_minutes(request: &serde_json::Value) -> serde_json::Value {
4937 let until = unix_ms() + 120_000;
4938 serde_json::json!({
4939 "id": request["id"],
4940 "status": 418,
4941 "error": {
4942 "code": -1003,
4943 "msg": format!("Way too much request weight used; IP banned until {until}."),
4944 "data": { "serverTime": unix_ms(), "retryAfter": until },
4945 },
4946 })
4947 }
4948
4949 async fn rate_limited_end(rx: &mut mpsc::UnboundedReceiver<UnindexedAccountEvent>) -> Duration {
4952 let event = tokio::time::timeout(Duration::from_secs(5), rx.recv())
4953 .await
4954 .expect("the stream ends rate-limited within the timeout")
4955 .expect("it reports why before it ends");
4956 let AccountEventKind::StreamTerminated(StreamTerminationReason::RateLimited {
4957 retry_after,
4958 }) = event.kind
4959 else {
4960 panic!("expected a rate-limited termination, got {:?}", event.kind);
4961 };
4962 assert!(rx.recv().await.is_none(), "the stream has ended");
4963 retry_after
4964 }
4965
4966 #[tokio::test]
4969 async fn a_banned_subscribe_ends_the_cross_margin_stream_rate_limited() {
4970 let rig = ManagerRig::new(banned_for_two_minutes, |_| Lifetime::Open).await;
4971 let mut events = rig.cross().await;
4972
4973 let wait = rate_limited_end(&mut events).await;
4974
4975 assert!(wait > Duration::from_secs(115), "{wait:?}");
4976 assert_eq!(rig.requests.lock().len(), 1, "no second subscribe");
4977 }
4978
4979 #[tokio::test]
4982 async fn a_banned_isolated_subscribe_is_recognised_as_a_refusal() {
4983 let rig = ManagerRig::new(banned_for_two_minutes, |_| Lifetime::Open).await;
4984 let (tx, _rx) = mpsc::unbounded_channel();
4985 let symbol = InstrumentNameExchange::new("BTCUSDT");
4986
4987 let Err(error) = isolated_connect_and_subscribe(
4988 &rig.ws_config,
4989 &tx,
4990 &new_dedup_cache(),
4991 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
4992 &Arc::new(HashMap::new()),
4993 &[(symbol, ManagerRig::token())],
4994 )
4995 .await
4996 else {
4997 panic!("a banned subscribe fails");
4998 };
4999
5000 assert!(rig.rate_limiter.on_stream_refused(&error), "{error:#}");
5001 assert!(
5002 rig.rate_limiter
5003 .cooldown_remaining()
5004 .is_some_and(|left| left > Duration::from_secs(115))
5005 );
5006 }
5007
5008 #[tokio::test]
5011 async fn a_drop_during_a_long_cooldown_ends_the_isolated_margin_stream_rate_limited() {
5012 let rig = ManagerRig::new(subscribed, |_| Lifetime::Silent(Duration::from_secs(1))).await;
5014 let mut events = rig.isolated().await;
5015 rig.rate_limiter
5016 .on_rate_limited(Some(Duration::from_secs(60)));
5017
5018 let wait = rate_limited_end(&mut events).await;
5019
5020 assert!(wait > Duration::from_secs(55), "{wait:?}");
5021 assert_eq!(rig.requests.lock().len(), 1, "no second subscribe");
5022 }
5023
5024 #[tokio::test]
5027 async fn a_dropped_margin_connection_backs_off_before_reconnecting() {
5028 for isolated in [false, true] {
5029 let rig =
5030 ManagerRig::new(subscribed, |_| Lifetime::Silent(Duration::from_millis(100))).await;
5031 let _events = if isolated {
5032 rig.isolated().await
5033 } else {
5034 rig.cross().await
5035 };
5036
5037 let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
5038 while rig.requests.lock().len() < 2 {
5039 assert!(
5040 tokio::time::Instant::now() < deadline,
5041 "isolated {isolated}: no reconnect"
5042 );
5043 tokio::time::sleep(Duration::from_millis(20)).await;
5044 }
5045
5046 let requests = rig.requests.lock();
5047 let between = requests[1] - requests[0];
5048 assert!(
5050 between >= BackoffLimits::ACCOUNT_STREAM.initial,
5051 "isolated {isolated}: reconnected {between:?} after the first subscribe"
5052 );
5053 }
5054 }
5055
5056 #[tokio::test(start_paused = true)]
5061 async fn a_margin_reconnect_budget_resets_only_once_a_connection_proves_stable() {
5062 async fn run(isolated: bool, pinging: bool) {
5063 let lifetime = if pinging {
5064 test_ws_api::dropped_then_pinging
5065 } else {
5066 test_ws_api::dropped_then_silent
5067 };
5068 let rig = ManagerRig::new(subscribed, lifetime).await;
5069 let _events = if isolated {
5070 rig.isolated().await
5071 } else {
5072 rig.cross().await
5073 };
5074 let label = format!("isolated {isolated}");
5075 if pinging {
5076 test_ws_api::assert_reset_once_pinged(&rig.requests, &label).await;
5077 } else {
5078 test_ws_api::assert_no_reset_when_silent(&rig.requests, &label).await;
5079 }
5080 }
5081
5082 tokio::join!(
5083 run(false, true),
5084 run(true, true),
5085 run(false, false),
5086 run(true, false)
5087 );
5088 }
5089
5090 #[tokio::test]
5093 async fn a_dropped_margin_connection_is_closed() {
5094 use futures::StreamExt as _;
5095 use tokio_tungstenite::tungstenite::Message;
5096
5097 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
5098 let url = format!("ws://{}", listener.local_addr().unwrap());
5099 let (closed_tx, mut closed) = mpsc::unbounded_channel();
5100 tokio::spawn(async move {
5101 let (stream, _) = listener.accept().await.unwrap();
5102 let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap();
5103 while let Some(Ok(message)) = ws.next().await {
5104 if let Message::Close(_) = message {
5105 let _ = closed_tx.send(());
5106 return;
5107 }
5108 }
5109 });
5110 let config = ConfigurationWebsocketApi::builder()
5111 .api_key("key")
5112 .api_secret("secret")
5113 .ws_url(url)
5114 .build()
5115 .unwrap();
5116
5117 drop(connect_margin_ws(&config).await.unwrap());
5118
5119 tokio::time::timeout(Duration::from_secs(5), closed.recv())
5120 .await
5121 .expect("the dropped connection is closed")
5122 .unwrap();
5123 }
5124
5125 #[test]
5126 fn margin_effect_default_is_no_side_effect() {
5127 assert_eq!(MarginEffect::default(), MarginEffect::NoSideEffect);
5128 }
5129
5130 #[test]
5131 fn margin_effect_wire_values() {
5132 for (effect, wire) in [
5133 (MarginEffect::NoSideEffect, "NO_SIDE_EFFECT"),
5134 (MarginEffect::Borrow, "MARGIN_BUY"),
5135 (MarginEffect::Repay, "AUTO_REPAY"),
5136 ] {
5137 assert_eq!(binance_side_effect(effect).as_str(), wire);
5138 }
5139 }
5140
5141 #[test]
5142 fn config_debug_redacts_secrets() {
5143 let config = BinanceMarginConfig::new(
5144 "my_api_key".to_string(),
5145 "my_secret_key".to_string(),
5146 false,
5147 false,
5148 Vec::new(),
5149 MarginEffect::default(),
5150 true,
5151 );
5152 let debug = format!("{config:?}");
5153 assert!(!debug.contains("my_api_key"));
5154 assert!(!debug.contains("my_secret_key"));
5155 assert!(debug.contains("***"));
5156 }
5157
5158 #[test]
5159 fn cross_margin_uses_common_case_defaults() {
5160 let config = BinanceMarginConfig::cross_margin("k".to_string(), "s".to_string());
5161 assert!(!config.testnet);
5162 assert!(!config.is_isolated);
5163 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
5164 assert!(config.auto_repay_at_cancel);
5165 assert_eq!(config.api_key(), "k");
5166 }
5167
5168 #[test]
5169 fn config_deserializes_with_defaults() {
5170 let config: BinanceMarginConfig =
5173 serde_json::from_str(r#"{"api_key":"k","secret_key":"s","testnet":false}"#)
5174 .expect("deserialize");
5175 assert!(!config.is_isolated);
5176 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
5177 assert!(config.auto_repay_at_cancel);
5178 }
5179
5180 #[test]
5181 fn config_deserializes_explicit_margin_effect() {
5182 let config: BinanceMarginConfig = serde_json::from_str(
5183 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true,"margin_effect":"borrow","auto_repay_at_cancel":false}"#,
5184 )
5185 .expect("deserialize");
5186 assert!(config.is_isolated);
5187 assert_eq!(config.margin_effect, MarginEffect::Borrow);
5188 assert!(!config.auto_repay_at_cancel);
5189 }
5190
5191 #[test]
5192 fn config_refuses_the_retired_combined_effect() {
5193 let parsed = serde_json::from_str::<BinanceMarginConfig>(
5195 r#"{"api_key":"k","secret_key":"s","testnet":false,"margin_effect":"auto_borrow_repay"}"#,
5196 );
5197 assert!(parsed.is_err());
5198 }
5199
5200 #[test]
5201 fn isolated_ctor_sets_symbols_and_flag() {
5202 let symbols = vec![
5203 InstrumentNameExchange::new("BTCUSDT"),
5204 InstrumentNameExchange::new("ETHUSDT"),
5205 ];
5206 let config =
5207 BinanceMarginConfig::isolated("k".to_string(), "s".to_string(), symbols.clone());
5208 assert!(config.is_isolated);
5209 assert_eq!(config.isolated_symbols, symbols);
5210 assert_eq!(config.margin_effect, MarginEffect::NoSideEffect);
5211 }
5212
5213 #[test]
5214 fn isolated_config_with_symbols_constructs() {
5215 let config = BinanceMarginConfig::isolated(
5217 "k".to_string(),
5218 "s".to_string(),
5219 vec![InstrumentNameExchange::new("BTCUSDT")],
5220 );
5221 let _client = BinanceMargin::new(config); }
5223
5224 #[test]
5225 #[should_panic(expected = "non-empty isolated_symbols")]
5226 fn isolated_empty_symbols_panics_at_new() {
5227 let config = BinanceMarginConfig::new(
5229 "k".to_string(),
5230 "s".to_string(),
5231 false,
5232 true,
5233 Vec::new(),
5234 MarginEffect::default(),
5235 true,
5236 );
5237 let _ = BinanceMargin::new(config);
5238 }
5239
5240 #[test]
5241 #[should_panic(expected = "non-empty isolated_symbols")]
5242 fn isolated_empty_symbols_panics_via_deserialize_path() {
5243 let config: BinanceMarginConfig = serde_json::from_str(
5246 r#"{"api_key":"k","secret_key":"s","testnet":false,"is_isolated":true}"#,
5247 )
5248 .expect("deserialize");
5249 assert!(config.isolated_symbols.is_empty());
5250 let _ = BinanceMargin::new(config);
5251 }
5252
5253 #[test]
5254 fn isolated_flag_maps_mode() {
5255 assert_eq!(
5258 MarginAccountNewOrderIsIsolatedEnum::from_flag(true).as_str(),
5259 "TRUE"
5260 );
5261 assert_eq!(
5262 MarginAccountNewOrderIsIsolatedEnum::from_flag(false).as_str(),
5263 "FALSE"
5264 );
5265 assert_eq!(
5266 MarginAccountCancelOrderIsIsolatedEnum::from_flag(true).as_str(),
5267 "TRUE"
5268 );
5269 assert_eq!(
5270 MarginAccountCancelOrderIsIsolatedEnum::from_flag(false).as_str(),
5271 "FALSE"
5272 );
5273 assert_eq!(
5274 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(true).as_str(),
5275 "TRUE"
5276 );
5277 assert_eq!(
5278 QueryMarginAccountsOpenOrdersIsIsolatedEnum::from_flag(false).as_str(),
5279 "FALSE"
5280 );
5281 assert_eq!(
5282 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(true).as_str(),
5283 "TRUE"
5284 );
5285 assert_eq!(
5286 QueryMarginAccountsTradeListIsIsolatedEnum::from_flag(false).as_str(),
5287 "FALSE"
5288 );
5289 }
5290
5291 #[test]
5296 fn cancel_params_cross_with_parseable_order_id() {
5297 let p = build_cancel_order_params(
5298 "BTCUSDT".to_string(),
5299 Some(&OrderId::new("12345")),
5300 &ClientOrderId::new("cid-1"),
5301 false,
5302 )
5303 .expect("build");
5304 assert_eq!(p.symbol, "BTCUSDT");
5305 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("FALSE"));
5306 assert_eq!(p.order_id, Some(12345));
5307 assert_eq!(p.orig_client_order_id, None);
5308 }
5309
5310 #[test]
5311 fn cancel_params_isolated_is_config_driven() {
5312 let p = build_cancel_order_params(
5313 "BTCUSDT".to_string(),
5314 Some(&OrderId::new("12345")),
5315 &ClientOrderId::new("cid-1"),
5316 true,
5317 )
5318 .expect("build");
5319 assert_eq!(p.is_isolated.as_ref().map(|i| i.as_str()), Some("TRUE"));
5320 assert_eq!(p.order_id, Some(12345));
5321 }
5322
5323 #[test]
5324 fn cancel_params_non_i64_order_id_falls_back_to_cid() {
5325 let p = build_cancel_order_params(
5326 "BTCUSDT".to_string(),
5327 Some(&OrderId::new("not-an-i64")),
5328 &ClientOrderId::new("cid-1"),
5329 false,
5330 )
5331 .expect("build");
5332 assert_eq!(p.order_id, None);
5333 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
5334 }
5335
5336 #[test]
5337 fn cancel_params_absent_order_id_uses_cid() {
5338 let p = build_cancel_order_params(
5339 "BTCUSDT".to_string(),
5340 None,
5341 &ClientOrderId::new("cid-1"),
5342 false,
5343 )
5344 .expect("build");
5345 assert_eq!(p.order_id, None);
5346 assert_eq!(p.orig_client_order_id.as_deref(), Some("cid-1"));
5347 }
5348
5349 use crate::order::TrailingOffsetType;
5354
5355 fn params(
5357 side: Side,
5358 price: Option<Decimal>,
5359 kind: OrderKind,
5360 tif: TimeInForce,
5361 margin_effect: MarginEffect,
5362 ) -> Result<MarginAccountNewOrderParams, BuildOrderError> {
5363 build_new_order_params(
5364 "BTCUSDT".to_string(),
5365 side,
5366 price,
5367 Decimal::from(2),
5368 kind,
5369 tif,
5370 "cid-1".to_string(),
5371 margin_effect,
5372 true,
5373 false, )
5375 }
5376
5377 fn gtc() -> TimeInForce {
5378 TimeInForce::GoodUntilCancelled { post_only: false }
5379 }
5380
5381 #[test]
5382 fn new_order_limit_maps_core_fields() {
5383 let p = params(
5384 Side::Buy,
5385 Some(Decimal::from(50_000)),
5386 OrderKind::Limit,
5387 gtc(),
5388 MarginEffect::Borrow,
5389 )
5390 .expect("build");
5391
5392 assert_eq!(p.symbol, "BTCUSDT");
5393 assert_eq!(p.side.as_str(), "BUY");
5394 assert_eq!(p.r#type.as_str(), "LIMIT");
5395 assert_eq!(p.quantity, Some(Decimal::from(2)));
5396 assert_eq!(p.price, Some(Decimal::from(50_000)));
5397 assert_eq!(p.stop_price, None);
5398 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some("GTC"));
5399 assert_eq!(p.new_client_order_id.as_deref(), Some("cid-1"));
5400 assert_eq!(
5402 p.new_order_resp_type.as_ref().map(|r| r.as_str()),
5403 Some("FULL")
5404 );
5405 }
5406
5407 #[test]
5408 fn new_order_is_isolated_is_config_driven() {
5409 let cross = build_new_order_params(
5411 "BTCUSDT".to_string(),
5412 Side::Sell,
5413 Some(Decimal::from(10)),
5414 Decimal::from(2),
5415 OrderKind::Limit,
5416 gtc(),
5417 "cid-1".to_string(),
5418 MarginEffect::Borrow,
5419 true,
5420 false,
5421 )
5422 .expect("build");
5423 assert_eq!(
5424 cross.is_isolated.as_ref().map(|i| i.as_str()),
5425 Some("FALSE")
5426 );
5427 assert_eq!(cross.side.as_str(), "SELL");
5428
5429 let isolated = build_new_order_params(
5431 "BTCUSDT".to_string(),
5432 Side::Sell,
5433 Some(Decimal::from(10)),
5434 Decimal::from(2),
5435 OrderKind::Limit,
5436 gtc(),
5437 "cid-1".to_string(),
5438 MarginEffect::Borrow,
5439 true,
5440 true,
5441 )
5442 .expect("build");
5443 assert_eq!(
5444 isolated.is_isolated.as_ref().map(|i| i.as_str()),
5445 Some("TRUE")
5446 );
5447 }
5448
5449 #[test]
5450 fn side_effect_and_auto_repay_gating() {
5451 let build = |effect, auto_repay_at_cancel| {
5452 build_new_order_params(
5453 "BTCUSDT".to_string(),
5454 Side::Buy,
5455 Some(Decimal::from(1)),
5456 Decimal::from(2),
5457 OrderKind::Limit,
5458 gtc(),
5459 "cid-1".to_string(),
5460 effect,
5461 auto_repay_at_cancel,
5462 false,
5463 )
5464 .expect("build")
5465 };
5466 let wire = |params: &MarginAccountNewOrderParams| {
5467 params
5468 .side_effect_type
5469 .as_ref()
5470 .map(|s| s.as_str().to_owned())
5471 };
5472
5473 let borrow = build(MarginEffect::Borrow, true);
5475 assert_eq!(wire(&borrow).as_deref(), Some("MARGIN_BUY"));
5476 assert_eq!(borrow.auto_repay_at_cancel, Some(true));
5477 let borrow_off = build(MarginEffect::Borrow, false);
5478 assert_eq!(borrow_off.auto_repay_at_cancel, Some(false));
5479
5480 let repay = build(MarginEffect::Repay, true);
5482 assert_eq!(wire(&repay).as_deref(), Some("AUTO_REPAY"));
5483 assert_eq!(repay.auto_repay_at_cancel, None);
5484 let none = build(MarginEffect::NoSideEffect, true);
5485 assert_eq!(wire(&none).as_deref(), Some("NO_SIDE_EFFECT"));
5486 assert_eq!(none.auto_repay_at_cancel, None);
5487 }
5488
5489 #[test]
5490 fn new_order_market_has_no_price_or_tif() {
5491 let p = params(
5492 Side::Buy,
5493 None,
5494 OrderKind::Market,
5495 gtc(),
5496 MarginEffect::Borrow,
5497 )
5498 .expect("build");
5499 assert_eq!(p.r#type.as_str(), "MARKET");
5500 assert_eq!(p.price, None);
5501 assert_eq!(p.stop_price, None);
5502 assert!(p.time_in_force.is_none());
5503 }
5504
5505 #[test]
5506 fn post_only_limit_maps_to_limit_maker() {
5507 let p = params(
5508 Side::Buy,
5509 Some(Decimal::from(10)),
5510 OrderKind::Limit,
5511 TimeInForce::GoodUntilCancelled { post_only: true },
5512 MarginEffect::Borrow,
5513 )
5514 .expect("build");
5515 assert_eq!(p.r#type.as_str(), "LIMIT_MAKER");
5516 assert!(p.time_in_force.is_none());
5518 }
5519
5520 #[test]
5521 fn conditional_kinds_set_stop_price() {
5522 let trigger = Decimal::from(48_000);
5523
5524 let stop = params(
5525 Side::Sell,
5526 None,
5527 OrderKind::Stop {
5528 trigger_price: trigger,
5529 },
5530 gtc(),
5531 MarginEffect::Borrow,
5532 )
5533 .expect("build");
5534 assert_eq!(stop.r#type.as_str(), "STOP_LOSS");
5535 assert_eq!(stop.stop_price, Some(trigger));
5536 assert_eq!(stop.price, None);
5537
5538 let stop_limit = params(
5539 Side::Sell,
5540 Some(Decimal::from(47_900)),
5541 OrderKind::StopLimit {
5542 trigger_price: trigger,
5543 },
5544 gtc(),
5545 MarginEffect::Borrow,
5546 )
5547 .expect("build");
5548 assert_eq!(stop_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
5549 assert_eq!(stop_limit.stop_price, Some(trigger));
5550 assert_eq!(stop_limit.price, Some(Decimal::from(47_900)));
5551 assert_eq!(
5552 stop_limit.time_in_force.as_ref().map(|t| t.as_str()),
5553 Some("GTC")
5554 );
5555
5556 let take_profit = params(
5557 Side::Sell,
5558 None,
5559 OrderKind::TakeProfit {
5560 trigger_price: trigger,
5561 },
5562 gtc(),
5563 MarginEffect::Borrow,
5564 )
5565 .expect("build");
5566 assert_eq!(take_profit.r#type.as_str(), "TAKE_PROFIT");
5567 assert_eq!(take_profit.stop_price, Some(trigger));
5568 }
5569
5570 #[test]
5573 fn a_trailing_stop_maps_to_stop_loss_with_trailing_delta() {
5574 let cases = [
5575 (Decimal::from(25), TrailingOffsetType::BasisPoints, 25),
5576 (Decimal::new(15, 1), TrailingOffsetType::Percentage, 150),
5577 ];
5578 for (offset, offset_type, trailing_delta) in cases {
5579 let p = params(
5580 Side::Sell,
5581 None,
5582 OrderKind::TrailingStop {
5583 offset,
5584 offset_type,
5585 activation_price: None,
5586 },
5587 gtc(),
5588 MarginEffect::NoSideEffect,
5589 )
5590 .expect("build");
5591 assert_eq!(p.r#type.as_str(), "STOP_LOSS");
5592 assert_eq!(p.trailing_delta, Some(trailing_delta), "{offset_type:?}");
5593 assert_eq!(p.stop_price, None);
5594 assert_eq!(p.price, None);
5595 assert!(p.time_in_force.is_none());
5596 }
5597 }
5598
5599 #[test]
5603 fn trailing_orders_carry_their_activation_price_and_fixed_limit() {
5604 let activation = Some(Decimal::from(50_000));
5605 let trailing_stop = params(
5606 Side::Sell,
5607 None,
5608 OrderKind::TrailingStop {
5609 offset: Decimal::from(25),
5610 offset_type: TrailingOffsetType::BasisPoints,
5611 activation_price: activation,
5612 },
5613 gtc(),
5614 MarginEffect::NoSideEffect,
5615 )
5616 .expect("build");
5617 assert_eq!(trailing_stop.r#type.as_str(), "STOP_LOSS");
5618 assert_eq!(trailing_stop.trailing_delta, Some(25));
5619 assert_eq!(trailing_stop.stop_price, activation);
5620 assert_eq!(trailing_stop.price, None);
5621
5622 for activation_price in [None, activation] {
5623 let fixed_limit = params(
5624 Side::Sell,
5625 Some(Decimal::from(49_000)),
5626 OrderKind::TrailingStopFixedLimit {
5627 offset: Decimal::new(15, 1),
5628 offset_type: TrailingOffsetType::Percentage,
5629 activation_price,
5630 },
5631 TimeInForce::ImmediateOrCancel,
5632 MarginEffect::NoSideEffect,
5633 )
5634 .expect("build");
5635 assert_eq!(fixed_limit.r#type.as_str(), "STOP_LOSS_LIMIT");
5636 assert_eq!(fixed_limit.trailing_delta, Some(150));
5637 assert_eq!(fixed_limit.price, Some(Decimal::from(49_000)));
5638 assert_eq!(fixed_limit.stop_price, activation_price);
5639 assert_eq!(
5640 fixed_limit.time_in_force.as_ref().map(|t| t.as_str()),
5641 Some("IOC")
5642 );
5643 }
5644
5645 let absolute = params(
5646 Side::Sell,
5647 Some(Decimal::from(49_000)),
5648 OrderKind::TrailingStopFixedLimit {
5649 offset: Decimal::from(100),
5650 offset_type: TrailingOffsetType::Absolute,
5651 activation_price: None,
5652 },
5653 gtc(),
5654 MarginEffect::NoSideEffect,
5655 );
5656 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
5657
5658 let no_limit = params(
5660 Side::Sell,
5661 None,
5662 OrderKind::TrailingStopFixedLimit {
5663 offset: Decimal::from(25),
5664 offset_type: TrailingOffsetType::BasisPoints,
5665 activation_price: None,
5666 },
5667 gtc(),
5668 MarginEffect::NoSideEffect,
5669 );
5670 assert!(matches!(
5671 no_limit,
5672 Err(BuildOrderError::Trailing(TrailingOrderError::LimitPrice(
5673 None
5674 )))
5675 ));
5676 let zero_activation = params(
5677 Side::Sell,
5678 None,
5679 OrderKind::TrailingStop {
5680 offset: Decimal::from(25),
5681 offset_type: TrailingOffsetType::BasisPoints,
5682 activation_price: Some(Decimal::ZERO),
5683 },
5684 gtc(),
5685 MarginEffect::NoSideEffect,
5686 );
5687 assert!(matches!(
5688 zero_activation,
5689 Err(BuildOrderError::Trailing(
5690 TrailingOrderError::NonPositiveActivation(_)
5691 ))
5692 ));
5693 }
5694
5695 #[test]
5696 fn absolute_trailing_offsets_and_trailing_stop_limits_are_unsupported() {
5697 let absolute = params(
5698 Side::Sell,
5699 None,
5700 OrderKind::TrailingStop {
5701 offset: Decimal::from(100),
5702 offset_type: TrailingOffsetType::Absolute,
5703 activation_price: None,
5704 },
5705 gtc(),
5706 MarginEffect::Borrow,
5707 );
5708 assert!(matches!(absolute, Err(BuildOrderError::Unsupported)));
5709
5710 let trailing_stop_limit = params(
5711 Side::Sell,
5712 Some(Decimal::from(100)),
5713 OrderKind::TrailingStopLimit {
5714 offset: Decimal::from(100),
5715 offset_type: TrailingOffsetType::BasisPoints,
5716 stop_price: Decimal::from(95),
5717 limit_offset: Decimal::from(10),
5718 },
5719 gtc(),
5720 MarginEffect::Borrow,
5721 );
5722 assert!(matches!(
5723 trailing_stop_limit,
5724 Err(BuildOrderError::Unsupported)
5725 ));
5726 }
5727
5728 #[test]
5730 fn a_fractional_trailing_offset_is_refused() {
5731 let fractional = params(
5732 Side::Sell,
5733 None,
5734 OrderKind::TrailingStop {
5735 offset: Decimal::new(155, 3),
5736 offset_type: TrailingOffsetType::Percentage,
5737 activation_price: None,
5738 },
5739 gtc(),
5740 MarginEffect::Borrow,
5741 );
5742 assert!(matches!(
5743 fractional,
5744 Err(BuildOrderError::Trailing(TrailingOrderError::Delta(
5745 crate::client::binance::shared::TrailingDeltaError::Invalid(_)
5746 )))
5747 ));
5748 }
5749
5750 #[test]
5751 fn tif_variants_map_to_margin_enum() {
5752 for (tif, expected) in [
5753 (TimeInForce::ImmediateOrCancel, "IOC"),
5754 (TimeInForce::FillOrKill, "FOK"),
5755 (gtc(), "GTC"),
5756 ] {
5757 let p = params(
5758 Side::Buy,
5759 Some(Decimal::from(1)),
5760 OrderKind::Limit,
5761 tif,
5762 MarginEffect::Borrow,
5763 )
5764 .expect("build");
5765 assert_eq!(p.time_in_force.as_ref().map(|t| t.as_str()), Some(expected));
5766 }
5767 }
5768
5769 fn user_asset(
5774 asset: &str,
5775 free: &str,
5776 locked: &str,
5777 borrowed: &str,
5778 interest: &str,
5779 ) -> QueryCrossMarginAccountDetailsResponseUserAssetsInner {
5780 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
5781 a.asset = Some(asset.to_string());
5782 a.free = Some(free.to_string());
5783 a.locked = Some(locked.to_string());
5784 a.borrowed = Some(borrowed.to_string());
5785 a.interest = Some(interest.to_string());
5786 a
5787 }
5788
5789 #[test]
5790 fn margin_balance_entry_maps_debt() {
5791 let ab =
5793 convert_margin_balance_entry(user_asset("USDT", "10", "2", "3", "0.5"), Utc::now())
5794 .expect("convert");
5795 assert_eq!(ab.asset.name().as_str(), "USDT");
5796 assert_eq!(ab.balance.total, Decimal::from(12));
5797 assert_eq!(ab.balance.free, Decimal::from(10));
5798 assert!(
5799 ab.balance.margin.is_some(),
5800 "REST snapshot must populate margin"
5801 );
5802 assert_eq!(ab.balance.net_asset(), Decimal::from(9));
5804 }
5805
5806 #[test]
5807 fn margin_balance_short_is_negative_net() {
5808 let ab = convert_margin_balance_entry(user_asset("BTC", "1", "0", "5", "0"), Utc::now())
5810 .expect("convert");
5811 assert_eq!(ab.balance.net_asset(), Decimal::from(-4));
5812 }
5813
5814 #[test]
5815 fn margin_balance_missing_debt_defaults_to_zero() {
5816 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
5819 a.asset = Some("ETH".to_string());
5820 a.free = Some("4".to_string());
5821 a.locked = Some("0".to_string());
5822 let ab = convert_margin_balance_entry(a, Utc::now()).expect("convert");
5823 assert!(ab.balance.margin.is_some());
5824 assert_eq!(ab.balance.net_asset(), Decimal::from(4));
5825 }
5826
5827 #[test]
5828 fn margin_balance_missing_free_is_dropped() {
5829 let mut a = QueryCrossMarginAccountDetailsResponseUserAssetsInner::new();
5831 a.asset = Some("USDT".to_string());
5832 a.locked = Some("0".to_string());
5833 assert!(convert_margin_balance_entry(a, Utc::now()).is_none());
5834 }
5835
5836 #[test]
5837 fn margin_balance_filtering() {
5838 let assets = vec![
5839 user_asset("USDT", "10", "0", "0", "0"),
5840 user_asset("BTC", "1", "0", "0", "0"),
5841 user_asset("ETH", "5", "0", "0", "0"),
5842 ];
5843 assert_eq!(
5845 filter_and_convert_margin_balances(assets.clone(), &[]).len(),
5846 3
5847 );
5848 let filtered = filter_and_convert_margin_balances(
5850 assets,
5851 &[AssetNameExchange::new("BTC"), AssetNameExchange::new("ETH")],
5852 );
5853 assert_eq!(filtered.len(), 2);
5854 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "USDT"));
5855 }
5856
5857 #[test]
5858 fn margin_balance_filtering_large_slice_uses_hashset_branch() {
5859 let assets = vec![
5861 user_asset("BTC", "1", "0", "0", "0"),
5862 user_asset("ETH", "5", "0", "0", "0"),
5863 user_asset("DOGE", "9", "0", "0", "0"), ];
5865 let mut requested: Vec<AssetNameExchange> = (0..17)
5867 .map(|i| AssetNameExchange::new(format!("A{i:02}")))
5868 .collect();
5869 requested.push(AssetNameExchange::new("BTC"));
5870 requested.push(AssetNameExchange::new("ETH"));
5871 assert!(
5872 requested.len() > 16,
5873 "must exceed the linear-scan threshold"
5874 );
5875
5876 let filtered = filter_and_convert_margin_balances(assets, &requested);
5877 assert_eq!(filtered.len(), 2);
5878 assert!(filtered.iter().all(|b| b.asset.name().as_str() != "DOGE"));
5879 }
5880
5881 use binance_sdk::margin_trading::rest_api::{
5886 QueryIsolatedMarginAccountInfoResponseAssetsInnerBaseAsset as IsoBase,
5887 QueryIsolatedMarginAccountInfoResponseAssetsInnerQuoteAsset as IsoQuote,
5888 };
5889
5890 fn d(s: &str) -> Decimal {
5891 Decimal::from_str(s).unwrap()
5892 }
5893
5894 fn iso_base(asset: &str, free: &str, locked: &str, borrowed: &str, interest: &str) -> IsoBase {
5895 let mut b = IsoBase::new();
5896 b.asset = Some(asset.to_string());
5897 b.free = Some(free.to_string());
5898 b.locked = Some(locked.to_string());
5899 b.borrowed = Some(borrowed.to_string());
5900 b.interest = Some(interest.to_string());
5901 b
5902 }
5903
5904 fn iso_quote(
5905 asset: &str,
5906 free: &str,
5907 locked: &str,
5908 borrowed: &str,
5909 interest: &str,
5910 ) -> IsoQuote {
5911 let mut q = IsoQuote::new();
5912 q.asset = Some(asset.to_string());
5913 q.free = Some(free.to_string());
5914 q.locked = Some(locked.to_string());
5915 q.borrowed = Some(borrowed.to_string());
5916 q.interest = Some(interest.to_string());
5917 q
5918 }
5919
5920 fn iso_entry(
5921 symbol: &str,
5922 base: IsoBase,
5923 quote: IsoQuote,
5924 margin_level: Option<&str>,
5925 margin_ratio: Option<&str>,
5926 liquidate_price: Option<&str>,
5927 ) -> QueryIsolatedMarginAccountInfoResponseAssetsInner {
5928 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
5929 e.symbol = Some(symbol.to_string());
5930 e.base_asset = Some(Box::new(base));
5931 e.quote_asset = Some(Box::new(quote));
5932 e.margin_level = margin_level.map(str::to_string);
5933 e.margin_ratio = margin_ratio.map(str::to_string);
5934 e.liquidate_price = liquidate_price.map(str::to_string);
5935 e
5936 }
5937
5938 fn isolated_client(symbols: &[&str]) -> BinanceMargin {
5939 let config = BinanceMarginConfig::isolated(
5940 "k".to_string(),
5941 "s".to_string(),
5942 symbols
5943 .iter()
5944 .map(|s| InstrumentNameExchange::new(*s))
5945 .collect(),
5946 );
5947 BinanceMargin::new(config)
5948 }
5949
5950 #[test]
5951 fn client_debug_shows_mode_and_margin_effect_without_credentials() {
5952 let debug = format!("{:?}", isolated_client(&["BTCUSDT"]));
5953 assert!(debug.contains("BinanceMargin"));
5954 assert!(debug.contains("is_isolated: true"));
5955 assert!(debug.contains("margin_effect: NoSideEffect"));
5956 assert!(!debug.contains("\"k\"") && !debug.contains("\"s\""));
5957 }
5958
5959 #[test]
5960 fn isolated_assets_map_base_quote_and_risk() {
5961 let entry = iso_entry(
5964 "BTCUSDT",
5965 iso_base("BTC", "0.5", "0.1", "0.2", "0.001"),
5966 iso_quote("USDT", "1000", "50", "300", "1.5"),
5967 Some("3.5"),
5968 Some("0.12"),
5969 Some("48000"),
5970 );
5971 let map = convert_isolated_margin_assets(vec![entry]);
5972 let state = map
5973 .get(&InstrumentNameExchange::new("BTCUSDT"))
5974 .expect("entry present");
5975
5976 assert_eq!(state.base.asset.name().as_str(), "BTC");
5977 assert_eq!(state.base.balance.total, d("0.6"));
5978 assert_eq!(state.base.balance.free, d("0.5"));
5979 assert_eq!(state.base.balance.net_asset(), d("0.4"));
5980
5981 assert_eq!(state.quote.asset.name().as_str(), "USDT");
5982 assert_eq!(state.quote.balance.total, d("1050"));
5983 assert_eq!(state.quote.balance.net_asset(), d("750"));
5984
5985 assert_eq!(state.risk.margin_level, Some(d("3.5")));
5986 assert_eq!(state.risk.margin_ratio, Some(d("0.12")));
5987 assert_eq!(state.risk.liquidation_price, Some(d("48000")));
5988 }
5989
5990 #[test]
5991 fn isolated_base_short_is_negative_net() {
5992 let entry = iso_entry(
5994 "BTCUSDT",
5995 iso_base("BTC", "0", "0", "1.5", "0.001"),
5996 iso_quote("USDT", "100", "0", "0", "0"),
5997 None,
5998 None,
5999 None,
6000 );
6001 let map = convert_isolated_margin_assets(vec![entry]);
6002 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
6003 assert_eq!(state.base.balance.net_asset(), d("-1.5"));
6004 }
6005
6006 #[test]
6007 fn isolated_missing_debt_defaults_zero_and_risk_optional() {
6008 let mut base = IsoBase::new();
6011 base.asset = Some("BTC".to_string());
6012 base.free = Some("0.5".to_string());
6013 base.locked = Some("0".to_string());
6014 let mut quote = IsoQuote::new();
6015 quote.asset = Some("USDT".to_string());
6016 quote.free = Some("100".to_string());
6017 quote.locked = Some("0".to_string());
6018
6019 let map = convert_isolated_margin_assets(vec![iso_entry(
6020 "BTCUSDT", base, quote, None, None, None,
6021 )]);
6022 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
6023 assert!(state.base.balance.margin.is_some());
6024 assert_eq!(state.base.balance.net_asset(), d("0.5"));
6025 assert_eq!(state.risk.margin_level, None);
6026 assert_eq!(state.risk.liquidation_price, None);
6027 }
6028
6029 #[test]
6030 fn isolated_unparseable_risk_is_none_but_entry_kept() {
6031 let entry = iso_entry(
6032 "BTCUSDT",
6033 iso_base("BTC", "1", "0", "0", "0"),
6034 iso_quote("USDT", "1", "0", "0", "0"),
6035 Some("not_a_number"),
6036 None,
6037 None,
6038 );
6039 let map = convert_isolated_margin_assets(vec![entry]);
6040 let state = map.get(&InstrumentNameExchange::new("BTCUSDT")).unwrap();
6041 assert_eq!(state.risk.margin_level, None);
6042 }
6043
6044 #[test]
6045 fn isolated_missing_base_free_drops_entry() {
6046 let mut base = IsoBase::new();
6048 base.asset = Some("BTC".to_string());
6049 base.locked = Some("0".to_string()); let entry = iso_entry(
6051 "BTCUSDT",
6052 base,
6053 iso_quote("USDT", "100", "0", "0", "0"),
6054 None,
6055 None,
6056 None,
6057 );
6058 assert!(convert_isolated_margin_assets(vec![entry]).is_empty());
6059 }
6060
6061 #[test]
6062 fn isolated_missing_symbol_drops_entry() {
6063 let mut e = QueryIsolatedMarginAccountInfoResponseAssetsInner::new();
6064 e.base_asset = Some(Box::new(iso_base("BTC", "1", "0", "0", "0")));
6065 e.quote_asset = Some(Box::new(iso_quote("USDT", "1", "0", "0", "0")));
6066 assert!(convert_isolated_margin_assets(vec![e]).is_empty());
6068 }
6069
6070 #[test]
6071 fn chunk_symbols_batches_by_five() {
6072 let syms: Vec<InstrumentNameExchange> = (0..12)
6073 .map(|i| InstrumentNameExchange::new(format!("S{i:02}")))
6074 .collect();
6075 let chunks = chunk_symbols(&syms);
6076 assert_eq!(chunks.len(), 3, "12 symbols → 5 + 5 + 2");
6077 assert_eq!(chunks[0].split(',').count(), 5);
6078 assert_eq!(chunks[1].split(',').count(), 5);
6079 assert_eq!(chunks[2].split(',').count(), 2);
6080
6081 let three: Vec<_> = (0..3)
6083 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
6084 .collect();
6085 assert_eq!(chunk_symbols(&three).len(), 1);
6086
6087 let five: Vec<_> = (0..5)
6089 .map(|i| InstrumentNameExchange::new(format!("S{i}")))
6090 .collect();
6091 let c = chunk_symbols(&five);
6092 assert_eq!(c.len(), 1);
6093 assert_eq!(c[0].split(',').count(), 5);
6094
6095 assert!(chunk_symbols(&[]).is_empty());
6097 }
6098
6099 #[test]
6100 fn effective_isolated_set_empty_returns_all_configured() {
6101 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
6102 assert_eq!(
6103 client.effective_isolated_set(&[]),
6104 vec![
6105 InstrumentNameExchange::new("BTCUSDT"),
6106 InstrumentNameExchange::new("ETHUSDT"),
6107 ]
6108 );
6109 }
6110
6111 #[test]
6112 fn effective_isolated_set_intersects_requested() {
6113 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
6114 assert_eq!(
6115 client.effective_isolated_set(&[InstrumentNameExchange::new("ETHUSDT")]),
6116 vec![InstrumentNameExchange::new("ETHUSDT")]
6117 );
6118 }
6119
6120 #[test]
6121 fn effective_isolated_set_skips_out_of_set() {
6122 let client = isolated_client(&["BTCUSDT", "ETHUSDT"]);
6123 assert_eq!(
6125 client.effective_isolated_set(&[
6126 InstrumentNameExchange::new("BTCUSDT"),
6127 InstrumentNameExchange::new("DOGEUSDT"),
6128 ]),
6129 vec![InstrumentNameExchange::new("BTCUSDT")]
6130 );
6131 }
6132
6133 #[tokio::test]
6134 async fn fetch_balances_isolated_returns_empty() {
6135 let client = isolated_client(&["BTCUSDT"]);
6138 assert!(client.fetch_balances(&[]).await.expect("ok").is_empty());
6139 }
6140
6141 fn open_order(order_id: Option<i64>, side: &str) -> QueryMarginAccountsOpenOrdersResponseInner {
6142 let mut o = QueryMarginAccountsOpenOrdersResponseInner::new();
6143 o.order_id = order_id;
6144 o.client_order_id = Some("cid-9".to_string());
6145 o.side = Some(side.to_string());
6146 o.price = Some("50000".to_string());
6147 o.orig_qty = Some("2".to_string());
6148 o.executed_qty = Some("0.5".to_string());
6149 o.r#type = Some("LIMIT".to_string());
6150 o.time_in_force = Some("GTC".to_string());
6151 o.time = Some(1_700_000_000_000);
6152 o.status = Some("PARTIALLY_FILLED".to_string());
6153 o
6154 }
6155
6156 #[test]
6157 fn margin_open_order_converts() {
6158 let inst = InstrumentNameExchange::new("BTCUSDT");
6159 let order = convert_open_order(
6160 &open_order(Some(42), "BUY"),
6161 ExchangeId::BinanceMargin,
6162 &inst,
6163 )
6164 .expect("convert");
6165 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
6166 assert_eq!(
6167 order.state.id.assigned().map(|id| id.0.as_str()),
6168 Some("42")
6169 );
6170 assert_eq!(order.key.cid.0.as_str(), "cid-9");
6171 assert_eq!(order.side, Side::Buy);
6172 assert_eq!(order.price, Some(Decimal::from(50_000)));
6173 assert_eq!(order.quantity, Decimal::from(2));
6174 assert_eq!(order.state.filled_quantity, Decimal::new(5, 1));
6175 assert_eq!(order.kind, OrderKind::Limit);
6176 }
6177
6178 #[test]
6181 fn margin_open_order_that_is_not_live_is_dropped() {
6182 let inst = InstrumentNameExchange::new("BTCUSDT");
6183 let mut o = open_order(Some(42), "BUY");
6184 o.status = Some("CANCELED".to_string());
6185 assert!(convert_open_order(&o, ExchangeId::BinanceMargin, &inst).is_none());
6186 }
6187
6188 #[test]
6189 fn margin_open_order_missing_order_id_is_dropped() {
6190 let inst = InstrumentNameExchange::new("BTCUSDT");
6191 assert!(
6192 convert_open_order(&open_order(None, "BUY"), ExchangeId::BinanceMargin, &inst)
6193 .is_none()
6194 );
6195 }
6196
6197 #[test]
6203 fn margin_open_order_prefers_update_time_over_creation_time() {
6204 let inst = InstrumentNameExchange::new("BTCUSDT");
6205
6206 let mut updated = open_order(Some(42), "BUY");
6207 updated.update_time = Some(1_700_000_005_000);
6208 let order =
6209 convert_open_order(&updated, ExchangeId::BinanceMargin, &inst).expect("convert");
6210 assert_eq!(
6211 order.state.time_exchange,
6212 Utc.timestamp_millis_opt(1_700_000_005_000)
6213 .single()
6214 .unwrap(),
6215 "update_time must win over time"
6216 );
6217 }
6218
6219 #[test]
6222 fn margin_open_order_falls_back_to_creation_time_when_update_time_absent() {
6223 let inst = InstrumentNameExchange::new("BTCUSDT");
6224
6225 let base = open_order(Some(42), "BUY");
6226 assert!(base.update_time.is_none(), "fixture must omit update_time");
6227 let order = convert_open_order(&base, ExchangeId::BinanceMargin, &inst).expect("convert");
6228 assert_eq!(
6229 order.state.time_exchange,
6230 Utc.timestamp_millis_opt(1_700_000_000_000)
6231 .single()
6232 .unwrap(),
6233 "absent update_time falls back to time"
6234 );
6235 }
6236
6237 #[test]
6238 fn margin_open_order_owned_symbol_recovers_instrument() {
6239 let mut o = open_order(Some(42), "SELL");
6241 o.symbol = Some("ETHUSDT".to_string());
6242 let order =
6243 convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).expect("convert");
6244 assert_eq!(order.key.instrument.name().as_str(), "ETHUSDT");
6245 assert_eq!(order.key.exchange, ExchangeId::BinanceMargin);
6246 assert_eq!(order.side, Side::Sell);
6247 }
6248
6249 #[test]
6250 fn margin_open_order_owned_symbol_missing_symbol_is_dropped() {
6251 let o = open_order(Some(42), "BUY");
6253 assert!(o.symbol.is_none());
6254 assert!(convert_open_order_owned_symbol(&o, ExchangeId::BinanceMargin).is_none());
6255 }
6256
6257 fn trade(id: Option<i64>, is_buyer: Option<bool>) -> QueryMarginAccountsTradeListResponseInner {
6258 let mut t = QueryMarginAccountsTradeListResponseInner::new();
6259 t.id = id;
6260 t.order_id = Some(7);
6261 t.is_buyer = is_buyer;
6262 t.price = Some("48000".to_string());
6263 t.qty = Some("0.25".to_string());
6264 t.commission = Some("0.001".to_string());
6265 t.commission_asset = Some("BNB".to_string());
6266 t.time = Some(1_700_000_000_000);
6267 t
6268 }
6269
6270 #[test]
6271 fn margin_trade_converts() {
6272 let inst = InstrumentNameExchange::new("BTCUSDT");
6273 let tr = convert_margin_trade(&trade(Some(11), Some(false)), &inst).expect("convert");
6274 assert_eq!(tr.id.0.as_str(), "11");
6275 assert_eq!(tr.order_id.0.as_str(), "7");
6276 assert_eq!(tr.side, Side::Sell);
6277 assert_eq!(tr.price, Decimal::from(48_000));
6278 assert_eq!(tr.quantity, Decimal::new(25, 2));
6279 assert_eq!(tr.fees.asset.name().as_str(), "BNB");
6281 assert_eq!(tr.fees.fees, Decimal::new(1, 3));
6282 }
6283
6284 #[test]
6285 fn margin_trade_missing_fields_are_dropped() {
6286 let inst = InstrumentNameExchange::new("BTCUSDT");
6287 assert!(convert_margin_trade(&trade(None, Some(true)), &inst).is_none());
6289 assert!(convert_margin_trade(&trade(Some(11), None), &inst).is_none());
6290 }
6291
6292 #[test]
6295 fn token_renew_after_applies_margin_and_floor() {
6296 let now_ms = Utc::now().timestamp_millis();
6297 let far = token_renew_after(now_ms + 86_400_000).as_secs();
6299 assert!(
6300 far > 80_000 && far <= 86_400,
6301 "expected ~24h-minus-margin, got {far}"
6302 );
6303 assert_eq!(
6305 token_renew_after(now_ms - 10_000).as_secs(),
6306 TOKEN_MIN_LIFETIME_SECS
6307 );
6308 assert_eq!(
6310 token_renew_after(now_ms + 60_000).as_secs(),
6311 TOKEN_MIN_LIFETIME_SECS
6312 );
6313 }
6314
6315 fn push(event: serde_json::Value) -> String {
6320 serde_json::json!({ "subscriptionId": 1, "event": event }).to_string()
6321 }
6322
6323 fn cross_outcome(frame: &str, buf: &mut Vec<UnindexedAccountEvent>) -> FrameOutcome {
6324 convert_margin_user_data_events_with(
6325 frame,
6326 buf,
6327 &mut cross_account_position_handler,
6328 &mut cross_notice_instrument,
6329 )
6330 }
6331
6332 #[test]
6335 fn margin_ws_rpc_ack_yields_no_events() {
6336 let frame = serde_json::json!({ "id": "abc", "status": 200, "result": {} }).to_string();
6337 let mut buf = Vec::new();
6338 assert_eq!(cross_outcome(&frame, &mut buf), FrameOutcome::Response);
6339 assert!(buf.is_empty());
6340 }
6341
6342 #[test]
6345 fn margin_ws_unrecognised_frames_yield_no_events() {
6346 for frame in [
6347 r#"{"e":"eventStreamTerminated","E":1}"#,
6348 r#"{"subscriptionId":1,"event":{"E":1}}"#,
6349 ] {
6350 let mut buf = Vec::new();
6351 assert_eq!(
6352 cross_outcome(frame, &mut buf),
6353 FrameOutcome::Pushed { terminated: false },
6354 "{frame}"
6355 );
6356 assert!(buf.is_empty(), "{frame}");
6357 }
6358 }
6359
6360 #[test]
6363 fn margin_ws_trailing_stop_report_decodes_as_a_trailing_stop() {
6364 for trailing_delta in [serde_json::json!(25), serde_json::json!("25")] {
6365 let frame = push(serde_json::json!({
6366 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "STOP_LOSS",
6367 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "cid-1", "t": -1_i64,
6368 "p": "0.00000000", "P": "0.00000000", "d": trailing_delta, "D": 1_700_000_000_000_i64,
6369 "q": "2", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
6370 }));
6371 let mut buf = Vec::new();
6372 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6373 let [event] = buf.as_slice() else {
6374 panic!("one snapshot expected for {frame}, got {buf:?}");
6375 };
6376 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
6377 panic!("expected an order snapshot, got {event:?}");
6378 };
6379 assert_eq!(
6380 snapshot.0.kind,
6381 OrderKind::TrailingStop {
6382 offset: Decimal::from(25),
6383 offset_type: TrailingOffsetType::BasisPoints,
6384 activation_price: None,
6385 }
6386 );
6387 assert_eq!(snapshot.0.price, None);
6388 }
6389 }
6390
6391 #[test]
6399 fn margin_ws_execution_report_trade_maps_to_trade_and_order_snapshot() {
6400 let frame = push(serde_json::json!({
6401 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6402 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "cid-1",
6403 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
6404 "p": "48000", "q": "2", "f": "GTC",
6405 "n": "0.001", "N": "BNB", "T": 1_700_000_000_000_i64,
6406 }));
6407 let mut buf = Vec::new();
6408 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6409 assert_eq!(
6410 buf.len(),
6411 2,
6412 "a fill must produce the execution and the order snapshot, got: {buf:?}"
6413 );
6414 assert_eq!(buf[0].exchange, ExchangeId::BinanceMargin);
6415 match &buf[0].kind {
6416 AccountEventKind::Trade(t) => {
6417 assert_eq!(t.instrument.name().as_str(), "BTCUSDT");
6418 assert_eq!(t.side, Side::Buy);
6419 assert_eq!(t.price, Decimal::from(48_000));
6420 assert_eq!(t.quantity, Decimal::new(5, 1));
6421 assert_eq!(t.fees.asset.name().as_str(), "BNB"); assert_eq!(t.fees.fees, Decimal::new(1, 3));
6423 assert_eq!(t.client_order_id, Some(ClientOrderId::new("cid-1")));
6424 }
6425 other => panic!("expected Trade, got {other:?}"),
6426 }
6427 assert_eq!(buf[1].exchange, ExchangeId::BinanceMargin);
6428 match &buf[1].kind {
6429 AccountEventKind::OrderSnapshot(snap) => {
6430 assert_eq!(
6431 snap.0.quantity,
6432 Decimal::from(2),
6433 "the order's total quantity"
6434 );
6435 match &snap.0.state {
6436 OrderState::Active(ActiveOrderState::Open(open)) => {
6437 assert_eq!(open.id.assigned().map(|id| id.0.as_str()), Some("12345"));
6438 assert_eq!(
6439 open.filled_quantity,
6440 Decimal::new(5, 1),
6441 "the snapshot must carry the order's cumulative filled quantity (z)"
6442 );
6443 assert_eq!(
6444 open.time_exchange.timestamp_millis(),
6445 1_700_000_000_000,
6446 "stamped with the execution's transaction time (T), which is what \
6447 gives the engine's staleness gate an ordering key"
6448 );
6449 }
6450 other => panic!("expected an Open order, got {other:?}"),
6451 }
6452 }
6453 other => panic!("expected OrderSnapshot, got {other:?}"),
6454 }
6455 }
6456
6457 #[test]
6461 fn margin_trade_for_an_order_no_longer_working_emits_no_snapshot() {
6462 for status in ["CANCELED", "EXPIRED", "REJECTED", "PENDING_CANCEL", "NEW"] {
6463 let frame = push(serde_json::json!({
6464 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6465 "x": "TRADE", "X": status, "i": 12_345_i64, "c": "cid-1",
6466 "t": 99_i64, "l": "0.5", "L": "48000", "z": "0.5",
6467 "p": "48000", "q": "2", "f": "GTC", "T": 1_700_000_000_000_i64,
6468 }));
6469 let mut buf = Vec::new();
6470 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6471 assert_eq!(
6472 buf.len(),
6473 1,
6474 "status {status} must produce the execution alone, got: {buf:?}"
6475 );
6476 assert!(
6477 matches!(buf[0].kind, AccountEventKind::Trade(_)),
6478 "the execution must still be reported for status {status}"
6479 );
6480 }
6481 }
6482
6483 #[test]
6484 fn margin_ws_new_report_maps_to_active_order_snapshot() {
6485 let frame = push(serde_json::json!({
6486 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "LIMIT",
6487 "x": "NEW", "X": "NEW", "i": 7_i64, "c": "cid-2",
6488 "p": "48000", "q": "1", "f": "GTC", "z": "0", "T": 1_700_000_000_000_i64,
6489 }));
6490 let mut buf = Vec::new();
6491 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6492 assert_eq!(buf.len(), 1);
6493 match &buf[0].kind {
6494 AccountEventKind::OrderSnapshot(snap) => {
6495 assert_eq!(snap.0.side, Side::Sell);
6496 assert_eq!(snap.0.price, Some(Decimal::from(48_000)));
6497 assert_eq!(snap.0.quantity, Decimal::from(1));
6498 assert!(
6499 matches!(snap.0.state, OrderState::Active(_)),
6500 "NEW should be an active (resting) order"
6501 );
6502 }
6503 other => panic!("expected OrderSnapshot, got {other:?}"),
6504 }
6505 }
6506
6507 #[test]
6508 fn margin_ws_canceled_report_maps_to_order_cancelled() {
6509 let frame = push(serde_json::json!({
6510 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6511 "x": "CANCELED", "X": "CANCELED", "i": 8_i64, "c": "cid-3",
6512 "z": "0", "T": 1_700_000_000_000_i64,
6513 }));
6514 let mut buf = Vec::new();
6515 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6516 assert_eq!(buf.len(), 1);
6517 match &buf[0].kind {
6518 AccountEventKind::OrderCancelled(resp) => assert!(resp.state.is_ok()),
6519 other => panic!("expected OrderCancelled, got {other:?}"),
6520 }
6521 }
6522
6523 #[test]
6526 fn margin_ws_trade_without_a_client_order_id_reports_none() {
6527 let frame = push(serde_json::json!({
6528 "e": "executionReport", "s": "BTCUSDT", "S": "SELL", "o": "MARKET",
6529 "x": "TRADE", "X": "FILLED", "i": 12_345_i64,
6530 "t": 99_i64, "l": "1", "L": "48000", "z": "1",
6531 "q": "1", "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
6532 }));
6533 let mut buf = Vec::new();
6534 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6535 let AccountEventKind::Trade(trade) = &buf[0].kind else {
6536 panic!("expected Trade, got {:?}", buf[0].kind);
6537 };
6538 assert_eq!(trade.order_id.0.as_str(), "12345");
6539 assert_eq!(trade.client_order_id, None);
6540 }
6541
6542 #[test]
6543 fn margin_ws_execution_report_missing_symbol_is_dropped() {
6544 let frame = push(serde_json::json!({
6546 "e": "executionReport", "S": "BUY", "x": "TRADE", "i": 1_i64, "t": 1_i64,
6547 "l": "1", "L": "100", "T": 1_700_000_000_000_i64,
6548 }));
6549 let mut buf = Vec::new();
6550 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6551 assert!(buf.is_empty());
6552 }
6553
6554 #[test]
6555 fn margin_ws_outbound_account_position_maps_to_balance_stream_updates() {
6556 let frame = push(serde_json::json!({
6557 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
6558 "B": [
6559 { "a": "USDT", "f": "100.0", "l": "5.0" },
6560 { "a": "BTC", "f": "0.5", "l": "0" },
6561 ],
6562 }));
6563 let mut buf = Vec::new();
6564 assert!(!convert_margin_user_data_events(&frame, &mut buf));
6565 assert_eq!(buf.len(), 2);
6566 for ev in &buf {
6568 assert!(matches!(ev.kind, AccountEventKind::BalanceStreamUpdate(_)));
6569 }
6570 }
6571
6572 #[test]
6573 fn margin_ws_stream_terminated_signals_reconnect() {
6574 let frame = push(serde_json::json!({ "e": "eventStreamTerminated" }));
6575 let mut buf = Vec::new();
6576 assert!(
6577 convert_margin_user_data_events(&frame, &mut buf),
6578 "eventStreamTerminated must signal reconnect"
6579 );
6580 assert!(buf.is_empty());
6581 }
6582
6583 #[test]
6584 fn margin_ws_liability_change_is_observable_only() {
6585 let mut buf = Vec::new();
6588 assert!(!convert_margin_user_data_events(
6589 &push(documented_liability_change("USER_LIABILITY_CHANGE")),
6590 &mut buf
6591 ));
6592 assert!(buf.is_empty());
6593 }
6594
6595 fn only_notice(buf: &[UnindexedAccountEvent]) -> &AccountNotice<InstrumentNameExchange> {
6597 match buf {
6598 [
6599 UnindexedAccountEvent {
6600 exchange: ExchangeId::BinanceMargin,
6601 kind: AccountEventKind::Notice(notice),
6602 },
6603 ] => notice,
6604 other => panic!("expected one BinanceMargin notice, got {other:?}"),
6605 }
6606 }
6607
6608 #[test]
6609 fn margin_ws_margin_level_change_is_forwarded_as_a_notice() {
6610 let mut buf = Vec::new();
6611 assert!(!convert_margin_user_data_events(
6612 &push(documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE")),
6613 &mut buf
6614 ));
6615 assert_eq!(
6616 only_notice(&buf),
6617 &AccountNotice::new(
6618 NoticeKind::MarginCall,
6619 None,
6620 Utc.timestamp_millis_opt(1_573_200_697_110).unwrap(),
6621 Some(Decimal::new(125, 2)),
6622 SmolStr::new("MARGIN_CALL"),
6623 ),
6624 "cross names no instrument"
6625 );
6626 }
6627
6628 #[test]
6629 fn margin_level_statuses_map_to_notice_kinds() {
6630 for (status, kind) in [
6631 ("MARGIN_CALL", NoticeKind::MarginCall),
6632 ("PRE_LIQUIDATION", NoticeKind::LiquidationWarning),
6633 ("FORCE_LIQUIDATION", NoticeKind::Liquidation),
6634 ("NORMAL", NoticeKind::MarginRestored),
6635 ("EXCESSIVE", NoticeKind::MarginRestored),
6636 ("SOME_FUTURE_STATUS", NoticeKind::Other),
6637 ] {
6638 let mut buf = Vec::new();
6639 let frame = push(serde_json::json!({
6640 "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": 1_700_000_000_000_i64, "l": "1.5",
6641 "s": status,
6642 }));
6643 convert_margin_user_data_events(&frame, &mut buf);
6644 let notice = only_notice(&buf);
6645 assert_eq!(notice.kind, kind, "{status}");
6646 assert_eq!(notice.status, status, "the raw status is kept");
6647 }
6648 }
6649
6650 #[test]
6651 fn a_margin_level_change_missing_parts_is_still_delivered_where_it_can_be() {
6652 let mut buf = Vec::new();
6654 convert_margin_user_data_events(
6655 &push(serde_json::json!({
6656 "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": 1_700_000_000_000_i64, "l": "n/a",
6657 "s": "FORCE_LIQUIDATION",
6658 })),
6659 &mut buf,
6660 );
6661 let notice = only_notice(&buf);
6662 assert_eq!(notice.kind, NoticeKind::Liquidation);
6663 assert_eq!(notice.margin_level, None);
6664
6665 let before = Utc::now();
6667 let mut buf = Vec::new();
6668 convert_margin_user_data_events(
6669 &push(serde_json::json!({ "e": "MARGIN_LEVEL_STATUS_CHANGE", "s": "MARGIN_CALL" })),
6670 &mut buf,
6671 );
6672 assert!(only_notice(&buf).time_exchange >= before);
6673
6674 let mut buf = Vec::new();
6676 convert_margin_user_data_events(
6677 &push(serde_json::json!({
6678 "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": 1_700_000_000_000_i64, "l": "1.5",
6679 })),
6680 &mut buf,
6681 );
6682 assert!(buf.is_empty());
6683 }
6684
6685 fn documented_liability_change(e: &str) -> serde_json::Value {
6687 serde_json::json!({
6688 "e": e, "E": 1_573_200_697_110_i64, "a": "BTC", "t": "BORROW",
6689 "p": "1.03453430", "i": "0",
6690 })
6691 }
6692
6693 fn documented_margin_level_change(e: &str) -> serde_json::Value {
6695 serde_json::json!({
6696 "e": e, "E": 1_573_200_697_110_i64, "l": "1.25", "s": "MARGIN_CALL",
6697 })
6698 }
6699
6700 #[test]
6701 fn margin_ws_margin_specific_events_match_their_documented_names() {
6702 for (e, frame, expected) in [
6707 (
6708 "USER_LIABILITY_CHANGE",
6709 documented_liability_change("USER_LIABILITY_CHANGE"),
6710 "USER_LIABILITY_CHANGE (observable",
6711 ),
6712 (
6713 "MARGIN_LEVEL_STATUS_CHANGE",
6714 documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE"),
6715 "MARGIN_LEVEL_STATUS_CHANGE received",
6716 ),
6717 (
6719 "userLiabilityChange",
6720 documented_liability_change("userLiabilityChange"),
6721 "USER_LIABILITY_CHANGE (observable",
6722 ),
6723 (
6724 "marginLevelStatusChange",
6725 documented_margin_level_change("marginLevelStatusChange"),
6726 "MARGIN_LEVEL_STATUS_CHANGE received",
6727 ),
6728 ] {
6729 let mut buf = Vec::new();
6730 let (terminated, messages) =
6731 log_messages(|| convert_margin_user_data_events(&push(frame), &mut buf));
6732 assert!(!terminated, "{e}");
6733 assert_eq!(
6735 buf.len(),
6736 usize::from(expected.starts_with("MARGIN")),
6737 "{e}"
6738 );
6739 assert!(
6740 messages.iter().any(|m| m.contains(expected)),
6741 "{e}: expected its own arm's log, got {messages:?}"
6742 );
6743 assert!(
6744 !messages.iter().any(|m| m.contains("undeserializable")
6745 || m.contains("unhandled")
6746 || m.contains("does not handle")),
6747 "{e}: {messages:?}"
6748 );
6749 }
6750 }
6751
6752 fn log_messages<T>(f: impl FnOnce() -> T) -> (T, Vec<String>) {
6756 use std::sync::{Arc, Mutex};
6757 use tracing_subscriber::layer::SubscriberExt;
6758
6759 #[derive(Default)]
6760 struct Messages(Arc<Mutex<Vec<String>>>);
6761
6762 struct Message<'a>(&'a mut String);
6763
6764 impl tracing::field::Visit for Message<'_> {
6765 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
6766 if field.name() == "message" {
6767 *self.0 = format!("{value:?}");
6768 }
6769 }
6770 }
6771
6772 impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for Messages {
6773 fn on_event(
6774 &self,
6775 event: &tracing::Event<'_>,
6776 _: tracing_subscriber::layer::Context<'_, S>,
6777 ) {
6778 let mut message = String::new();
6779 event.record(&mut Message(&mut message));
6780 self.0.lock().unwrap().push(message);
6781 }
6782 }
6783
6784 let layer = Messages::default();
6785 let messages = Arc::clone(&layer.0);
6786 let value =
6788 tracing::subscriber::with_default(tracing_subscriber::registry().with(layer), f);
6789 let messages = messages.lock().unwrap().clone();
6790 (value, messages)
6791 }
6792
6793 #[test]
6794 fn margin_ws_unhandled_event_types_are_ignored_without_teardown() {
6795 for (e, expected) in [
6798 ("listStatus", "ignoring listStatus"),
6799 ("someFutureEvent", "of a type this client does not handle"),
6801 ] {
6802 let mut buf = Vec::new();
6803 let frame = push(serde_json::json!({ "e": e, "E": 1_700_000_000_000_i64 }));
6804 let (terminated, messages) =
6805 log_messages(|| convert_margin_user_data_events(&frame, &mut buf));
6806 assert!(!terminated, "{e}");
6807 assert!(buf.is_empty(), "{e}");
6808 assert!(
6809 messages.iter().any(|m| m.contains(expected)),
6810 "{e}: {messages:?}"
6811 );
6812 }
6813 }
6814
6815 #[test]
6818 fn margin_trade_event_dedup_by_key() {
6819 let frame = push(serde_json::json!({
6822 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6823 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 4_242_i64,
6824 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
6825 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
6826 }));
6827 let mut buf = Vec::new();
6828 convert_margin_user_data_events(&frame, &mut buf);
6829 let event = buf.first().expect("a Trade event");
6832 assert!(
6833 matches!(event.kind, AccountEventKind::Trade(_)),
6834 "the execution is the first of the two events a fill produces"
6835 );
6836
6837 let cache = new_dedup_cache();
6838 let first = dedup_key_from_event(event).expect("Trade events have a dedup key");
6840 assert!(!is_duplicate(&cache, first), "first sighting is fresh");
6841 let second = dedup_key_from_event(event).expect("Trade events have a dedup key");
6842 assert!(
6843 is_duplicate(&cache, second),
6844 "second sighting is a duplicate"
6845 );
6846 }
6847
6848 fn drive_ws_pipeline(frames: &[&str]) -> Vec<UnindexedAccountEvent> {
6856 let cache = new_dedup_cache();
6857 let mut delivered = Vec::new();
6858 let mut buf = Vec::new();
6859 for frame in frames {
6860 let _ = convert_margin_user_data_events(frame, &mut buf);
6861 for ev in buf.drain(..) {
6862 if let Some(key) = dedup_key_from_event(&ev)
6863 && is_duplicate(&cache, key)
6864 {
6865 continue;
6866 }
6867 delivered.push(ev);
6868 }
6869 }
6870 delivered
6871 }
6872
6873 fn ws_new(cumulative_filled: &str) -> String {
6874 push(serde_json::json!({
6875 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6876 "x": "NEW", "X": "NEW", "i": 12_345_i64, "c": "client-1",
6877 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
6878 "T": 1_700_000_000_000_i64,
6879 }))
6880 }
6881
6882 fn ws_partial_fill(cumulative_filled: &str) -> String {
6883 push(serde_json::json!({
6884 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
6885 "x": "TRADE", "X": "PARTIALLY_FILLED", "i": 12_345_i64, "c": "client-1",
6886 "q": "2", "p": "100", "f": "GTC", "z": cumulative_filled,
6887 "l": "1", "L": "100", "t": 555_i64, "n": "0.1", "N": "USDT",
6888 "T": 1_700_000_001_000_i64,
6889 }))
6890 }
6891
6892 fn open_filled_quantities(events: &[UnindexedAccountEvent]) -> Vec<Decimal> {
6893 events
6894 .iter()
6895 .filter_map(|ev| match &ev.kind {
6896 AccountEventKind::OrderSnapshot(snap) => match &snap.0.state {
6897 OrderState::Active(ActiveOrderState::Open(open)) => Some(open.filled_quantity),
6898 _ => None,
6899 },
6900 _ => None,
6901 })
6902 .collect()
6903 }
6904
6905 #[test]
6913 fn margin_ws_fill_snapshot_survives_the_dedup_gate() {
6914 let delivered = drive_ws_pipeline(&[&ws_new("0"), &ws_partial_fill("1")]);
6915
6916 assert_eq!(
6917 delivered.len(),
6918 3,
6919 "expected the ack snapshot, then the fill's execution and its snapshot, got: {delivered:?}"
6920 );
6921 assert_eq!(
6922 open_filled_quantities(&delivered),
6923 vec![Decimal::ZERO, Decimal::ONE],
6924 "the fill's snapshot must reach the consumer carrying the advanced filled quantity"
6925 );
6926 }
6927
6928 #[test]
6931 fn margin_ws_replayed_frame_is_still_deduplicated() {
6932 let delivered = drive_ws_pipeline(&[
6933 &ws_new("0"),
6934 &ws_partial_fill("1"),
6935 &ws_new("0"),
6936 &ws_partial_fill("1"),
6937 ]);
6938
6939 assert_eq!(
6940 delivered.len(),
6941 3,
6942 "re-delivering both frames must add nothing, got: {delivered:?}"
6943 );
6944 }
6945
6946 #[test]
6952 fn margin_ws_an_ack_and_its_fill_reporting_one_state_deliver_that_state_once() {
6953 let delivered = drive_ws_pipeline(&[&ws_new("1"), &ws_partial_fill("1")]);
6954
6955 assert_eq!(
6956 delivered.len(),
6957 2,
6958 "the ack snapshot and the execution, with the fill's duplicate snapshot collapsed, \
6959 got: {delivered:?}"
6960 );
6961 assert!(
6962 delivered
6963 .iter()
6964 .any(|ev| matches!(ev.kind, AccountEventKind::Trade(_))),
6965 "the execution must reach the consumer regardless"
6966 );
6967 assert_eq!(
6968 open_filled_quantities(&delivered),
6969 vec![Decimal::ONE],
6970 "one snapshot, carrying the state both reports agree on"
6971 );
6972 }
6973
6974 fn push_with_sub(subscription_id: i64, event: serde_json::Value) -> String {
6979 serde_json::json!({ "subscriptionId": subscription_id, "event": event }).to_string()
6980 }
6981
6982 fn isolated_handler(
6985 sub_map: Arc<Mutex<HashMap<i64, InstrumentNameExchange>>>,
6986 base_quote: Arc<HashMap<InstrumentNameExchange, (AssetNameExchange, AssetNameExchange)>>,
6987 ) -> impl FnMut(OutboundAccountPosition, Option<i64>, &mut Vec<UnindexedAccountEvent>) {
6988 move |position, subscription_id, buf| {
6989 route_isolated_account_position(position, subscription_id, &sub_map, &base_quote, buf);
6990 }
6991 }
6992
6993 fn btcusdt() -> InstrumentNameExchange {
6994 InstrumentNameExchange::new("BTCUSDT")
6995 }
6996
6997 fn isolated_balances(buf: &[UnindexedAccountEvent]) -> Vec<(&str, &str, Decimal, Decimal)> {
7000 buf.iter()
7001 .map(|event| match &event.kind {
7002 AccountEventKind::InstrumentBalanceUpdate(ibu) => (
7003 ibu.instrument.name().as_str(),
7004 ibu.balance.asset.name().as_str(),
7005 ibu.balance.update.free,
7006 ibu.balance.update.locked,
7007 ),
7008 other => panic!("expected InstrumentBalanceUpdate, got {other:?}"),
7009 })
7010 .collect()
7011 }
7012
7013 fn token(expiration_time_ms: i64) -> UserListenToken {
7014 UserListenToken {
7015 token: "t".to_string(),
7016 expiration_time_ms,
7017 }
7018 }
7019
7020 #[test]
7021 fn listen_token_query_cross_vs_isolated() {
7022 assert!(build_listen_token_query(None).is_empty());
7024
7025 let q = build_listen_token_query(Some(&btcusdt()));
7027 assert_eq!(q.get("isIsolated").and_then(|v| v.as_str()), Some("TRUE"));
7028 assert_eq!(q.get("symbol").and_then(|v| v.as_str()), Some("BTCUSDT"));
7029 assert_eq!(q.len(), 2);
7030 }
7031
7032 #[test]
7033 fn earliest_token_expiry_picks_min() {
7034 let tokens = vec![
7035 (btcusdt(), token(3_000)),
7036 (InstrumentNameExchange::new("ETHUSDT"), token(1_000)),
7037 (InstrumentNameExchange::new("BNBUSDT"), token(2_000)),
7038 ];
7039 assert_eq!(earliest_token_expiry_ms(&tokens), 1_000);
7040 assert_eq!(earliest_token_expiry_ms(&[]), i64::MAX);
7042 }
7043
7044 #[test]
7045 fn base_quote_map_extracts_base_and_quote() {
7046 let entries = vec![
7047 iso_entry(
7048 "BTCUSDT",
7049 iso_base("BTC", "0", "0", "0", "0"),
7050 iso_quote("USDT", "0", "0", "0", "0"),
7051 None,
7052 None,
7053 None,
7054 ),
7055 iso_entry(
7056 "ETHBTC",
7057 iso_base("ETH", "0", "0", "0", "0"),
7058 iso_quote("BTC", "0", "0", "0", "0"),
7059 None,
7060 None,
7061 None,
7062 ),
7063 ];
7064 let map = build_base_quote_map(&entries);
7065 assert_eq!(
7066 map.get(&btcusdt())
7067 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
7068 Some(("BTC", "USDT"))
7069 );
7070 assert_eq!(
7073 map.get(&InstrumentNameExchange::new("ETHBTC"))
7074 .map(|(b, q)| (b.name().as_str(), q.name().as_str())),
7075 Some(("ETH", "BTC"))
7076 );
7077 }
7078
7079 #[test]
7080 fn isolated_margin_level_change_names_its_subscriptions_pair_or_none() {
7081 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7082 let notice_on = |subscription_id: i64| {
7083 let mut buf = Vec::new();
7084 let frame = push_with_sub(
7085 subscription_id,
7086 documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE"),
7087 );
7088 assert_eq!(
7089 convert_margin_user_data_events_with(
7090 &frame,
7091 &mut buf,
7092 &mut cross_account_position_handler,
7093 &mut |id| isolated_notice_instrument(id, &sub_map),
7094 ),
7095 FrameOutcome::Pushed { terminated: false }
7096 );
7097 only_notice(&buf).instrument.clone()
7098 };
7099
7100 assert_eq!(notice_on(7), Some(btcusdt()));
7101 assert_eq!(notice_on(99), None);
7103 assert_eq!(isolated_notice_instrument(None, &sub_map), None);
7105 }
7106
7107 #[test]
7108 fn the_two_streams_key_one_notice_alike() {
7109 use crate::client::binance::margin_risk::{RiskFrame, convert_risk_frame};
7111
7112 let change = documented_margin_level_change("MARGIN_LEVEL_STATUS_CHANGE");
7113 let mut from_user_data = Vec::new();
7114 convert_margin_user_data_events(&push(change.clone()), &mut from_user_data);
7115 let mut from_risk = Vec::new();
7116 let risk_frame = serde_json::json!({ "stream": "listen-key", "data": change });
7117 assert_eq!(
7118 convert_risk_frame(&risk_frame.to_string(), "listen-key", &mut from_risk),
7119 RiskFrame::Continue
7120 );
7121
7122 let dedup = new_dedup_cache();
7123 let key = |events: &[UnindexedAccountEvent]| dedup_key_from_event(&events[0]).unwrap();
7124 assert!(!is_duplicate(&dedup, key(&from_user_data)));
7125 assert!(
7126 is_duplicate(&dedup, key(&from_risk)),
7127 "the risk data stream's copy is dropped"
7128 );
7129 }
7130
7131 #[test]
7132 fn a_notice_from_both_streams_is_delivered_once() {
7133 let dedup = new_dedup_cache();
7137 let notice = |time_ms: i64| {
7138 let mut buf = Vec::new();
7139 convert_margin_user_data_events(
7140 &push(serde_json::json!({
7141 "e": "MARGIN_LEVEL_STATUS_CHANGE", "E": time_ms, "l": "1.1",
7142 "s": "MARGIN_CALL",
7143 })),
7144 &mut buf,
7145 );
7146 buf.pop().unwrap()
7147 };
7148 let is_new = |event: &UnindexedAccountEvent| {
7149 !is_duplicate(&dedup, dedup_key_from_event(event).unwrap())
7150 };
7151
7152 assert!(is_new(¬ice(1_700_000_000_000)));
7153 assert!(!is_new(¬ice(1_700_000_000_000)), "second copy dropped");
7154 assert!(is_new(¬ice(1_700_000_060_000)), "a later notice is new");
7155 }
7156
7157 #[test]
7158 fn isolated_outbound_position_routes_to_instrument_balance_update() {
7159 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7160 let base_quote = Arc::new(HashMap::from([(
7161 btcusdt(),
7162 (
7163 AssetNameExchange::new("BTC"),
7164 AssetNameExchange::new("USDT"),
7165 ),
7166 )]));
7167 let mut handler = isolated_handler(sub_map, base_quote);
7168
7169 let frame = push_with_sub(
7171 7,
7172 serde_json::json!({
7173 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7174 "B": [
7175 { "a": "USDT", "f": "1000.0", "l": "50.0" },
7176 { "a": "BTC", "f": "0.5", "l": "0.1" },
7177 ],
7178 }),
7179 );
7180 let mut buf = Vec::new();
7181 assert_eq!(
7182 convert_margin_user_data_events_with(
7183 &frame,
7184 &mut buf,
7185 &mut handler,
7186 &mut cross_notice_instrument,
7187 ),
7188 FrameOutcome::Pushed { terminated: false }
7189 );
7190 assert_eq!(
7193 isolated_balances(&buf),
7194 vec![
7195 ("BTCUSDT", "USDT", Decimal::from(1000), Decimal::from(50)),
7196 ("BTCUSDT", "BTC", Decimal::new(5, 1), Decimal::new(1, 1)),
7197 ]
7198 );
7199 let time = Utc.timestamp_millis_opt(1_700_000_000_000).unwrap();
7200 assert!(buf.iter().all(|event| matches!(
7201 &event.kind,
7202 AccountEventKind::InstrumentBalanceUpdate(ibu) if ibu.balance.time_exchange == time
7203 )));
7204 }
7205
7206 #[test]
7207 fn isolated_outbound_position_unknown_subscription_dropped() {
7208 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7210 let base_quote = Arc::new(HashMap::from([(
7211 btcusdt(),
7212 (
7213 AssetNameExchange::new("BTC"),
7214 AssetNameExchange::new("USDT"),
7215 ),
7216 )]));
7217 let mut handler = isolated_handler(sub_map, base_quote);
7218
7219 let frame = push_with_sub(
7220 99,
7221 serde_json::json!({
7222 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7223 "B": [{ "a": "BTC", "f": "1", "l": "0" }, { "a": "USDT", "f": "1", "l": "0" }],
7224 }),
7225 );
7226 let mut buf = Vec::new();
7227 assert_eq!(
7228 convert_margin_user_data_events_with(
7229 &frame,
7230 &mut buf,
7231 &mut handler,
7232 &mut cross_notice_instrument,
7233 ),
7234 FrameOutcome::Pushed { terminated: false }
7235 );
7236 assert!(buf.is_empty(), "unmapped subscriptionId frame is dropped");
7237 }
7238
7239 #[test]
7240 fn isolated_outbound_position_with_one_side_emits_that_side() {
7241 for (listed, free) in [("BTC", Decimal::new(5, 1)), ("USDT", Decimal::from(20))] {
7244 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7245 let base_quote = Arc::new(HashMap::from([(
7246 btcusdt(),
7247 (
7248 AssetNameExchange::new("BTC"),
7249 AssetNameExchange::new("USDT"),
7250 ),
7251 )]));
7252 let mut handler = isolated_handler(sub_map, base_quote);
7253
7254 let frame = push_with_sub(
7255 7,
7256 serde_json::json!({
7257 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7258 "B": [{ "a": listed, "f": free.to_string(), "l": "0" }],
7259 }),
7260 );
7261 let mut buf = Vec::new();
7262 assert_eq!(
7263 convert_margin_user_data_events_with(
7264 &frame,
7265 &mut buf,
7266 &mut handler,
7267 &mut cross_notice_instrument,
7268 ),
7269 FrameOutcome::Pushed { terminated: false }
7270 );
7271 assert_eq!(
7272 isolated_balances(&buf),
7273 vec![("BTCUSDT", listed, free, Decimal::ZERO)],
7274 "only {listed} listed"
7275 );
7276 }
7277 }
7278
7279 #[test]
7280 fn isolated_outbound_position_skips_assets_outside_the_pair() {
7281 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7284 let base_quote = Arc::new(HashMap::from([(
7285 btcusdt(),
7286 (
7287 AssetNameExchange::new("BTC"),
7288 AssetNameExchange::new("USDT"),
7289 ),
7290 )]));
7291 let mut handler = isolated_handler(sub_map, base_quote);
7292
7293 let mixed = push_with_sub(
7294 7,
7295 serde_json::json!({
7296 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7297 "B": [{ "a": "BNB", "f": "3", "l": "0" }, { "a": "USDT", "f": "20", "l": "1" }],
7298 }),
7299 );
7300 let mut buf = Vec::new();
7301 assert_eq!(
7302 convert_margin_user_data_events_with(
7303 &mixed,
7304 &mut buf,
7305 &mut handler,
7306 &mut cross_notice_instrument,
7307 ),
7308 FrameOutcome::Pushed { terminated: false }
7309 );
7310 assert_eq!(
7311 isolated_balances(&buf),
7312 vec![("BTCUSDT", "USDT", Decimal::from(20), Decimal::ONE)]
7313 );
7314
7315 let stray_only = push_with_sub(
7316 7,
7317 serde_json::json!({
7318 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7319 "B": [{ "a": "BNB", "f": "3", "l": "0" }],
7320 }),
7321 );
7322 let mut buf = Vec::new();
7323 assert_eq!(
7324 convert_margin_user_data_events_with(
7325 &stray_only,
7326 &mut buf,
7327 &mut handler,
7328 &mut cross_notice_instrument,
7329 ),
7330 FrameOutcome::Pushed { terminated: false }
7331 );
7332 assert!(buf.is_empty(), "no asset of the pair → nothing emitted");
7333 }
7334
7335 #[test]
7336 fn isolated_outbound_position_unparseable_entry_keeps_the_other_side() {
7337 let sub_map = Arc::new(Mutex::new(HashMap::from([(7_i64, btcusdt())])));
7339 let base_quote = Arc::new(HashMap::from([(
7340 btcusdt(),
7341 (
7342 AssetNameExchange::new("BTC"),
7343 AssetNameExchange::new("USDT"),
7344 ),
7345 )]));
7346 let mut handler = isolated_handler(sub_map, base_quote);
7347
7348 let frame = push_with_sub(
7349 7,
7350 serde_json::json!({
7351 "e": "outboundAccountPosition", "u": 1_700_000_000_000_i64,
7352 "B": [{ "a": "BTC", "f": "x", "l": "0" }, { "a": "USDT", "f": "20", "l": "1" }],
7353 }),
7354 );
7355 let mut buf = Vec::new();
7356 assert_eq!(
7357 convert_margin_user_data_events_with(
7358 &frame,
7359 &mut buf,
7360 &mut handler,
7361 &mut cross_notice_instrument,
7362 ),
7363 FrameOutcome::Pushed { terminated: false }
7364 );
7365 assert_eq!(
7366 isolated_balances(&buf),
7367 vec![("BTCUSDT", "USDT", Decimal::from(20), Decimal::ONE)]
7368 );
7369 }
7370
7371 #[test]
7372 fn isolated_execution_report_routes_by_inner_symbol_independent_of_map() {
7373 let sub_map = Arc::new(Mutex::new(HashMap::<i64, InstrumentNameExchange>::new()));
7377 let base_quote = Arc::new(HashMap::new());
7378 let mut handler = isolated_handler(sub_map, base_quote);
7379
7380 let frame = push_with_sub(
7381 42,
7382 serde_json::json!({
7383 "e": "executionReport", "s": "BTCUSDT", "S": "BUY", "o": "LIMIT",
7384 "x": "TRADE", "X": "FILLED", "i": 1_i64, "c": "cid", "t": 5_i64,
7385 "l": "1", "L": "100", "z": "1", "p": "100", "q": "1", "f": "GTC",
7386 "n": "0", "N": "USDT", "T": 1_700_000_000_000_i64,
7387 }),
7388 );
7389 let mut buf = Vec::new();
7390 assert_eq!(
7391 convert_margin_user_data_events_with(
7392 &frame,
7393 &mut buf,
7394 &mut handler,
7395 &mut cross_notice_instrument,
7396 ),
7397 FrameOutcome::Pushed { terminated: false }
7398 );
7399 assert_eq!(buf.len(), 2, "the execution and its order snapshot");
7400 match &buf[0].kind {
7401 AccountEventKind::Trade(t) => assert_eq!(t.instrument.name().as_str(), "BTCUSDT"),
7402 other => panic!("expected Trade, got {other:?}"),
7403 }
7404 match &buf[1].kind {
7405 AccountEventKind::OrderSnapshot(snap) => {
7406 assert_eq!(snap.0.key.instrument.name().as_str(), "BTCUSDT");
7407 }
7408 other => panic!("expected OrderSnapshot, got {other:?}"),
7409 }
7410 }
7411
7412 struct MarginMyTradesVenue {
7423 trades: Vec<(i64, i64, i64)>,
7425 end_exclusive: bool,
7426 }
7427
7428 impl wiremock::Respond for MarginMyTradesVenue {
7429 fn respond(&self, request: &wiremock::Request) -> wiremock::ResponseTemplate {
7430 const DAY_MS: i64 = 24 * 60 * 60 * 1000;
7431 let query: std::collections::HashMap<String, String> =
7432 request.url.query_pairs().into_owned().collect();
7433 let param = |name: &str| query.get(name).map(|v| v.parse::<i64>().unwrap());
7434 let limit = usize::try_from(param("limit").unwrap_or(500)).unwrap();
7435 let order = param("orderId");
7436
7437 let mut selected: Vec<&(i64, i64, i64)> = self
7438 .trades
7439 .iter()
7440 .filter(|(_, _, order_id)| order.is_none_or(|order| *order_id == order))
7441 .collect();
7442 if let Some(from_id) = param("fromId") {
7443 selected.retain(|(id, _, _)| *id >= from_id);
7444 } else if order.is_none() {
7445 let start = param("startTime").expect("a query by time sends startTime");
7446 let end = match param("endTime") {
7447 Some(end) if end - start >= DAY_MS => {
7448 return wiremock::ResponseTemplate::new(400).set_body_json(
7449 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
7450 );
7451 }
7452 Some(end) if self.end_exclusive => end - 1,
7453 Some(end) => end,
7454 None => start + DAY_MS - 1,
7455 };
7456 selected.retain(|(_, time, _)| (start..=end).contains(time));
7457 }
7458
7459 let page: Vec<serde_json::Value> = selected
7460 .into_iter()
7461 .take(limit)
7462 .map(|(id, time, order_id)| {
7463 serde_json::json!({
7464 "id": id, "symbol": "BTCUSDT", "time": time, "orderId": order_id,
7465 "price": "50000", "qty": "0.01", "commission": "0",
7466 "commissionAsset": "USDT", "isBuyer": true, "isMaker": false,
7467 "isBestMatch": true, "isIsolated": true,
7468 })
7469 })
7470 .collect();
7471 wiremock::ResponseTemplate::new(200).set_body_json(page)
7472 }
7473 }
7474
7475 fn since(start: i64) -> MyTradesFrom {
7477 MyTradesFrom::Span {
7478 start,
7479 end: i64::MAX,
7480 }
7481 }
7482
7483 async fn read_margin_trades(
7486 venue: MarginMyTradesVenue,
7487 from: MyTradesFrom,
7488 ) -> (Vec<i64>, Vec<std::collections::HashMap<String, String>>) {
7489 let server = wiremock::MockServer::start().await;
7490 wiremock::Mock::given(wiremock::matchers::method("GET"))
7491 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7492 .respond_with(venue)
7493 .mount(&server)
7494 .await;
7495 let rest = Arc::new(MarginTradingRestApi::from_config(
7496 ConfigurationRestApi::builder()
7497 .api_key("key")
7498 .api_secret("secret")
7499 .base_path(server.uri())
7500 .build()
7501 .unwrap(),
7502 ));
7503
7504 let read = paginate_margin_my_trades(
7505 &rest,
7506 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7507 &InstrumentNameExchange::new("BTCUSDT"),
7508 from,
7509 true,
7510 RequestKind::Query,
7511 )
7512 .await
7513 .unwrap();
7514
7515 let requests = server
7516 .received_requests()
7517 .await
7518 .unwrap()
7519 .iter()
7520 .map(|request| request.url.query_pairs().into_owned().collect())
7521 .collect();
7522 (read.into_iter().map(|t| t.id.unwrap()).collect(), requests)
7523 }
7524
7525 #[tokio::test]
7528 async fn recovered_margin_fills_carry_their_orders_client_order_ids() {
7529 recovered_margin_fills_carry_client_order_ids(true).await;
7530 recovered_margin_fills_carry_client_order_ids(false).await;
7531 }
7532
7533 async fn recovered_margin_fills_carry_client_order_ids(is_isolated: bool) {
7534 let disconnect = Utc::now() - chrono::Duration::minutes(10);
7535 let reconnect = Utc::now() - chrono::Duration::minutes(5);
7536 let fill_time = (disconnect + chrono::Duration::minutes(1)).timestamp_millis();
7537 let server = wiremock::MockServer::start().await;
7538 wiremock::Mock::given(wiremock::matchers::method("GET"))
7539 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7540 .respond_with(MarginMyTradesVenue {
7541 trades: vec![(1, fill_time, 1), (2, fill_time, 2)],
7542 end_exclusive: false,
7543 })
7544 .mount(&server)
7545 .await;
7546 wiremock::Mock::given(wiremock::matchers::method("GET"))
7547 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7548 .and(wiremock::matchers::query_param("orderId", "1"))
7549 .and(wiremock::matchers::query_param(
7550 "isIsolated",
7551 if is_isolated { "TRUE" } else { "FALSE" },
7552 ))
7553 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
7554 serde_json::json!({"symbol": "BTCUSDT", "orderId": 1, "clientOrderId": "repay-1"}),
7555 ))
7556 .mount(&server)
7557 .await;
7558 wiremock::Mock::given(wiremock::matchers::method("GET"))
7559 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
7560 .and(wiremock::matchers::query_param("orderId", "2"))
7561 .respond_with(
7562 wiremock::ResponseTemplate::new(400).set_body_json(
7563 serde_json::json!({"code": -2013, "msg": "Order does not exist."}),
7564 ),
7565 )
7566 .mount(&server)
7567 .await;
7568 let rest = Arc::new(MarginTradingRestApi::from_config(
7569 ConfigurationRestApi::builder()
7570 .api_key("key")
7571 .api_secret("secret")
7572 .base_path(server.uri())
7573 .build()
7574 .unwrap(),
7575 ));
7576 let (tx, mut rx) = mpsc::unbounded_channel();
7577 let mut unrecovered = UnrecoveredFills::default();
7578 unrecovered.open(
7579 &[InstrumentNameExchange::new("BTCUSDT")],
7580 disconnect,
7581 reconnect,
7582 );
7583
7584 recover_margin_fills(
7585 &rest,
7586 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7587 &mut unrecovered,
7588 &tx,
7589 &new_dedup_cache(),
7590 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7591 is_isolated,
7592 )
7593 .await;
7594
7595 let mut cids: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok())
7596 .filter_map(|event| match event.kind {
7597 AccountEventKind::Trade(trade) => Some((
7598 trade.id.0.to_string(),
7599 trade.client_order_id,
7600 trade.order_filled_quantity.is_some(),
7601 )),
7602 _ => None,
7603 })
7604 .collect();
7605 cids.sort();
7606 assert_eq!(
7608 cids,
7609 [
7610 ("1".to_string(), Some(ClientOrderId::new("repay-1")), true),
7611 ("2".to_string(), None, true),
7612 ],
7613 "is_isolated: {is_isolated}"
7614 );
7615 let order_reads = server
7616 .received_requests()
7617 .await
7618 .unwrap()
7619 .iter()
7620 .filter(|request| request.url.path() == "/sapi/v1/margin/order")
7621 .count();
7622 assert_eq!(order_reads, 2, "one read per order");
7623 }
7624
7625 fn venue(trades: Vec<(i64, i64)>) -> MarginMyTradesVenue {
7627 MarginMyTradesVenue {
7628 trades: trades
7629 .into_iter()
7630 .map(|(id, time)| (id, time, id))
7631 .collect(),
7632 end_exclusive: false,
7633 }
7634 }
7635
7636 const HOUR_MS: i64 = 60 * 60 * 1000;
7637
7638 #[tokio::test]
7641 async fn margin_trades_read_a_lone_fill_beyond_the_first_day() {
7642 let now = Utc::now().timestamp_millis();
7643 let (ids, _) = read_margin_trades(
7644 venue(vec![(7, now - 72 * HOUR_MS)]),
7645 since(now - 120 * HOUR_MS),
7646 )
7647 .await;
7648
7649 assert_eq!(ids, vec![7]);
7650 }
7651
7652 #[tokio::test]
7655 async fn margin_trades_read_fills_across_several_windows_once_each() {
7656 let now = Utc::now().timestamp_millis();
7657 let trades = vec![
7658 (1, now - 119 * HOUR_MS),
7659 (2, now - 72 * HOUR_MS),
7660 (3, now - HOUR_MS),
7661 ];
7662 let (ids, _) = read_margin_trades(venue(trades), since(now - 144 * HOUR_MS)).await;
7663
7664 assert_eq!(ids, vec![1, 2, 3]);
7665 }
7666
7667 #[tokio::test]
7670 async fn margin_trades_read_a_trade_on_a_window_boundary_once() {
7671 for end_exclusive in [false, true] {
7672 let now = Utc::now().timestamp_millis();
7673 let from = now - 48 * HOUR_MS;
7674 let venue = MarginMyTradesVenue {
7675 end_exclusive,
7676 ..venue(vec![(5, from + MARGIN_MY_TRADES_WINDOW_MS)])
7677 };
7678 let (ids, _) = read_margin_trades(venue, since(from)).await;
7679
7680 assert_eq!(ids, vec![5], "end_exclusive: {end_exclusive}");
7681 }
7682 }
7683
7684 #[tokio::test]
7686 async fn margin_trades_page_on_by_id_past_a_full_window() {
7687 let now = Utc::now().timestamp_millis();
7688 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
7689 let trades = (1..=count)
7690 .map(|id| (id, now - 48 * HOUR_MS + id))
7691 .collect();
7692 let (ids, _) = read_margin_trades(venue(trades), since(now - 50 * HOUR_MS)).await;
7693
7694 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
7695 }
7696
7697 #[tokio::test]
7700 async fn margin_trades_page_on_past_a_full_final_window() {
7701 let now = Utc::now().timestamp_millis();
7702 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
7703 let trades = (1..=count).map(|id| (id, now - HOUR_MS + id)).collect();
7704 let (ids, requests) = read_margin_trades(venue(trades), since(now - 2 * HOUR_MS)).await;
7705
7706 assert_eq!(ids, (1..=count).collect::<Vec<_>>());
7707 assert_eq!(requests.len(), 2);
7708 }
7709
7710 #[tokio::test]
7713 async fn margin_trades_quiet_lookback_sends_one_request_per_window() {
7714 let now = Utc::now().timestamp_millis();
7715 let (ids, requests) =
7716 read_margin_trades(venue(Vec::new()), since(now - 72 * HOUR_MS)).await;
7717
7718 assert!(ids.is_empty());
7719 assert_eq!(requests.len(), 4);
7721 }
7722
7723 #[tokio::test]
7726 async fn margin_trades_read_a_short_gap_in_one_request() {
7727 let now = Utc::now().timestamp_millis();
7728 let (ids, requests) =
7729 read_margin_trades(venue(vec![(9, now - HOUR_MS)]), since(now - 2 * HOUR_MS)).await;
7730
7731 assert_eq!(ids, vec![9]);
7732 assert_eq!(requests.len(), 1);
7733 }
7734
7735 #[tokio::test]
7737 async fn margin_trades_from_the_future_send_no_request() {
7738 let now = Utc::now().timestamp_millis();
7739 let (ids, requests) = read_margin_trades(venue(Vec::new()), since(now + HOUR_MS)).await;
7740
7741 assert!(ids.is_empty());
7742 assert!(requests.is_empty());
7743 }
7744
7745 #[tokio::test]
7748 async fn margin_trades_by_order_page_by_id_without_time_bounds() {
7749 let now = Utc::now().timestamp_millis();
7750 let count = i64::try_from(BINANCE_MAX_TRADES).unwrap() + 1;
7751 let trades = (1..=2 * count)
7753 .map(|id| {
7754 (
7755 id,
7756 now - 48 * HOUR_MS + id,
7757 if id % 2 == 0 { 42 } else { 7 },
7758 )
7759 })
7760 .collect();
7761 let venue = MarginMyTradesVenue {
7762 trades,
7763 end_exclusive: false,
7764 };
7765 let (ids, requests) = read_margin_trades(venue, MyTradesFrom::Order(42)).await;
7766
7767 assert_eq!(ids, (1..=count).map(|n| 2 * n).collect::<Vec<_>>());
7768 assert_eq!(requests.len(), 2);
7769 assert!(
7770 requests.iter().all(
7771 |query| query.get("orderId").map(String::as_str) == Some("42")
7772 && !query.contains_key("startTime")
7773 && !query.contains_key("endTime")
7774 )
7775 );
7776 }
7777
7778 async fn margin_trades_error(response: wiremock::ResponseTemplate) -> UnindexedClientError {
7781 let server = wiremock::MockServer::start().await;
7782 wiremock::Mock::given(wiremock::matchers::method("GET"))
7783 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7784 .respond_with(response)
7785 .mount(&server)
7786 .await;
7787 let rest = Arc::new(MarginTradingRestApi::from_config(
7788 ConfigurationRestApi::builder()
7789 .api_key("key")
7790 .api_secret("secret")
7791 .base_path(server.uri())
7792 .build()
7793 .unwrap(),
7794 ));
7795
7796 paginate_margin_my_trades(
7797 &rest,
7798 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
7799 &InstrumentNameExchange::new("BTCUSDT"),
7800 since(Utc::now().timestamp_millis() - HOUR_MS),
7801 true,
7802 RequestKind::Query,
7803 )
7804 .await
7805 .unwrap_err()
7806 }
7807
7808 #[tokio::test]
7811 async fn margin_trades_report_a_venue_rejection_as_a_non_transient_request_rejection() {
7812 let err = margin_trades_error(wiremock::ResponseTemplate::new(400).set_body_json(
7813 serde_json::json!({"code": -1127, "msg": "More than 24 hours between startTime and endTime."}),
7814 ))
7815 .await;
7816
7817 let UnindexedClientError::Api(ApiError::RequestRejected(msg)) = &err else {
7818 panic!("expected RequestRejected, got {err:?}");
7819 };
7820 assert!(msg.contains("-1127"), "code missing from {msg:?}");
7821 assert!(!err.is_transient());
7822 }
7823
7824 #[tokio::test]
7826 async fn margin_trades_report_an_undecodable_response_as_internal() {
7827 let err = margin_trades_error(
7828 wiremock::ResponseTemplate::new(200)
7829 .set_body_json(serde_json::json!({"unexpected": 1})),
7830 )
7831 .await;
7832
7833 assert!(
7834 matches!(err, UnindexedClientError::Internal(_)),
7835 "got {err:?}"
7836 );
7837 assert!(!err.is_transient());
7838 }
7839
7840 #[tokio::test]
7843 async fn sapi_used_ip_weight_near_the_limit_pauses_queries() {
7844 for (used, pauses) in [("10799", false), ("10800", true), ("not a number", false)] {
7845 let server = wiremock::MockServer::start().await;
7846 wiremock::Mock::given(wiremock::matchers::method("GET"))
7847 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
7848 .respond_with(
7849 wiremock::ResponseTemplate::new(200)
7850 .insert_header("x-sapi-used-ip-weight-1m", used)
7851 .set_body_json(serde_json::json!([])),
7852 )
7853 .mount(&server)
7854 .await;
7855 let rest = Arc::new(MarginTradingRestApi::from_config(
7856 ConfigurationRestApi::builder()
7857 .api_key("key")
7858 .api_secret("secret")
7859 .base_path(server.uri())
7860 .build()
7861 .unwrap(),
7862 ));
7863 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7864
7865 fetch_margin_all_open_orders(rest, Arc::clone(&tracker), false)
7866 .await
7867 .unwrap();
7868
7869 let query_waits = tokio::time::timeout(
7870 Duration::from_millis(50),
7871 tracker.wait_if_blocked(RequestKind::Query),
7872 )
7873 .await
7874 .is_err();
7875 assert_eq!(query_waits, pauses, "used IP weight {used}");
7876 tokio::time::timeout(
7877 Duration::from_millis(50),
7878 tracker.wait_if_blocked(RequestKind::Order),
7879 )
7880 .await
7881 .expect("orders never wait on the pause")
7882 .expect("no cooldown too long to sleep through");
7883 }
7884 }
7885
7886 #[tokio::test]
7888 async fn margin_fill_recovery_does_not_wait_for_the_weight_pause() {
7889 let server = wiremock::MockServer::start().await;
7890 wiremock::Mock::given(wiremock::matchers::method("GET"))
7891 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7892 .respond_with(
7894 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([{
7895 "symbol": "BTCUSDT",
7896 "id": 1,
7897 "orderId": 7,
7898 "price": "100",
7899 "qty": "1",
7900 "commission": "0",
7901 "commissionAsset": "USDT",
7902 "time": Utc::now().timestamp_millis(),
7903 "isBuyer": true,
7904 "isMaker": false,
7905 }])),
7906 )
7907 .expect(2)
7908 .mount(&server)
7909 .await;
7910 let rest = Arc::new(MarginTradingRestApi::from_config(
7911 ConfigurationRestApi::builder()
7912 .api_key("key")
7913 .api_secret("secret")
7914 .base_path(server.uri())
7915 .build()
7916 .unwrap(),
7917 ));
7918 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7919 tracker.throttle(Duration::from_secs(60));
7920 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
7921 let mut unrecovered = UnrecoveredFills::default();
7922 unrecovered.open(
7923 &[InstrumentNameExchange::new("BTCUSDT")],
7924 Utc::now() - chrono::Duration::minutes(1),
7925 Utc::now(),
7926 );
7927
7928 tokio::time::timeout(
7930 Duration::from_millis(800),
7931 recover_margin_fills(
7932 &rest,
7933 &tracker,
7934 &mut unrecovered,
7935 &tx,
7936 &new_dedup_cache(),
7937 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
7938 false,
7939 ),
7940 )
7941 .await
7942 .expect("recovery does not wait for the pause");
7943 }
7944
7945 #[tokio::test]
7948 async fn margin_trades_read_over_a_span_stop_at_its_end() {
7949 let now = Utc::now().timestamp_millis();
7950 let trades = vec![
7951 (1, now - 50 * HOUR_MS),
7952 (2, now - 49 * HOUR_MS),
7953 (3, now - 10 * HOUR_MS),
7954 ];
7955 let (ids, _) = read_margin_trades(
7956 venue(trades),
7957 MyTradesFrom::Span {
7958 start: now - 60 * HOUR_MS,
7959 end: now - 20 * HOUR_MS,
7960 },
7961 )
7962 .await;
7963
7964 assert_eq!(ids, vec![1, 2]);
7965 }
7966
7967 #[tokio::test]
7970 async fn a_margin_gap_whose_every_read_fails_is_reported_when_given_up() {
7971 let disconnect = Utc::now() - chrono::Duration::minutes(10);
7972 let reconnect = Utc::now() - chrono::Duration::minutes(5);
7973 let server = wiremock::MockServer::start().await;
7974 wiremock::Mock::given(wiremock::matchers::method("GET"))
7975 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
7976 .respond_with(
7977 wiremock::ResponseTemplate::new(400)
7978 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
7979 )
7980 .mount(&server)
7981 .await;
7982 let rest = Arc::new(MarginTradingRestApi::from_config(
7983 ConfigurationRestApi::builder()
7984 .api_key("key")
7985 .api_secret("secret")
7986 .base_path(server.uri())
7987 .build()
7988 .expect("valid config"),
7989 ));
7990 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
7991 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
7992 let dedup = new_dedup_cache();
7993 let btc = InstrumentNameExchange::new("BTCUSDT");
7994 let mut unrecovered = UnrecoveredFills::default();
7995 unrecovered.open(std::slice::from_ref(&btc), disconnect, reconnect);
7996
7997 for _ in 0..=crate::client::order_recovery::MAX_GAP_RETRIES {
7998 recover_margin_fills(
7999 &rest,
8000 &tracker,
8001 &mut unrecovered,
8002 &tx,
8003 &dedup,
8004 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
8005 false,
8006 )
8007 .await;
8008 unrecovered.make_due();
8009 }
8010
8011 assert!(unrecovered.is_empty(), "the gap is given up");
8012 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
8013 let [event] = forwarded.as_slice() else {
8014 panic!("only the give-up is sent: {forwarded:?}");
8015 };
8016 let AccountEventKind::FillRecoveryGaveUp(gave_up) = &event.kind else {
8017 panic!("expected FillRecoveryGaveUp, got {event:?}");
8018 };
8019 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
8020 assert_eq!(
8021 gave_up.scope,
8022 crate::FillRecoveryScope::Instruments(vec![btc])
8023 );
8024 assert_eq!(
8025 gave_up.start.timestamp_millis(),
8026 disconnect.timestamp_millis()
8027 );
8028 assert_eq!(
8029 gave_up.attempts,
8030 crate::client::order_recovery::MAX_GAP_RETRIES + 1
8031 );
8032 assert!(
8033 matches!(&gave_up.reason, FillRecoveryFailure::Request(reason) if reason.contains("rejected")),
8034 "the last read's error: {:?}",
8035 gave_up.reason
8036 );
8037 }
8038
8039 #[tokio::test]
8042 async fn a_failed_margin_recovery_keeps_its_closed_gap_for_the_retry() {
8043 let disconnect = Utc::now() - chrono::Duration::minutes(10);
8044 let reconnect = Utc::now() - chrono::Duration::minutes(5);
8045 let server = wiremock::MockServer::start().await;
8046 wiremock::Mock::given(wiremock::matchers::method("GET"))
8047 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
8048 .respond_with(
8049 wiremock::ResponseTemplate::new(400)
8050 .set_body_json(serde_json::json!({"code": -1100, "msg": "rejected"})),
8051 )
8052 .up_to_n_times(1)
8053 .with_priority(1)
8054 .mount(&server)
8055 .await;
8056 wiremock::Mock::given(wiremock::matchers::method("GET"))
8057 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
8058 .respond_with(venue(vec![
8059 (
8060 1,
8061 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
8062 ),
8063 (
8064 2,
8065 (Utc::now() - chrono::Duration::minutes(1)).timestamp_millis(),
8066 ),
8067 ]))
8068 .mount(&server)
8069 .await;
8070 let rest = Arc::new(MarginTradingRestApi::from_config(
8071 ConfigurationRestApi::builder()
8072 .api_key("key")
8073 .api_secret("secret")
8074 .base_path(server.uri())
8075 .build()
8076 .unwrap(),
8077 ));
8078 let tracker = Arc::new(RateLimitTracker::new(WeightPool::Sapi));
8079 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
8080 let dedup = new_dedup_cache();
8081 let mut unrecovered = UnrecoveredFills::default();
8082 unrecovered.open(
8083 &[InstrumentNameExchange::new("BTCUSDT")],
8084 disconnect,
8085 reconnect,
8086 );
8087
8088 recover_margin_fills(
8089 &rest,
8090 &tracker,
8091 &mut unrecovered,
8092 &tx,
8093 &dedup,
8094 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
8095 false,
8096 )
8097 .await;
8098 assert!(!unrecovered.is_empty(), "the failed gap is kept");
8099 assert!(rx.try_recv().is_err(), "nothing forwarded");
8100
8101 unrecovered.make_due();
8102 recover_margin_fills(
8103 &rest,
8104 &tracker,
8105 &mut unrecovered,
8106 &tx,
8107 &dedup,
8108 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
8109 false,
8110 )
8111 .await;
8112 assert!(unrecovered.is_empty(), "the retry recovered it");
8113 let forwarded: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
8114 assert_eq!(
8115 forwarded.len(),
8116 1,
8117 "only the fill inside the gap: {forwarded:?}"
8118 );
8119 }
8120
8121 #[tokio::test]
8124 async fn a_timed_out_margin_recovery_charges_only_the_gaps_it_started() {
8125 let server = wiremock::MockServer::start().await;
8126 wiremock::Mock::given(wiremock::matchers::method("GET"))
8127 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
8128 .respond_with(
8129 wiremock::ResponseTemplate::new(200)
8130 .set_body_json(serde_json::json!([]))
8131 .set_delay(Duration::from_secs(3_600)),
8132 )
8133 .mount(&server)
8134 .await;
8135 let rest = Arc::new(MarginTradingRestApi::from_config(
8136 ConfigurationRestApi::builder()
8137 .api_key("key")
8138 .api_secret("secret")
8139 .base_path(server.uri())
8140 .timeout(3_600_000_u64)
8141 .retries(0_u32)
8142 .build()
8143 .unwrap(),
8144 ));
8145 let instruments: Vec<_> = (0..9)
8146 .map(|i| InstrumentNameExchange::new(format!("SYM{i}USDT")))
8147 .collect();
8148 let mut unrecovered = UnrecoveredFills::default();
8149 unrecovered.open(
8150 &instruments,
8151 Utc::now() - chrono::Duration::minutes(10),
8152 Utc::now(),
8153 );
8154 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
8155 tokio::time::pause();
8157
8158 recover_margin_fills(
8159 &rest,
8160 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
8161 &mut unrecovered,
8162 &tx,
8163 &new_dedup_cache(),
8164 &KnownLiveOrders::shared(ExchangeId::BinanceMargin),
8165 false,
8166 )
8167 .await;
8168
8169 let now = tokio::time::Instant::now();
8170 assert_eq!(
8171 unrecovered.due(now).len(),
8172 1,
8173 "the gap never started stays due"
8174 );
8175 assert_eq!(
8176 unrecovered
8177 .due(now + Duration::from_secs(crate::client::order_recovery::GAP_RETRY_BASE_SECS))
8178 .len(),
8179 9,
8180 "the eight started wait for their first retry"
8181 );
8182 }
8183
8184 fn margin_order_row(cid: &str, status: &str) -> serde_json::Value {
8190 serde_json::json!({
8191 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid, "price": "100",
8192 "origQty": "2", "executedQty": "1", "cummulativeQuoteQty": "105", "status": status,
8193 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY", "isIsolated": false,
8194 "time": 1_700_000_000_000_i64, "updateTime": 1_700_000_060_000_i64,
8195 })
8196 }
8197
8198 fn margin_key(instrument: &str, cid: &str) -> UnindexedOrderKey {
8199 OrderKey::new(
8200 ExchangeId::BinanceMargin,
8201 InstrumentNameExchange::new(instrument),
8202 StrategyId::new("strategy"),
8203 ClientOrderId::new(cid),
8204 )
8205 }
8206
8207 #[test]
8208 fn a_margin_order_row_says_how_the_order_ended() {
8209 use crate::order::state::InactiveOrderState;
8210 use binance_sdk::margin_trading::rest_api::QueryMarginAccountsOrderResponse;
8211 let key = margin_key("BTCUSDT", "a");
8212 let ended = |row: serde_json::Value| {
8213 let row: QueryMarginAccountsOrderResponse = serde_json::from_value(row).unwrap();
8214 convert_ended_order(&row, ExchangeId::BinanceMargin, &key, None)
8215 .map(|order| order.state)
8216 };
8217
8218 let mut filled = margin_order_row("a", "FILLED");
8219 filled["executedQty"] = "2".into();
8220 filled["cummulativeQuoteQty"] = "210".into();
8221 let Some(InactiveOrderState::FullyFilled(filled)) = ended(filled) else {
8222 panic!("FILLED is fully filled");
8223 };
8224 assert_eq!(filled.avg_price, Some(Decimal::from(105)));
8225 assert!(matches!(
8226 ended(margin_order_row("a", "CANCELED")),
8227 Some(InactiveOrderState::Cancelled(cancelled)) if cancelled.filled_quantity == Some(Decimal::ONE)
8228 ));
8229 assert_eq!(ended(margin_order_row("a", "NEW")), None);
8230
8231 let mut unknown_quote = margin_order_row("a", "FILLED");
8233 unknown_quote["executedQty"] = "2".into();
8234 unknown_quote["cummulativeQuoteQty"] = "-1".into();
8235 let Some(InactiveOrderState::FullyFilled(filled)) = ended(unknown_quote) else {
8236 panic!("still fully filled");
8237 };
8238 assert_eq!(filled.avg_price, None, "no negative average price");
8239 }
8240
8241 fn margin_rest_config_at(server: &wiremock::MockServer) -> ConfigurationRestApi {
8243 ConfigurationRestApi::builder()
8244 .api_key("key")
8245 .api_secret("secret")
8246 .base_path(server.uri())
8247 .build()
8248 .unwrap()
8249 }
8250
8251 fn margin_rest_at(server: &wiremock::MockServer) -> Arc<RestApi> {
8252 Arc::new(MarginTradingRestApi::from_config(margin_rest_config_at(
8253 server,
8254 )))
8255 }
8256
8257 pub(super) fn margin_client_at(
8259 server: &wiremock::MockServer,
8260 is_isolated: bool,
8261 ) -> BinanceMargin {
8262 let config = if is_isolated {
8263 BinanceMarginConfig::isolated(
8264 "key".into(),
8265 "secret".into(),
8266 vec![InstrumentNameExchange::new("BTCUSDT")],
8267 )
8268 } else {
8269 BinanceMarginConfig::cross_margin("key".into(), "secret".into())
8270 };
8271 let mode = config.pre_send_rules;
8272 let mut client = <BinanceMargin as ExecutionClient>::new(config);
8273 client.rest = margin_rest_at(server);
8274 client.rest_config = Arc::new(margin_rest_config_at(server));
8275 client.rules = rules_at(server, mode);
8276 client
8277 }
8278
8279 fn rules_at(server: &wiremock::MockServer, mode: PreSendRules) -> Arc<RulesLoader> {
8281 Arc::new(RulesLoader::new(
8282 mode,
8283 Arc::new(binance_sdk::spot::SpotRestApi::from_config(
8284 ConfigurationRestApi::builder()
8285 .api_key("key")
8286 .api_secret("secret")
8287 .base_path(server.uri())
8288 .build()
8289 .unwrap(),
8290 )),
8291 Arc::new(RateLimitTracker::new(WeightPool::Spot)),
8292 ))
8293 }
8294
8295 async fn mount_margin_order(
8297 server: &wiremock::MockServer,
8298 cid: &str,
8299 response: wiremock::ResponseTemplate,
8300 ) {
8301 wiremock::Mock::given(wiremock::matchers::method("GET"))
8302 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8303 .and(wiremock::matchers::query_param("origClientOrderId", cid))
8304 .respond_with(response)
8305 .mount(server)
8306 .await;
8307 }
8308
8309 fn margin_venue_error(code: i64) -> wiremock::ResponseTemplate {
8310 wiremock::ResponseTemplate::new(400)
8311 .set_body_json(serde_json::json!({"code": code, "msg": "refused"}))
8312 }
8313
8314 fn margin_row_response(cid: &str, status: &str) -> wiremock::ResponseTemplate {
8315 wiremock::ResponseTemplate::new(200).set_body_json(margin_order_row(cid, status))
8316 }
8317
8318 async fn margin_requests(server: &wiremock::MockServer) -> Vec<(String, Option<String>)> {
8320 server
8321 .received_requests()
8322 .await
8323 .unwrap()
8324 .iter()
8325 .map(|request| {
8326 let isolated = request
8327 .url
8328 .query_pairs()
8329 .find(|(key, _)| key == "isIsolated")
8330 .map(|(_, value)| value.into_owned());
8331 (request.url.path().to_owned(), isolated)
8332 })
8333 .collect()
8334 }
8335
8336 #[tokio::test]
8337 async fn margin_fetch_ended_orders_asks_the_configured_account_and_omits_the_rest() {
8338 use crate::order::state::InactiveOrderState;
8339 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
8340 let server = wiremock::MockServer::start().await;
8341 mount_margin_order(&server, "filled", margin_row_response("filled", "FILLED")).await;
8342 mount_margin_order(&server, "live", margin_row_response("live", "NEW")).await;
8343 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
8344 mount_margin_order(&server, "no-symbol", margin_venue_error(-1121)).await;
8345 let client = margin_client_at(&server, is_isolated);
8346 let keys = ["filled", "live", "unknown"].map(|cid| margin_key("BTCUSDT", cid));
8347
8348 let ended = client
8349 .fetch_ended_orders(
8350 &[keys.to_vec(), vec![margin_key("NOSUCH", "no-symbol")]].concat(),
8351 )
8352 .await
8353 .unwrap();
8354
8355 let [order] = ended.as_slice() else {
8356 panic!("one order ended: {ended:?}");
8357 };
8358 assert_eq!(order.key, keys[0], "the strategy asked survives");
8359 assert!(matches!(order.state, InactiveOrderState::FullyFilled(_)));
8360 let requests = margin_requests(&server).await;
8361 assert_eq!(requests.len(), 4);
8362 for (path, isolated) in requests {
8363 assert_eq!(path, "/sapi/v1/margin/order");
8364 assert_eq!(
8365 isolated.as_deref(),
8366 Some(flag),
8367 "is_isolated = {is_isolated}"
8368 );
8369 }
8370 }
8371 }
8372
8373 #[tokio::test]
8374 async fn margin_fetch_ended_orders_fails_when_any_lookup_fails() {
8375 let server = wiremock::MockServer::start().await;
8376 mount_margin_order(
8377 &server,
8378 "cancelled",
8379 margin_row_response("cancelled", "CANCELED"),
8380 )
8381 .await;
8382 mount_margin_order(&server, "refused", margin_venue_error(-1100)).await;
8383 let client = margin_client_at(&server, false);
8384
8385 let result = client
8386 .fetch_ended_orders(&[
8387 margin_key("BTCUSDT", "cancelled"),
8388 margin_key("BTCUSDT", "refused"),
8389 ])
8390 .await;
8391
8392 assert!(
8393 matches!(
8394 result,
8395 Err(UnindexedClientError::Api(ApiError::RequestRejected(_)))
8396 ),
8397 "{result:?}"
8398 );
8399 }
8400
8401 fn held_margin_orders() -> SharedKnownLiveOrders {
8403 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
8404 for (n, cid) in ["live", "gone", "unknown"].into_iter().enumerate() {
8405 known.lock().live(
8406 &margin_key("BTCUSDT", cid),
8407 Decimal::TWO,
8408 OrderKind::Limit,
8409 &Open::new(
8410 VenueOrderId::Assigned(OrderId::new(n.to_string())),
8411 Utc::now(),
8412 Decimal::ZERO,
8413 ),
8414 );
8415 }
8416 known
8417 }
8418
8419 #[tokio::test]
8424 async fn an_ended_margin_trailing_stop_is_reported_with_the_kind_it_was_held_with() {
8425 let server = wiremock::MockServer::start().await;
8426 wiremock::Mock::given(wiremock::matchers::method("GET"))
8427 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
8428 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
8429 .mount(&server)
8430 .await;
8431 let mut row = margin_order_row("trailing", "CANCELED");
8432 row["type"] = "STOP_LOSS".into();
8433 row["price"] = "0.00000000".into();
8434 row["stopPrice"] = "0.00000000".into();
8435 mount_margin_order(
8436 &server,
8437 "trailing",
8438 wiremock::ResponseTemplate::new(200).set_body_json(row),
8439 )
8440 .await;
8441 let trailing_stop = OrderKind::TrailingStop {
8442 offset: Decimal::from(25),
8443 offset_type: TrailingOffsetType::BasisPoints,
8444 activation_price: None,
8445 };
8446 let hold = |known: &SharedKnownLiveOrders| {
8447 known.lock().live(
8448 &margin_key("BTCUSDT", "trailing"),
8449 Decimal::TWO,
8450 trailing_stop,
8451 &Open::new(
8452 VenueOrderId::Assigned(OrderId::new("7")),
8453 Utc::now(),
8454 Decimal::ZERO,
8455 ),
8456 );
8457 };
8458
8459 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
8460 hold(&known);
8461 let mut unchecked = UncheckedOrders::default();
8462 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
8463 let (tx, mut rx) = mpsc::unbounded_channel();
8464 recover_margin_ended_orders(
8465 &margin_rest_at(&server),
8466 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
8467 &known,
8468 &mut unchecked,
8469 &UnrecoveredFills::default(),
8470 &tx,
8471 false,
8472 )
8473 .await;
8474 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
8475 let [event] = sent.as_slice() else {
8476 panic!("the trailing stop ended: {sent:?}");
8477 };
8478 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
8479 panic!("an order snapshot: {event:?}");
8480 };
8481 assert_eq!(snapshot.0.kind, trailing_stop);
8482 assert!(matches!(
8483 snapshot.0.state,
8484 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
8485 ));
8486 assert!(!known.lock().contains(&ClientOrderId::new("trailing")));
8487
8488 let client = margin_client_at(&server, false);
8489 hold(&client.known_live);
8490 let ended = client
8491 .fetch_ended_orders(&[margin_key("BTCUSDT", "trailing")])
8492 .await
8493 .expect("lookup");
8494 let [order] = ended.as_slice() else {
8495 panic!("the trailing stop ended: {ended:?}");
8496 };
8497 assert_eq!(order.kind, trailing_stop);
8498 }
8499
8500 #[tokio::test]
8501 async fn a_margin_reconnect_reports_how_an_unlisted_order_ended_in_the_configured_account() {
8502 for (is_isolated, flag) in [(false, "FALSE"), (true, "TRUE")] {
8503 let server = wiremock::MockServer::start().await;
8504 wiremock::Mock::given(wiremock::matchers::method("GET"))
8505 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
8506 .and(wiremock::matchers::query_param("symbol", "BTCUSDT"))
8507 .respond_with(
8508 wiremock::ResponseTemplate::new(200)
8509 .set_body_json(serde_json::json!([margin_order_row("live", "NEW")])),
8510 )
8511 .mount(&server)
8512 .await;
8513 mount_margin_order(&server, "gone", margin_row_response("gone", "CANCELED")).await;
8514 mount_margin_order(&server, "unknown", margin_venue_error(-2013)).await;
8515 let known = held_margin_orders();
8516 let mut unchecked = UncheckedOrders::default();
8517 unchecked.open([InstrumentNameExchange::new("BTCUSDT")]);
8518 let (tx, mut rx) = mpsc::unbounded_channel();
8519
8520 recover_margin_ended_orders(
8521 &margin_rest_at(&server),
8522 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
8523 &known,
8524 &mut unchecked,
8525 &UnrecoveredFills::default(),
8526 &tx,
8527 is_isolated,
8528 )
8529 .await;
8530
8531 let sent: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
8532 let [event] = sent.as_slice() else {
8533 panic!("one order ended: {sent:?}");
8534 };
8535 assert_eq!(event.exchange, ExchangeId::BinanceMargin);
8536 let AccountEventKind::OrderSnapshot(snapshot) = &event.kind else {
8537 panic!("an order snapshot: {event:?}");
8538 };
8539 assert_eq!(snapshot.0.key.cid, ClientOrderId::new("gone"));
8540 assert_eq!(snapshot.0.key.strategy, StrategyId::unknown());
8541 assert!(matches!(
8542 snapshot.0.state,
8543 OrderState::Inactive(crate::order::state::InactiveOrderState::Cancelled(_))
8544 ));
8545 let mut requests = margin_requests(&server).await;
8546 requests.sort();
8547 let expected = |path: &str| (path.to_owned(), Some(flag.to_owned()));
8548 assert_eq!(
8549 requests,
8550 [
8551 expected("/sapi/v1/margin/openOrders"),
8552 expected("/sapi/v1/margin/order"),
8553 expected("/sapi/v1/margin/order"),
8554 ],
8555 "one listing, and a lookup of each unlisted order only"
8556 );
8557 let known = known.lock();
8558 assert!(known.contains(&ClientOrderId::new("live")));
8559 assert!(!known.contains(&ClientOrderId::new("gone")));
8560 assert!(
8561 !known.contains(&ClientOrderId::new("unknown")),
8562 "asking again cannot help"
8563 );
8564 assert!(unchecked.is_empty());
8565 }
8566 }
8567
8568 fn margin_open_request<'a>(
8569 instrument: &'a InstrumentNameExchange,
8570 cid: &str,
8571 ) -> OrderRequestOpen<ExchangeId, &'a InstrumentNameExchange> {
8572 OrderRequestOpen {
8573 key: OrderKey::new(
8574 ExchangeId::BinanceMargin,
8575 instrument,
8576 StrategyId::new("strategy"),
8577 ClientOrderId::new(cid),
8578 ),
8579 state: crate::order::request::RequestOpen::new(
8580 Side::Buy,
8581 Some(Decimal::from(100)),
8582 Decimal::TWO,
8583 OrderKind::Limit,
8584 TimeInForce::GoodUntilCancelled { post_only: false },
8585 ),
8586 }
8587 }
8588
8589 #[tokio::test]
8592 async fn a_margin_trailing_stop_is_sent_with_trailing_delta() {
8593 let server = wiremock::MockServer::start().await;
8594 let sent_as_trailing_stop = |request: &wiremock::Request| {
8595 let mut body_as_query = request.url.clone();
8597 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
8598 let params: Vec<(String, String)> = request
8599 .url
8600 .query_pairs()
8601 .chain(body_as_query.query_pairs())
8602 .map(|(k, v)| (k.into_owned(), v.into_owned()))
8603 .collect();
8604 let has = |key: &str, value: &str| params.iter().any(|(k, v)| k == key && v == value);
8605 has("type", "STOP_LOSS")
8606 && has("trailingDelta", "25")
8607 && has("sideEffectType", "NO_SIDE_EFFECT")
8608 && !params.iter().any(|(k, _)| k == "stopPrice" || k == "price")
8609 };
8610 wiremock::Mock::given(wiremock::matchers::method("POST"))
8611 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8612 .and(sent_as_trailing_stop)
8613 .respond_with(
8614 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8615 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "trailing",
8616 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "2",
8617 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
8618 "timeInForce": "GTC", "type": "STOP_LOSS", "side": "SELL",
8619 })),
8620 )
8621 .expect(1)
8622 .mount(&server)
8623 .await;
8624 let client = margin_client_at(&server, false);
8625 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8626 let trailing_request = |cid, offset| {
8627 let mut request = margin_open_request(&btcusdt, cid);
8628 request.state.side = Side::Sell;
8629 request.state.price = None;
8630 request.state.kind = OrderKind::TrailingStop {
8631 offset,
8632 offset_type: TrailingOffsetType::BasisPoints,
8633 activation_price: None,
8634 };
8635 request
8636 };
8637
8638 let placed = client
8639 .open_order(trailing_request("trailing", Decimal::from(25)))
8640 .await;
8641 assert!(
8642 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8643 "{placed:?}"
8644 );
8645
8646 let refused = client
8647 .open_order(trailing_request("fractional", Decimal::new(25, 1)))
8648 .await;
8649 assert!(
8650 matches!(
8651 refused.state,
8652 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
8653 OrderError::InvalidPrecision(_)
8654 ))
8655 ),
8656 "{refused:?}"
8657 );
8658 }
8659
8660 #[tokio::test]
8664 async fn a_request_margin_effect_picks_the_side_effect_type_sent() {
8665 let server = wiremock::MockServer::start().await;
8666 let sends = |wire: &'static str, auto_repay_at_cancel: Option<&'static str>| {
8667 move |request: &wiremock::Request| {
8668 let mut body_as_query = request.url.clone();
8669 body_as_query.set_query(Some(&String::from_utf8_lossy(&request.body)));
8670 let params: Vec<(String, String)> = request
8671 .url
8672 .query_pairs()
8673 .chain(body_as_query.query_pairs())
8674 .map(|(k, v)| (k.into_owned(), v.into_owned()))
8675 .collect();
8676 let value = |key: &str| {
8677 params
8678 .iter()
8679 .find(|(k, _)| k == key)
8680 .map(|(_, v)| v.as_str())
8681 };
8682 value("sideEffectType") == Some(wire)
8683 && value("autoRepayAtCancel") == auto_repay_at_cancel
8684 }
8685 };
8686 for (matcher, cid) in [
8687 (sends("MARGIN_BUY", Some("true")), "borrow"),
8688 (sends("AUTO_REPAY", None), "repay"),
8689 (sends("NO_SIDE_EFFECT", None), "default"),
8690 ] {
8691 wiremock::Mock::given(wiremock::matchers::method("POST"))
8692 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8693 .and(matcher)
8694 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
8695 serde_json::json!({
8696 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
8697 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
8698 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
8699 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
8700 }),
8701 ))
8702 .expect(1)
8703 .mount(&server)
8704 .await;
8705 }
8706 let client = margin_client_at(&server, false);
8707 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8708
8709 for (cid, effect) in [
8710 ("borrow", Some(MarginEffect::Borrow)),
8711 ("repay", Some(MarginEffect::Repay)),
8712 ("default", None),
8713 ] {
8714 let mut request = margin_open_request(&btcusdt, cid);
8715 request.state.margin_effect = effect;
8716 let placed = client.open_order(request).await;
8717 assert!(
8718 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8719 "{cid}: {placed:?}"
8720 );
8721 }
8722 }
8723
8724 use crate::error::{Bound, BoundsViolation, OrderField, PrecisionLimit, PrecisionViolation};
8725 use rust_decimal_macros::dec;
8726
8727 fn btcusdt_exchange_info() -> wiremock::ResponseTemplate {
8729 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8730 "timezone": "UTC", "serverTime": 1_700_000_000_000_i64,
8731 "symbols": [{
8732 "symbol": "BTCUSDT", "status": "TRADING",
8733 "filters": [
8734 {"filterType": "PRICE_FILTER", "minPrice": "0.01", "maxPrice": "0", "tickSize": "0.01"},
8735 {"filterType": "LOT_SIZE", "minQty": "0.001", "maxQty": "100", "stepSize": "0.001"},
8736 {"filterType": "NOTIONAL", "minNotional": "5", "applyMinToMarket": true,
8737 "maxNotional": "9000000", "applyMaxToMarket": false, "avgPriceMins": 5},
8738 ],
8739 }],
8740 }))
8741 }
8742
8743 async fn mount_order_accepted(server: &wiremock::MockServer, expected: u64) {
8744 wiremock::Mock::given(wiremock::matchers::method("POST"))
8745 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8746 .respond_with(
8747 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8748 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "c",
8749 "transactTime": 1_700_000_000_000_i64, "price": "50000", "origQty": "2",
8750 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
8751 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
8752 })),
8753 )
8754 .expect(expected)
8755 .mount(server)
8756 .await;
8757 }
8758
8759 #[tokio::test]
8762 async fn an_order_breaking_the_symbols_rules_is_refused_unsent() {
8763 let server = wiremock::MockServer::start().await;
8764 wiremock::Mock::given(wiremock::matchers::method("GET"))
8765 .and(wiremock::matchers::path("/api/v3/exchangeInfo"))
8766 .and(wiremock::matchers::query_param("symbols", r#"["BTCUSDT"]"#))
8767 .respond_with(btcusdt_exchange_info())
8768 .expect(1)
8769 .mount(&server)
8770 .await;
8771 mount_order_accepted(&server, 1).await;
8772 let client = margin_client_at(&server, false);
8773 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8774 let order = |cid: &str, price: Decimal, quantity: Decimal| {
8775 let mut request = margin_open_request(&btcusdt, cid);
8776 request.state.price = Some(price);
8777 request.state.quantity = quantity;
8778 request
8779 };
8780
8781 let off_tick = client.open_order(order("a", dec!(100.001), dec!(1))).await;
8782 assert_eq!(
8783 off_tick.state,
8784 OrderState::inactive(OrderError::InvalidPrecision(PrecisionViolation::new(
8785 OrderField::Price,
8786 dec!(100.001),
8787 PrecisionLimit::NotOnGrid { step: dec!(0.01) }
8788 )))
8789 );
8790 let dust = client.open_order(order("b", dec!(100), dec!(0.001))).await;
8791 assert_eq!(
8792 dust.state,
8793 OrderState::inactive(OrderError::OutOfBounds(BoundsViolation::new(
8794 OrderField::Notional,
8795 Bound::Min,
8796 dec!(5),
8797 dec!(0.100)
8798 )))
8799 );
8800 let placed = client.open_order(order("c", dec!(100), dec!(1))).await;
8801 assert!(
8802 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8803 "{placed:?}"
8804 );
8805 }
8806
8807 #[tokio::test]
8809 async fn with_the_rules_off_every_order_is_sent_and_no_rules_are_read() {
8810 let server = wiremock::MockServer::start().await;
8811 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
8812 .respond_with(btcusdt_exchange_info())
8813 .expect(0)
8814 .mount(&server)
8815 .await;
8816 mount_order_accepted(&server, 1).await;
8817 let mut client = margin_client_at(&server, false);
8818 client.rules = rules_at(&server, PreSendRules::Off);
8819 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8820 let mut request = margin_open_request(&btcusdt, "x");
8821 request.state.price = Some(dec!(100.001));
8822 let placed = client.open_order(request).await;
8823 assert!(
8824 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8825 "{placed:?}"
8826 );
8827 }
8828
8829 #[tokio::test]
8831 async fn an_order_whose_rules_cannot_be_loaded_is_sent_unchecked() {
8832 let server = wiremock::MockServer::start().await;
8833 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
8834 .respond_with(wiremock::ResponseTemplate::new(500))
8835 .mount(&server)
8836 .await;
8837 mount_order_accepted(&server, 1).await;
8838 let client = margin_client_at(&server, false);
8839 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8840 let mut request = margin_open_request(&btcusdt, "x");
8841 request.state.price = Some(dec!(100.001));
8842 let placed = client.open_order(request).await;
8843 assert!(
8844 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8845 "{placed:?}"
8846 );
8847 }
8848
8849 #[tokio::test]
8851 async fn load_rules_returns_the_rules_and_the_first_order_reuses_them() {
8852 let server = wiremock::MockServer::start().await;
8853 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
8854 .respond_with(btcusdt_exchange_info())
8855 .expect(1)
8856 .mount(&server)
8857 .await;
8858 mount_order_accepted(&server, 0).await;
8859 let client = margin_client_at(&server, false);
8860 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8861
8862 let loaded = client.load_rules(std::slice::from_ref(&btcusdt)).await;
8863 let rules = loaded.ok().and_then(|mut loaded| loaded.remove(&btcusdt));
8864 assert_eq!(
8865 rules.map(|rules| (rules.price.step, rules.quantity.step, rules.notional.min)),
8866 Some((Some(dec!(0.01)), Some(dec!(0.001)), Some(dec!(5))))
8867 );
8868
8869 let mut request = margin_open_request(&btcusdt, "x");
8870 request.state.price = Some(dec!(100.001));
8871 let refused = client.open_order(request).await;
8872 assert!(
8873 matches!(refused.state, OrderState::Inactive(_)),
8874 "{refused:?}"
8875 );
8876 }
8877
8878 #[tokio::test]
8880 async fn load_rules_of_no_symbols_sends_no_request() {
8881 let server = wiremock::MockServer::start().await;
8882 wiremock::Mock::given(wiremock::matchers::path("/api/v3/exchangeInfo"))
8883 .respond_with(btcusdt_exchange_info())
8884 .expect(0)
8885 .mount(&server)
8886 .await;
8887 let client = margin_client_at(&server, false);
8888 assert_eq!(client.load_rules(&[]).await, Ok(HashMap::new()));
8889 }
8890
8891 #[tokio::test]
8895 async fn a_cross_refusal_of_a_trailing_stop_is_an_unsupported_order_type() {
8896 let server = wiremock::MockServer::start().await;
8897 wiremock::Mock::given(wiremock::matchers::method("POST"))
8898 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8899 .respond_with(
8900 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
8901 "code": -3111,
8902 "msg": "Trailing stop order only supports limit type.",
8903 })),
8904 )
8905 .expect(1)
8906 .mount(&server)
8907 .await;
8908 let client = margin_client_at(&server, false);
8909 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8910 let mut request = margin_open_request(&btcusdt, "trailing");
8911 request.state.side = Side::Sell;
8912 request.state.price = None;
8913 request.state.kind = OrderKind::TrailingStop {
8914 offset: Decimal::from(25),
8915 offset_type: TrailingOffsetType::BasisPoints,
8916 activation_price: None,
8917 };
8918
8919 let refused = client.open_order(request).await;
8920
8921 let OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
8922 OrderError::UnsupportedOrderType(msg),
8923 )) = &refused.state
8924 else {
8925 panic!("expected UnsupportedOrderType: {refused:?}");
8926 };
8927 assert!(
8928 msg.contains("-3111") && msg.contains("only supports limit type"),
8929 "{msg}"
8930 );
8931 }
8932
8933 #[tokio::test]
8934 async fn placing_listing_and_cancelling_margin_orders_feed_the_known_live_set() {
8935 let server = wiremock::MockServer::start().await;
8936 wiremock::Mock::given(wiremock::matchers::method("POST"))
8937 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8938 .respond_with(
8939 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8940 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "placed",
8941 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
8942 "executedQty": "0", "cummulativeQuoteQty": "0", "status": "NEW",
8943 "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
8944 })),
8945 )
8946 .mount(&server)
8947 .await;
8948 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
8949 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
8950 .respond_with(
8951 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
8952 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
8953 "clientOrderId": "cancel", "price": "100", "origQty": "2", "executedQty": "0",
8954 "cummulativeQuoteQty": "0", "status": "CANCELED", "timeInForce": "GTC",
8955 "type": "LIMIT", "side": "BUY",
8956 })),
8957 )
8958 .mount(&server)
8959 .await;
8960 wiremock::Mock::given(wiremock::matchers::method("GET"))
8961 .and(wiremock::matchers::path("/sapi/v1/margin/openOrders"))
8962 .respond_with(
8963 wiremock::ResponseTemplate::new(200)
8964 .set_body_json(serde_json::json!([margin_order_row("listed", "NEW")])),
8965 )
8966 .mount(&server)
8967 .await;
8968 let client = margin_client_at(&server, false);
8969 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
8970
8971 let placed = client
8972 .open_order(margin_open_request(&btcusdt, "placed"))
8973 .await;
8974 assert!(
8975 matches!(placed.state, OrderState::Active(ActiveOrderState::Open(_))),
8976 "{placed:?}"
8977 );
8978 assert!(
8979 client
8980 .known_live
8981 .lock()
8982 .contains(&ClientOrderId::new("placed"))
8983 );
8984
8985 client.fetch_open_orders(&[]).await.unwrap();
8986 assert!(
8987 client
8988 .known_live
8989 .lock()
8990 .contains(&ClientOrderId::new("listed"))
8991 );
8992
8993 let cancelled = client
8994 .cancel_order(OrderRequestCancel {
8995 key: OrderKey::new(
8996 ExchangeId::BinanceMargin,
8997 &btcusdt,
8998 StrategyId::new("strategy"),
8999 ClientOrderId::new("placed"),
9000 ),
9001 state: crate::order::request::RequestCancel {
9002 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
9003 },
9004 })
9005 .await;
9006 assert!(cancelled.state.is_ok(), "{cancelled:?}");
9007 assert!(
9008 !client
9009 .known_live
9010 .lock()
9011 .contains(&ClientOrderId::new("placed"))
9012 );
9013 }
9014
9015 #[tokio::test]
9017 async fn a_cancel_response_without_executed_qty_reports_the_fill_unknown() {
9018 let server = wiremock::MockServer::start().await;
9019 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
9020 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
9021 .respond_with(
9022 wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({
9023 "symbol": "BTCUSDT", "orderId": "7", "origClientOrderId": "placed",
9024 "clientOrderId": "cancel", "price": "100", "origQty": "2",
9025 "status": "CANCELED", "timeInForce": "GTC", "type": "LIMIT", "side": "BUY",
9026 })),
9027 )
9028 .mount(&server)
9029 .await;
9030 let client = margin_client_at(&server, false);
9031
9032 let cancelled = client
9033 .cancel_order(OrderRequestCancel {
9034 key: OrderKey::new(
9035 ExchangeId::BinanceMargin,
9036 &InstrumentNameExchange::new("BTCUSDT"),
9037 StrategyId::new("strategy"),
9038 ClientOrderId::new("placed"),
9039 ),
9040 state: crate::order::request::RequestCancel {
9041 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
9042 },
9043 })
9044 .await;
9045 let Ok(cancelled) = cancelled.state else {
9046 panic!("expected Ok, got {cancelled:?}");
9047 };
9048 assert_eq!(cancelled.filled_quantity, None);
9049 }
9050
9051 fn borrowing_answer() -> serde_json::Value {
9054 serde_json::json!({
9055 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": "borrow",
9056 "transactTime": 1_700_000_000_000_i64, "price": "0", "origQty": "0.0001",
9057 "executedQty": "0.0001", "cummulativeQuoteQty": "8", "status": "FILLED",
9058 "timeInForce": "GTC", "type": "MARKET", "side": "SELL", "isIsolated": false,
9059 "selfTradePreventionMode": "EXPIRE_MAKER",
9060 "marginBuyBorrowAmount": "0.00004321", "marginBuyBorrowAsset": "BTC",
9061 "fills": [{
9062 "price": "80000", "qty": "0.0001", "commission": "0.008",
9063 "commissionAsset": "USDT", "tradeId": 1,
9064 }],
9065 })
9066 }
9067
9068 fn borrowing_request(
9070 instrument: &InstrumentNameExchange,
9071 ) -> OrderRequestOpen<ExchangeId, &InstrumentNameExchange> {
9072 let mut request = margin_open_request(instrument, "borrow");
9073 request.state.side = Side::Sell;
9074 request.state.kind = OrderKind::Market;
9075 request.state.price = None;
9076 request.state.quantity = dec!(0.0001);
9077 request.state.margin_effect = Some(MarginEffect::Borrow);
9078 request
9079 }
9080
9081 fn borrowing_answer_without(fields: &[&str]) -> serde_json::Value {
9083 let mut answer = borrowing_answer();
9084 let object = answer.as_object_mut().unwrap();
9085 for field in fields {
9086 object.remove(*field);
9087 }
9088 answer
9089 }
9090
9091 #[tokio::test]
9094 async fn a_margin_placement_reports_what_the_order_borrowed() {
9095 let mut as_a_number = borrowing_answer();
9096 as_a_number["marginBuyBorrowAmount"] = serde_json::json!(5);
9097 let btc = AssetNameExchange::new("BTC");
9098 for (answer, expected) in [
9099 (
9100 borrowing_answer(),
9101 Some(MarginBorrow::new(btc.clone(), dec!(0.00004321))),
9102 ),
9103 (as_a_number, Some(MarginBorrow::new(btc, dec!(5)))),
9104 (
9105 borrowing_answer_without(&["marginBuyBorrowAmount", "marginBuyBorrowAsset"]),
9106 None,
9107 ),
9108 ] {
9109 let server = wiremock::MockServer::start().await;
9110 wiremock::Mock::given(wiremock::matchers::method("POST"))
9111 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
9112 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
9113 .expect(1)
9114 .mount(&server)
9115 .await;
9116 let client = margin_client_at(&server, false);
9117 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
9118
9119 let placement = client.open_margin_order(borrowing_request(&btcusdt)).await;
9120
9121 assert!(
9122 matches!(
9123 placement.order.state,
9124 OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(_))
9125 ),
9126 "{answer}: {placement:?}"
9127 );
9128 assert_eq!(placement.borrowed, expected, "{answer}");
9129 }
9130 }
9131
9132 #[tokio::test]
9135 async fn an_order_answered_with_a_fractional_borrowed_amount_is_read() {
9136 let server = wiremock::MockServer::start().await;
9137 wiremock::Mock::given(wiremock::matchers::method("POST"))
9138 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
9139 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(borrowing_answer()))
9140 .expect(1)
9141 .mount(&server)
9142 .await;
9143 let client = margin_client_at(&server, false);
9144 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
9145
9146 let placed = client.open_order(borrowing_request(&btcusdt)).await;
9147
9148 let OrderState::Inactive(crate::order::state::InactiveOrderState::FullyFilled(filled)) =
9149 placed.state
9150 else {
9151 panic!("expected a filled order, got {placed:?}");
9152 };
9153 assert_eq!(filled.id, OrderId::new("7"));
9154 assert_eq!(filled.filled_quantity, dec!(0.0001));
9155 }
9156
9157 #[tokio::test]
9160 async fn an_open_answered_unreadably_is_an_unknown_outcome() {
9161 for answer in [
9162 serde_json::json!({ "symbol": "BTCUSDT", "clientOrderId": "x" }),
9163 serde_json::json!({ "symbol": "BTCUSDT", "orderId": "not a number" }),
9164 borrowing_answer_without(&["marginBuyBorrowAsset"]),
9165 borrowing_answer_without(&["marginBuyBorrowAmount"]),
9166 ] {
9167 let server = wiremock::MockServer::start().await;
9168 wiremock::Mock::given(wiremock::matchers::method("POST"))
9169 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
9170 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
9171 .expect(1)
9172 .mount(&server)
9173 .await;
9174 let client = margin_client_at(&server, false);
9175 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
9176
9177 let placed = client
9178 .open_margin_order(margin_open_request(&btcusdt, "x"))
9179 .await;
9180
9181 assert!(
9182 matches!(
9183 placed.order.state,
9184 OrderState::Inactive(crate::order::state::InactiveOrderState::OpenFailed(
9185 OrderError::Connectivity(_)
9186 ))
9187 ),
9188 "{answer}: {placed:?}"
9189 );
9190 assert_eq!(placed.borrowed, None);
9191 }
9192 }
9193
9194 #[tokio::test]
9197 async fn a_cancel_answered_unreadably_is_an_unknown_outcome() {
9198 for answer in [
9199 serde_json::json!({ "symbol": "BTCUSDT", "status": "CANCELED" }),
9200 serde_json::json!({ "symbol": "BTCUSDT", "orderId": ["7"] }),
9201 ] {
9202 let server = wiremock::MockServer::start().await;
9203 mount_order_accepted(&server, 1).await;
9204 wiremock::Mock::given(wiremock::matchers::method("DELETE"))
9205 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
9206 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(&answer))
9207 .expect(1)
9208 .mount(&server)
9209 .await;
9210 let client = margin_client_at(&server, false);
9211 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
9212 client.open_order(margin_open_request(&btcusdt, "c")).await;
9213
9214 let cancelled = client
9215 .cancel_order(OrderRequestCancel {
9216 key: OrderKey::new(
9217 ExchangeId::BinanceMargin,
9218 &btcusdt,
9219 StrategyId::new("strategy"),
9220 ClientOrderId::new("c"),
9221 ),
9222 state: crate::order::request::RequestCancel {
9223 id: Some(VenueOrderId::Assigned(OrderId::new("7"))),
9224 },
9225 })
9226 .await;
9227
9228 assert!(
9229 matches!(cancelled.state, Err(OrderError::Connectivity(_))),
9230 "{answer}: {cancelled:?}"
9231 );
9232 assert!(client.known_live.lock().contains(&ClientOrderId::new("c")));
9233 }
9234 }
9235
9236 #[tokio::test]
9239 async fn the_new_order_query_is_the_one_the_sdk_sends() {
9240 let server = wiremock::MockServer::start().await;
9241 wiremock::Mock::given(wiremock::matchers::method("POST"))
9242 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
9243 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(serde_json::json!({})))
9244 .expect(2)
9245 .mount(&server)
9246 .await;
9247 let params = MarginAccountNewOrderParams::builder(
9248 "BTCUSDT".to_string(),
9249 MarginAccountNewOrderSideEnum::Sell,
9250 MarginAccountNewOrderTypeEnum::StopLossLimit,
9251 )
9252 .is_isolated(MarginAccountNewOrderIsIsolatedEnum::True)
9253 .quantity(dec!(0.5))
9254 .quote_order_qty(dec!(100.25))
9255 .price(dec!(50000.1))
9256 .stop_price(dec!(49000))
9257 .new_client_order_id("cid".to_string())
9258 .iceberg_qty(dec!(0.1))
9259 .new_order_resp_type(MarginAccountNewOrderNewOrderRespTypeEnum::Full)
9260 .side_effect_type(MarginAccountNewOrderSideEffectTypeEnum::MarginBuy)
9261 .time_in_force(MarginAccountNewOrderTimeInForceEnum::Gtc)
9262 .self_trade_prevention_mode(
9263 binance_sdk::margin_trading::rest_api::MarginAccountNewOrderSelfTradePreventionModeEnum::ExpireMaker,
9264 )
9265 .trailing_delta(25)
9266 .auto_repay_at_cancel(false)
9267 .recv_window(5000)
9268 .build()
9269 .unwrap();
9270
9271 let _ = margin_rest_at(&server)
9272 .margin_account_new_order(params.clone())
9273 .await
9274 .unwrap();
9275 let _ = binance_sdk::common::utils::send_request::<serde_json::Value>(
9276 &margin_rest_config_at(&server),
9277 NEW_ORDER_PATH,
9278 reqwest::Method::POST,
9279 new_order_query(params),
9280 BTreeMap::new(),
9281 None,
9282 true,
9283 )
9284 .await
9285 .unwrap();
9286
9287 let sent: Vec<(Vec<(String, String)>, Vec<u8>)> = server
9289 .received_requests()
9290 .await
9291 .unwrap()
9292 .into_iter()
9293 .map(|request| {
9294 let mut query: Vec<(String, String)> = request
9295 .url
9296 .query_pairs()
9297 .filter(|(k, _)| k != "timestamp" && k != "signature")
9298 .map(|(k, v)| (k.into_owned(), v.into_owned()))
9299 .collect();
9300 query.sort();
9301 (query, request.body)
9302 })
9303 .collect();
9304 assert_eq!(sent.len(), 2);
9305 assert_eq!(sent[0].0.len(), 17, "every field is sent: {:?}", sent[0].0);
9306 assert_eq!(sent[0], sent[1]);
9307 }
9308
9309 #[test]
9310 fn a_reconnect_opens_a_gap_on_each_stream_instrument_and_checks_those_with_orders_held() {
9311 let [btc, eth, xrp] = ["BTCUSDT", "ETHUSDT", "XRPUSDT"].map(InstrumentNameExchange::new);
9312 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
9313 for (instrument, cid) in [(&btc, "btc"), (&xrp, "xrp")] {
9314 known.lock().live(
9315 &margin_key(instrument.name(), cid),
9316 Decimal::TWO,
9317 OrderKind::Limit,
9318 &Open::new(
9319 VenueOrderId::Assigned(OrderId::new(cid)),
9320 Utc::now(),
9321 Decimal::ZERO,
9322 ),
9323 );
9324 }
9325 let mut unrecovered = UnrecoveredFills::default();
9326 let mut unchecked = UncheckedOrders::default();
9327
9328 open_reconnect_recovery(
9329 &mut unrecovered,
9330 &mut unchecked,
9331 &known,
9332 &[btc.clone(), eth.clone()],
9333 Utc::now() - chrono::Duration::minutes(1),
9334 );
9335
9336 assert!(unrecovered.covers(&btc) && unrecovered.covers(ð));
9337 assert!(!unrecovered.covers(&xrp), "not the stream's");
9338 assert!(unchecked.contains(&btc));
9339 assert!(!unchecked.contains(ð), "no order held");
9340 assert!(!unchecked.contains(&xrp), "its fills are not recovered");
9341 }
9342
9343 #[tokio::test]
9346 async fn a_recovered_margin_fill_that_completes_a_held_order_ends_it() {
9347 let disconnect = Utc::now() - chrono::Duration::minutes(10);
9348 let server = wiremock::MockServer::start().await;
9349 wiremock::Mock::given(wiremock::matchers::method("GET"))
9350 .and(wiremock::matchers::path("/sapi/v1/margin/myTrades"))
9351 .respond_with(venue(vec![
9352 (
9353 1,
9354 (disconnect + chrono::Duration::minutes(1)).timestamp_millis(),
9355 ),
9356 (
9357 2,
9358 (disconnect + chrono::Duration::minutes(2)).timestamp_millis(),
9359 ),
9360 ]))
9361 .mount(&server)
9362 .await;
9363 let known = KnownLiveOrders::shared(ExchangeId::BinanceMargin);
9366 for (cid, order_id, quantity) in [("one", "1", "0.01"), ("two", "2", "0.02")] {
9367 known.lock().live(
9368 &margin_key("BTCUSDT", cid),
9369 Decimal::from_str(quantity).unwrap(),
9370 OrderKind::Limit,
9371 &Open::new(
9372 VenueOrderId::Assigned(OrderId::new(order_id)),
9373 Utc::now(),
9374 Decimal::ZERO,
9375 ),
9376 );
9377 }
9378 let mut unrecovered = UnrecoveredFills::default();
9379 unrecovered.open(
9380 &[InstrumentNameExchange::new("BTCUSDT")],
9381 disconnect,
9382 Utc::now(),
9383 );
9384 let (tx, mut rx) = mpsc::unbounded_channel();
9385
9386 recover_margin_fills(
9387 &margin_rest_at(&server),
9388 &Arc::new(RateLimitTracker::new(WeightPool::Sapi)),
9389 &mut unrecovered,
9390 &tx,
9391 &new_dedup_cache(),
9392 &known,
9393 false,
9394 )
9395 .await;
9396
9397 assert_eq!(
9398 std::iter::from_fn(|| rx.try_recv().ok()).count(),
9399 2,
9400 "both fills"
9401 );
9402 let known = known.lock();
9403 assert!(!known.contains(&ClientOrderId::new("one")), "completed");
9404 assert!(known.contains(&ClientOrderId::new("two")), "part-filled");
9405 }
9406
9407 #[tokio::test]
9410 async fn a_margin_order_that_expired_on_placement_is_not_reported_or_held_open() {
9411 use rust_decimal_macros::dec;
9412
9413 for (cid, filled, quote) in [("unfilled", "0", "0"), ("partly", "1", "100")] {
9414 let server = wiremock::MockServer::start().await;
9415 wiremock::Mock::given(wiremock::matchers::method("POST"))
9416 .and(wiremock::matchers::path("/sapi/v1/margin/order"))
9417 .respond_with(wiremock::ResponseTemplate::new(200).set_body_json(
9418 serde_json::json!({
9419 "symbol": "BTCUSDT", "orderId": 7, "clientOrderId": cid,
9420 "transactTime": 1_700_000_000_000_i64, "price": "100", "origQty": "2",
9421 "executedQty": filled, "cummulativeQuoteQty": quote,
9422 "status": "EXPIRED", "timeInForce": "IOC", "type": "LIMIT", "side": "BUY",
9423 }),
9424 ))
9425 .mount(&server)
9426 .await;
9427 let client = margin_client_at(&server, false);
9428 let btcusdt = InstrumentNameExchange::new("BTCUSDT");
9429
9430 let placed = client.open_order(margin_open_request(&btcusdt, cid)).await;
9431
9432 let OrderState::Inactive(crate::order::state::InactiveOrderState::Expired(expired)) =
9433 &placed.state
9434 else {
9435 panic!("{cid}: expired, not open: {placed:?}");
9436 };
9437 assert_eq!(expired.filled_quantity, Decimal::from_str(filled).ok());
9438 let late = Open::new(
9439 VenueOrderId::Assigned(OrderId::new("7")),
9440 Utc::now(),
9441 Decimal::ZERO,
9442 );
9443 let mut known = client.known_live.lock();
9444 known.live(&placed.key, dec!(2), OrderKind::Limit, &late);
9445 assert!(
9446 !known.contains(&ClientOrderId::new(cid)),
9447 "{cid}: not held, even after a late live report"
9448 );
9449 }
9450 }
9451
9452 async fn mount_borrow_terms(
9456 server: &wiremock::MockServer,
9457 pair: serde_json::Value,
9458 asset: serde_json::Value,
9459 rate: serde_json::Value,
9460 max_borrow: serde_json::Value,
9461 ) {
9462 use wiremock::{
9463 Mock, ResponseTemplate,
9464 matchers::{method, path},
9465 };
9466 for (route, body) in [
9467 ("/sapi/v1/margin/allPairs", pair.clone()),
9468 ("/sapi/v1/margin/isolated/allPairs", pair),
9469 ("/sapi/v1/margin/allAssets", asset),
9470 ("/sapi/v1/margin/next-hourly-interest-rate", rate),
9471 ("/sapi/v1/margin/maxBorrowable", max_borrow),
9472 ("/sapi/v1/margin/delist-schedule", serde_json::json!([])),
9473 ] {
9474 Mock::given(method("GET"))
9475 .and(path(route))
9476 .respond_with(ResponseTemplate::new(200).set_body_json(body))
9477 .mount(server)
9478 .await;
9479 }
9480 }
9481
9482 async fn mount_delist_schedule(server: &wiremock::MockServer, schedule: serde_json::Value) {
9485 use wiremock::{
9486 Mock, ResponseTemplate,
9487 matchers::{method, path},
9488 };
9489 Mock::given(method("GET"))
9490 .and(path("/sapi/v1/margin/delist-schedule"))
9491 .respond_with(ResponseTemplate::new(200).set_body_json(schedule))
9492 .with_priority(1)
9493 .mount(server)
9494 .await;
9495 }
9496
9497 fn btcusdt_pair(is_margin_trade: bool, is_sell_allowed: bool) -> serde_json::Value {
9498 serde_json::json!([{
9499 "base": "BTC", "quote": "USDT", "symbol": "BTCUSDT",
9500 "isMarginTrade": is_margin_trade, "isSellAllowed": is_sell_allowed, "isBuyAllowed": true,
9501 }])
9502 }
9503
9504 fn btc_asset(is_borrowable: bool) -> serde_json::Value {
9505 serde_json::json!([{ "assetName": "BTC", "isBorrowable": is_borrowable }])
9506 }
9507
9508 fn btc_rate(rate: &str) -> serde_json::Value {
9509 serde_json::json!([{ "asset": "BTC", "nextHourlyInterestRate": rate }])
9510 }
9511
9512 async fn query_value(server: &wiremock::MockServer, route: &str, key: &str) -> Option<String> {
9514 server
9515 .received_requests()
9516 .await
9517 .unwrap()
9518 .iter()
9519 .find(|request| request.url.path() == route)
9520 .and_then(|request| {
9521 request
9522 .url
9523 .query_pairs()
9524 .find(|(name, _)| name == key)
9525 .map(|(_, value)| value.into_owned())
9526 })
9527 }
9528
9529 #[tokio::test]
9530 async fn margin_shortability_reads_the_pair_the_asset_and_the_annualised_rate() {
9531 use crate::client::ShortabilityClient;
9532 use rust_decimal_macros::dec;
9533 for (is_isolated, pairs_route, flag) in [
9534 (false, "/sapi/v1/margin/allPairs", "FALSE"),
9535 (true, "/sapi/v1/margin/isolated/allPairs", "TRUE"),
9536 ] {
9537 let server = wiremock::MockServer::start().await;
9538 mount_borrow_terms(
9539 &server,
9540 btcusdt_pair(true, true),
9541 btc_asset(true),
9542 btc_rate("0.00000571"),
9543 serde_json::json!({}),
9544 )
9545 .await;
9546
9547 let shortability = margin_client_at(&server, is_isolated)
9548 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9549 .await
9550 .unwrap();
9551
9552 assert_eq!(
9553 shortability,
9554 Shortability::new()
9555 .with_shortable(true)
9556 .with_fee_rate(dec!(0.00000571) * dec!(8760)),
9557 "isolated: {is_isolated}"
9558 );
9559 assert_eq!(
9560 query_value(&server, pairs_route, "symbol").await.as_deref(),
9561 Some("BTCUSDT"),
9562 "the pair list is asked for the one symbol"
9563 );
9564 assert_eq!(
9565 query_value(&server, "/sapi/v1/margin/allAssets", "asset")
9566 .await
9567 .as_deref(),
9568 Some("BTC"),
9569 "the base asset is the one borrowed"
9570 );
9571 let rate = "/sapi/v1/margin/next-hourly-interest-rate";
9572 assert_eq!(
9573 query_value(&server, rate, "assets").await.as_deref(),
9574 Some("BTC")
9575 );
9576 assert_eq!(
9577 query_value(&server, rate, "isIsolated").await.as_deref(),
9578 Some(flag)
9579 );
9580 }
9581 }
9582
9583 #[tokio::test]
9584 async fn margin_shortability_is_refused_by_any_flag_and_unknown_without_them() {
9585 use crate::client::ShortabilityClient;
9586 let cases = [
9587 (btcusdt_pair(false, true), btc_asset(true), Some(false)),
9588 (btcusdt_pair(true, false), btc_asset(true), Some(false)),
9589 (btcusdt_pair(true, true), btc_asset(false), Some(false)),
9590 (btcusdt_pair(true, true), serde_json::json!([]), None),
9592 ];
9593 for (pair, asset, shortable) in cases {
9594 let server = wiremock::MockServer::start().await;
9595 mount_borrow_terms(
9596 &server,
9597 pair.clone(),
9598 asset.clone(),
9599 serde_json::json!([]),
9600 serde_json::json!({}),
9601 )
9602 .await;
9603
9604 let shortability = margin_client_at(&server, false)
9605 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9606 .await
9607 .unwrap();
9608
9609 assert_eq!(shortability.shortable, shortable, "{pair} {asset}");
9610 assert_eq!(
9611 shortability.fee_rate, None,
9612 "no rate returned is an unknown fee"
9613 );
9614 assert_eq!(shortability.easy_to_borrow, None);
9615 assert_eq!(shortability.available, None);
9616 }
9617 }
9618
9619 #[tokio::test]
9623 async fn margin_shortability_reads_the_delisting_schedule_for_this_margin() {
9624 use crate::client::ShortabilityClient;
9625 let past = Utc::now() - chrono::Duration::hours(1);
9626 let soon = Utc::now() + chrono::Duration::days(3);
9627 let later = soon + chrono::Duration::days(7);
9628 let entry = |time: DateTime<Utc>, assets: &[&str], symbols: &[&str]| {
9629 serde_json::json!({
9630 "delistTime": time.timestamp_millis(),
9631 "crossMarginAssets": assets,
9632 "isolatedMarginSymbols": symbols,
9633 })
9634 };
9635 let millis = |time: DateTime<Utc>| DateTime::from_timestamp_millis(time.timestamp_millis());
9636 let cases = [
9637 (
9639 false,
9640 vec![entry(soon, &["BTC"], &[])],
9641 millis(soon),
9642 Some(true),
9643 ),
9644 (
9645 false,
9646 vec![entry(soon, &["USDT"], &[])],
9647 millis(soon),
9648 Some(true),
9649 ),
9650 (
9651 false,
9652 vec![
9653 entry(later, &["BTC"], &[]),
9654 entry(soon, &["ETH", "USDT"], &[]),
9655 ],
9656 millis(soon),
9657 Some(true),
9658 ),
9659 (
9660 false,
9661 vec![entry(soon, &["ETH"], &["BTCUSDT"])],
9662 None,
9663 Some(true),
9664 ),
9665 (
9666 false,
9667 vec![entry(past, &["BTC"], &[])],
9668 millis(past),
9669 Some(false),
9670 ),
9671 (
9673 true,
9674 vec![entry(soon, &[], &["BTCUSDT"])],
9675 millis(soon),
9676 Some(true),
9677 ),
9678 (
9679 true,
9680 vec![entry(soon, &["BTC", "USDT"], &["ETHUSDT"])],
9681 None,
9682 Some(true),
9683 ),
9684 (
9685 true,
9686 vec![entry(past, &[], &["BTCUSDT"])],
9687 millis(past),
9688 Some(false),
9689 ),
9690 ];
9691 for (is_isolated, schedule, delisting_at, shortable) in cases {
9692 let server = wiremock::MockServer::start().await;
9693 mount_borrow_terms(
9694 &server,
9695 btcusdt_pair(true, true),
9696 btc_asset(true),
9697 serde_json::json!([]),
9698 serde_json::json!({}),
9699 )
9700 .await;
9701 let schedule = serde_json::Value::from(schedule);
9702 mount_delist_schedule(&server, schedule.clone()).await;
9703
9704 let shortability = margin_client_at(&server, is_isolated)
9705 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9706 .await
9707 .unwrap();
9708
9709 let case = format!("isolated: {is_isolated}, {schedule}");
9710 assert_eq!(shortability.delisting_at, delisting_at, "{case}");
9711 assert_eq!(shortability.shortable, shortable, "{case}");
9712 }
9713 }
9714
9715 #[tokio::test]
9717 async fn margin_shortability_of_a_scheduled_pair_without_a_time_is_an_error() {
9718 use crate::client::ShortabilityClient;
9719 let server = wiremock::MockServer::start().await;
9720 mount_borrow_terms(
9721 &server,
9722 btcusdt_pair(true, true),
9723 btc_asset(true),
9724 serde_json::json!([]),
9725 serde_json::json!({}),
9726 )
9727 .await;
9728 mount_delist_schedule(
9729 &server,
9730 serde_json::json!([{ "crossMarginAssets": ["BTC"] }]),
9731 )
9732 .await;
9733
9734 let result = margin_client_at(&server, false)
9735 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9736 .await;
9737
9738 assert!(
9739 matches!(result, Err(UnindexedClientError::Internal(ref message))
9740 if message.contains("delistTime")),
9741 "{result:?}"
9742 );
9743 }
9744
9745 #[tokio::test]
9746 async fn margin_shortability_of_an_unlisted_pair_is_an_invalid_instrument() {
9747 use crate::client::ShortabilityClient;
9748 let server = wiremock::MockServer::start().await;
9749 mount_borrow_terms(
9750 &server,
9751 serde_json::json!([]),
9752 btc_asset(true),
9753 btc_rate("0.00000571"),
9754 serde_json::json!({}),
9755 )
9756 .await;
9757
9758 let result = margin_client_at(&server, false)
9759 .fetch_shortability(&InstrumentNameExchange::new("BTCUSDT"))
9760 .await;
9761
9762 assert!(
9763 matches!(
9764 result,
9765 Err(UnindexedClientError::Api(ApiError::InstrumentInvalid(ref instrument, _)))
9766 if instrument.name().as_str() == "BTCUSDT"
9767 ),
9768 "{result:?}"
9769 );
9770 }
9771
9772 #[tokio::test]
9773 async fn margin_borrow_capacity_asks_for_the_asset_the_side_borrows() {
9774 use crate::client::BorrowCapacityClient;
9775 use rust_decimal_macros::dec;
9776 for (is_isolated, side, asset, isolated_symbol) in [
9777 (false, Side::Sell, "BTC", None),
9778 (false, Side::Buy, "USDT", None),
9779 (true, Side::Sell, "BTC", Some("BTCUSDT")),
9780 ] {
9781 let server = wiremock::MockServer::start().await;
9782 mount_borrow_terms(
9783 &server,
9784 btcusdt_pair(true, true),
9785 btc_asset(true),
9786 btc_rate("0.00000571"),
9787 serde_json::json!({ "amount": "1.69248805", "borrowLimit": "60" }),
9788 )
9789 .await;
9790
9791 let capacity = margin_client_at(&server, is_isolated)
9792 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), side)
9793 .await
9794 .unwrap();
9795
9796 assert_eq!(
9797 capacity,
9798 BorrowCapacity::new(AssetNameExchange::new(asset))
9799 .with_borrowable_now(dec!(1.69248805))
9800 .with_account_limit(dec!(60)),
9801 "{side:?} isolated: {is_isolated}"
9802 );
9803 let max_borrow = "/sapi/v1/margin/maxBorrowable";
9804 assert_eq!(
9805 query_value(&server, max_borrow, "asset").await.as_deref(),
9806 Some(asset)
9807 );
9808 assert_eq!(
9809 query_value(&server, max_borrow, "isolatedSymbol")
9810 .await
9811 .as_deref(),
9812 isolated_symbol,
9813 "only isolated margin names the pair's account"
9814 );
9815 }
9816 }
9817
9818 #[tokio::test]
9819 async fn margin_borrow_capacity_keeps_an_omitted_amount_unknown_and_fails_a_malformed_one() {
9820 use crate::client::BorrowCapacityClient;
9821 use rust_decimal_macros::dec;
9822 let instrument = InstrumentNameExchange::new("BTCUSDT");
9823
9824 let server = wiremock::MockServer::start().await;
9825 mount_borrow_terms(
9826 &server,
9827 btcusdt_pair(true, true),
9828 btc_asset(true),
9829 btc_rate("0"),
9830 serde_json::json!({ "borrowLimit": "60" }),
9831 )
9832 .await;
9833 let capacity = margin_client_at(&server, false)
9834 .fetch_borrow_capacity(&instrument, Side::Sell)
9835 .await
9836 .unwrap();
9837 assert_eq!(capacity.borrowable_now, None);
9838 assert_eq!(capacity.account_limit, Some(dec!(60)));
9839
9840 let server = wiremock::MockServer::start().await;
9841 mount_borrow_terms(
9842 &server,
9843 btcusdt_pair(true, true),
9844 btc_asset(true),
9845 btc_rate("0"),
9846 serde_json::json!({ "amount": "lots", "borrowLimit": "60" }),
9847 )
9848 .await;
9849 let result = margin_client_at(&server, false)
9850 .fetch_borrow_capacity(&instrument, Side::Sell)
9851 .await;
9852 assert!(
9853 matches!(result, Err(UnindexedClientError::Internal(ref message)) if message.contains("amount")),
9854 "{result:?}"
9855 );
9856 }
9857
9858 #[tokio::test]
9859 async fn margin_borrow_capacity_without_an_isolated_account_names_the_pair() {
9860 use crate::client::BorrowCapacityClient;
9861 use wiremock::{
9862 Mock,
9863 matchers::{method, path},
9864 };
9865 let server = wiremock::MockServer::start().await;
9866 Mock::given(method("GET"))
9867 .and(path("/sapi/v1/margin/maxBorrowable"))
9868 .respond_with(
9869 wiremock::ResponseTemplate::new(400).set_body_json(serde_json::json!({
9870 "code": -11001, "msg": "Isolated margin account does not exist."
9871 })),
9872 )
9873 .mount(&server)
9874 .await;
9875 mount_borrow_terms(
9876 &server,
9877 btcusdt_pair(true, true),
9878 btc_asset(true),
9879 btc_rate("0"),
9880 serde_json::json!({}),
9881 )
9882 .await;
9883
9884 let err = margin_client_at(&server, true)
9885 .fetch_borrow_capacity(&InstrumentNameExchange::new("BTCUSDT"), Side::Sell)
9886 .await
9887 .unwrap_err();
9888
9889 assert_eq!(
9890 err.to_string(),
9891 "API: request rejected: BTCUSDT: -11001 Isolated margin account does not exist."
9892 );
9893 assert!(!err.is_transient());
9894 }
9895}